use super::project_dir::{
extract_project_dir_from_project_inspect_output, extract_search_files_scanned_dir,
project_inspect_reports_file_entries, search_files_result_no_matches,
};
use super::reflection::{record_tool_error, PendingReflectionRecovery, SemanticFailureInfo};
use super::types::ToolExecutionOutcome;
use crate::agent::loop_utils;
use crate::agent::recall_guardrails::tool_result_indicates_no_evidence;
use crate::agent::*;
use crate::events::TaskOutcome;
use once_cell::sync::Lazy;
use regex::Regex;
fn append_tool_result_notice(result_text: &mut String, notice: &ToolResultNotice) {
result_text.push_str("\n\n");
result_text.push_str(¬ice.render());
}
pub(super) struct ResultLearningEnv<'a> {
pub attempted_required_file_recheck: bool,
pub send_file_key: Option<String>,
pub restrict_to_personal_memory_tools: bool,
pub is_reaffirmation_challenge_turn: bool,
pub session_id: &'a str,
pub task_id: &'a str,
pub emitter: &'a crate::events::EventEmitter,
pub task_start: Instant,
pub iteration: usize,
pub tool_arguments: &'a str,
pub tool_summary: &'a str,
}
pub(super) struct ResultLearningState<'a> {
pub learning_ctx: &'a mut LearningContext,
pub no_evidence_result_streak: &'a mut usize,
pub iteration_had_tool_failures: &'a mut bool,
pub no_evidence_tools_seen: &'a mut HashSet<String>,
pub evidence_gain_count: &'a mut usize,
pub unknown_tools: &'a mut HashSet<String>,
pub tool_failure_count: &'a mut HashMap<String, usize>,
pub tool_failure_signatures: &'a mut HashMap<(String, String), usize>,
pub tool_transient_failure_count: &'a mut HashMap<String, usize>,
pub tool_cooldown_until_iteration: &'a mut HashMap<String, usize>,
pub pending_error_solution_ids: &'a mut Vec<i64>,
pub tool_error_history:
&'a mut HashMap<(String, String), Vec<super::reflection::ToolErrorEntry>>,
pub pending_reflection_recoveries: &'a mut HashMap<String, PendingReflectionRecovery>,
pub tool_failure_patterns: &'a mut HashMap<(String, String), usize>,
pub last_tool_failure: &'a mut Option<(String, String)>,
pub in_session_learned: &'a mut HashSet<(String, String)>,
pub force_text_response: &'a mut bool,
pub pending_system_messages: &'a mut Vec<SystemDirective>,
pub successful_tool_calls: &'a mut usize,
pub total_successful_tool_calls: &'a mut usize,
pub successful_send_file_keys: &'a mut HashSet<String>,
pub cli_agent_boundary_injected: &'a mut bool,
pub recent_tool_calls: &'a mut VecDeque<u64>,
pub consecutive_same_tool: &'a mut (String, usize),
pub consecutive_same_tool_arg_hashes: &'a mut HashSet<u64>,
pub recent_tool_names: &'a mut VecDeque<String>,
pub require_file_recheck_before_answer: &'a mut bool,
pub known_project_dir: &'a mut Option<String>,
pub dirs_with_project_inspect_file_evidence: &'a mut HashSet<String>,
pub dirs_with_search_no_matches: &'a mut HashSet<String>,
}
pub(super) struct ResultLearningOutcome {
pub control_flow: Option<ToolExecutionOutcome>,
pub semantic_failure: Option<SemanticFailureInfo>,
}
fn cli_result_is_substantive(result_text: &str) -> bool {
let cleaned = strip_appended_diagnostics(result_text).trim().to_string();
!cleaned.is_empty()
&& cleaned.chars().count() >= 500
&& !cleaned.to_ascii_lowercase().contains("error:")
&& !cleaned.to_ascii_lowercase().contains("failed")
}
fn looks_like_missing_goal_id_error(tool_name: &str, error_text: &str) -> bool {
if !matches!(
tool_name,
"scheduled_goal_runs" | "manage_memories" | "goal_trace" | "tool_trace"
) {
return false;
}
let lower = error_text.to_ascii_lowercase();
lower.contains("'goal_id' is required")
|| lower.contains("\"goal_id\" is required")
|| lower.contains("goal_id is required")
}
fn user_looks_like_fact_storage_request(user_text: &str) -> bool {
let lower = user_text.to_ascii_lowercase();
contains_keyword_as_words(&lower, "learn this")
|| contains_keyword_as_words(&lower, "learn these")
|| contains_keyword_as_words(&lower, "remember this")
|| contains_keyword_as_words(&lower, "remember these")
|| contains_keyword_as_words(&lower, "remember the following")
|| contains_keyword_as_words(&lower, "remember this for later")
|| contains_keyword_as_words(&lower, "remember these for later")
|| contains_keyword_as_words(&lower, "store this")
|| contains_keyword_as_words(&lower, "store these")
|| contains_keyword_as_words(&lower, "save this")
|| contains_keyword_as_words(&lower, "save these")
|| contains_keyword_as_words(&lower, "note this down")
|| contains_keyword_as_words(&lower, "note these down")
|| contains_keyword_as_words(&lower, "keep in mind")
|| contains_keyword_as_words(&lower, "i need you to know")
}
static ABS_UNIX_PATH_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(r#"/[^\s"'`)\]}\{:,;]+"#).expect("absolute unix path regex must compile")
});
static ABS_WINDOWS_PATH_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(r#"[A-Z]:[\\/][^\s"'`)\]}\{:,;]+"#)
.expect("absolute windows path regex must compile")
});
static LINE_COL_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r":\d+:\d+").expect("line:column regex must compile"));
static ATTEMPT_NUM_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"(?i)\battempt\s+\d+\b").expect("attempt regex must compile"));
static PID_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"(?i)\bpid\s*[=:]\s*\d+\b").expect("pid regex must compile"));
static EXIT_CODE_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"(?i)\bexit(?:\s+code)?\s*[:=]?\s*-?\d+\b").expect("exit code regex must compile")
});
static WHITESPACE_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"\s+").expect("whitespace regex must compile"));
fn path_tail_for_signature(path: &str) -> String {
let tail = path
.trim_matches(|ch| matches!(ch, '"' | '\'' | '`'))
.rsplit(['/', '\\'])
.find(|segment| !segment.is_empty())
.unwrap_or(path);
format!("<path:{}>", tail)
}
fn normalize_error_line_for_signature(line: &str) -> String {
let mut normalized = line.to_ascii_lowercase();
normalized = LINE_COL_RE.replace_all(&normalized, ":<line>").to_string();
normalized = ATTEMPT_NUM_RE
.replace_all(&normalized, "attempt <n>")
.to_string();
normalized = PID_RE.replace_all(&normalized, "pid=<n>").to_string();
normalized = EXIT_CODE_RE
.replace_all(&normalized, "exit <n>")
.to_string();
normalized = ABS_UNIX_PATH_RE
.replace_all(&normalized, |caps: ®ex::Captures<'_>| {
path_tail_for_signature(caps.get(0).map(|m| m.as_str()).unwrap_or_default())
})
.to_string();
normalized = ABS_WINDOWS_PATH_RE
.replace_all(&normalized, |caps: ®ex::Captures<'_>| {
path_tail_for_signature(caps.get(0).map(|m| m.as_str()).unwrap_or_default())
})
.to_string();
WHITESPACE_RE
.replace_all(&normalized, " ")
.trim()
.to_string()
}
fn should_skip_transient_cooldown(tool_name: &str, error_text: &str) -> bool {
let lower = error_text.to_ascii_lowercase();
loop_utils::is_file_lookup_miss_for_tool(tool_name, &lower)
}
fn is_browser_launch_failure(tool_name: &str, error_text: &str) -> bool {
if tool_name != "browser" {
return false;
}
let lower = error_text.to_ascii_lowercase();
lower.contains("failed to launch browser")
|| lower.contains("singletonlock")
|| lower.contains("before websocket url could be resolved")
|| lower.contains("browser process exited")
|| lower.contains("user data directory is already in use")
|| lower.contains("devtoolsactiveport")
|| lower.contains("failed to connect to browser websocket")
}
fn derive_failure_signature(error_text: &str) -> String {
let key_line = extract_key_error_line(error_text);
let normalized = normalize_error_line_for_signature(&key_line);
let trimmed = normalized.trim();
if trimmed.is_empty() {
return "unclassified error".to_string();
}
trimmed.chars().take(160).collect()
}
fn record_semantic_failure_signature(
tool_failure_count: &mut HashMap<String, usize>,
tool_failure_signatures: &mut HashMap<(String, String), usize>,
tool_name: &str,
error_text: &str,
) -> SemanticFailureInfo {
let signature = derive_failure_signature(error_text);
let count = tool_failure_signatures
.entry((tool_name.to_string(), signature.clone()))
.or_insert(0);
*count += 1;
let repeated_count = *count;
let per_tool = tool_failure_count
.entry(tool_name.to_string())
.or_insert(repeated_count);
if repeated_count > *per_tool {
*per_tool = repeated_count;
}
SemanticFailureInfo {
signature,
count: repeated_count,
}
}
#[allow(clippy::too_many_arguments)]
pub(super) async fn apply_result_learning(
agent: &Agent,
tc: &ToolCall,
result_text: &mut String,
is_error: bool,
failure_class: Option<ToolFailureClass>,
execution_failure_kind: Option<ExecutionFailureKind>,
env: &ResultLearningEnv<'_>,
state: &mut ResultLearningState<'_>,
) -> anyhow::Result<ResultLearningOutcome> {
if is_error {
*state.no_evidence_result_streak = 0;
*state.iteration_had_tool_failures = true;
if result_text.contains("Unknown tool '") {
state.unknown_tools.insert(tc.name.clone());
}
let base_error = strip_appended_diagnostics(result_text).to_string();
let failure_class = failure_class.unwrap_or(ToolFailureClass::Semantic);
let mut semantic_failure = None;
match execution_failure_kind {
Some(ExecutionFailureKind::ToolContractFailure) => append_tool_result_notice(
result_text,
&ToolResultNotice::ToolContractFailureRetry {
tool_name: tc.name.clone(),
},
),
Some(ExecutionFailureKind::EnvironmentFailure) => append_tool_result_notice(
result_text,
&ToolResultNotice::EnvironmentFailureGuidance {
tool_name: tc.name.clone(),
},
),
Some(ExecutionFailureKind::LogicFailure) => append_tool_result_notice(
result_text,
&ToolResultNotice::LogicFailureReplan {
tool_name: tc.name.clone(),
},
),
Some(ExecutionFailureKind::ToolInvocationFailure) | None => {}
}
if tc.name == "spawn_agent" {
append_tool_result_notice(result_text, &ToolResultNotice::SpecialistFailurePivot);
}
if matches!(failure_class, ToolFailureClass::Transient) {
state.pending_error_solution_ids.clear();
let transient_count = state
.tool_transient_failure_count
.entry(tc.name.clone())
.or_insert(0);
*transient_count += 1;
if should_skip_transient_cooldown(&tc.name, &base_error) {
state.tool_cooldown_until_iteration.remove(&tc.name);
append_tool_result_notice(
result_text,
&ToolResultNotice::RecoverableFilePathMiss {
tool_name: tc.name.clone(),
},
);
} else {
let cooldown_iters = 2usize;
let cooldown_until = env.iteration.saturating_add(cooldown_iters);
state
.tool_cooldown_until_iteration
.insert(tc.name.clone(), cooldown_until);
append_tool_result_notice(
result_text,
&ToolResultNotice::TransientFailureCooldown {
tool_name: tc.name.clone(),
cooldown_until,
cooldown_iters,
},
);
}
if is_browser_launch_failure(&tc.name, &base_error) {
append_tool_result_notice(result_text, &ToolResultNotice::BrowserLaunchFallback);
}
} else {
let failure = record_semantic_failure_signature(
state.tool_failure_count,
state.tool_failure_signatures,
&tc.name,
&base_error,
);
let semantic_count = failure.count;
semantic_failure = Some(failure.clone());
clear_pending_reflection_recovery_on_failure(
state.pending_reflection_recoveries,
&tc.name,
&failure.signature,
env.iteration,
);
record_tool_error(
state.tool_error_history,
&tc.name,
&failure.signature,
env.iteration,
env.tool_arguments,
&base_error,
);
if semantic_count == 1 && looks_like_missing_goal_id_error(&tc.name, &base_error) {
let likely_fact_storage =
user_looks_like_fact_storage_request(&state.learning_ctx.user_text);
let coach = if likely_fact_storage {
ToolResultNotice::OffTargetFactStorageRequest
} else if tc.name == "manage_memories" {
ToolResultNotice::MissingGoalIdManageMemories
} else {
ToolResultNotice::MissingGoalIdGeneric
};
append_tool_result_notice(result_text, &coach);
}
if tc.name == "web_fetch" || tc.name == "browser" {
let url = serde_json::from_str::<serde_json::Value>(&tc.arguments)
.ok()
.and_then(|v| v.get("url").and_then(|u| u.as_str()).map(|s| s.to_string()))
.unwrap_or_else(|| "<the same URL>".to_string());
append_tool_result_notice(
result_text,
&ToolResultNotice::FetchFailedTryDifferentSource {
tool_name: tc.name.clone(),
url,
},
);
}
if tc.name == "write_file"
&& (base_error.contains("EOF while parsing")
|| base_error.contains("trailing characters")
|| base_error.contains("invalid escape")
|| base_error.contains("control character"))
{
let write_path = serde_json::from_str::<serde_json::Value>(&tc.arguments)
.ok()
.and_then(|v| {
v.get("path")
.and_then(|p| p.as_str())
.map(|s| s.to_string())
})
.unwrap_or_else(|| "<target file>".to_string());
append_tool_result_notice(
result_text,
&ToolResultNotice::WriteFileJsonRecovery { path: write_path },
);
}
if tc.name == "edit_file" {
let edit_path = serde_json::from_str::<serde_json::Value>(&tc.arguments)
.ok()
.and_then(|v| {
v.get("path")
.and_then(|p| p.as_str())
.map(|s| s.to_string())
})
.unwrap_or_else(|| "<same file>".to_string());
if base_error.contains("EOF while parsing")
|| base_error.contains("trailing characters")
|| base_error.contains("invalid escape")
|| base_error.contains("control character")
{
append_tool_result_notice(
result_text,
&ToolResultNotice::EditFileJsonRecovery {
path: edit_path.clone(),
},
);
} else if base_error.contains("Text not found in ") {
append_tool_result_notice(
result_text,
&ToolResultNotice::EditFileTextNotFoundRecovery {
path: edit_path.clone(),
},
);
} else if base_error.contains("Set replace_all=true")
|| base_error.contains("occurrences of the text")
{
append_tool_result_notice(
result_text,
&ToolResultNotice::EditFileReplaceAllRecovery {
path: edit_path.clone(),
},
);
}
}
let err_pattern = crate::memory::procedures::extract_error_pattern(&base_error);
if !err_pattern.trim().is_empty() {
let key = (tc.name.clone(), err_pattern.clone());
let pattern_count = state.tool_failure_patterns.entry(key.clone()).or_insert(0);
*pattern_count += 1;
*state.last_tool_failure = Some(key);
if *pattern_count >= 3 {
let state_store = agent.state.clone();
let tool_name = tc.name.clone();
let error_pattern = err_pattern.clone();
let observed_count = *pattern_count;
tokio::spawn(async move {
let description = format!(
"Repeated {} failures for {} on '{}'; pivot to a different approach earlier.",
observed_count, tool_name, error_pattern
);
let confidence = (0.5 + (observed_count as f32 * 0.05)).min(0.9);
if let Err(e) = state_store
.record_behavior_pattern(
"failure",
&description,
Some(&tool_name),
Some("pivot to alternate tool/strategy"),
confidence,
1,
)
.await
{
warn!(
tool = %tool_name,
error = %e,
"Failed to record failure behavior pattern"
);
}
});
}
}
if semantic_count == 1 {
state.pending_error_solution_ids.clear();
if let Ok(solutions) = agent
.state
.get_relevant_error_solutions(&base_error, 3)
.await
{
if !solutions.is_empty() {
*state.pending_error_solution_ids =
solutions.first().map(|s| s.id).into_iter().collect();
let diagnostic_hints: Vec<String> = solutions
.iter()
.map(|s| {
if let Some(ref steps) = s.solution_steps {
format!(
"- {}\n Steps: {}",
s.solution_summary,
steps.join(" -> ")
)
} else {
format!("- {}", s.solution_summary)
}
})
.collect();
*result_text = format!(
"{}\n\n[DIAGNOSTIC] Similar errors resolved before:\n{}",
result_text,
diagnostic_hints.join("\n")
);
info!(
tool = %tc.name,
solutions_found = solutions.len(),
"Diagnostic loop: injected error solutions"
);
}
}
let since = Utc::now() - chrono::Duration::hours(24);
if let Ok(stats) = agent.event_store.get_tool_stats(&tc.name, since).await {
if stats.total_calls >= 3 {
let failure_pct =
((stats.failed * 100) + (stats.total_calls / 2)) / stats.total_calls;
let mut lines = Vec::new();
lines.push(format!(
"[TOOL STATS] {} (24h): {} calls, {} failed ({}%), avg {}ms",
tc.name,
stats.total_calls,
stats.failed,
failure_pct,
stats.avg_duration_ms
));
for (pattern, count) in stats.common_errors.into_iter().take(2) {
let limit = 100usize;
let head: String = pattern.chars().take(limit).collect();
let preview = if pattern.chars().count() > limit {
format!("{}...", head)
} else {
head
};
lines.push(format!(" - {}x: {}", count, preview));
}
*result_text = format!("{}\n\n{}", result_text, lines.join("\n"));
}
}
let key_line = extract_key_error_line(&base_error);
if let Some(coaching) = format_error_coaching(&key_line) {
append_tool_result_notice(result_text, &coaching);
}
}
if semantic_count >= 2 {
if let Some(solution_id) = state.pending_error_solution_ids.first().copied() {
state.pending_error_solution_ids.clear();
let state_store = agent.state.clone();
tokio::spawn(async move {
if let Err(e) = state_store
.update_error_solution_outcome(solution_id, false)
.await
{
warn!(
solution_id,
error = %e,
"Failed to record error solution failure"
);
}
});
}
let key_line = extract_key_error_line(&base_error);
let coaching = format_semantic_failure_coaching(semantic_count, &key_line);
append_tool_result_notice(result_text, &coaching);
}
}
if state.learning_ctx.first_error.is_none() {
state.learning_ctx.first_error = Some(base_error);
}
state.learning_ctx.errors.push((result_text.clone(), false));
return Ok(ResultLearningOutcome {
control_flow: None,
semantic_failure,
});
}
if env.attempted_required_file_recheck {
*state.require_file_recheck_before_answer = false;
append_tool_result_notice(result_text, &ToolResultNotice::RequiredFileRecheckCompleted);
}
if !env.attempted_required_file_recheck {
let mut contradiction_dir: Option<String> = None;
if tc.name == "project_inspect" {
if let Some(dir) = extract_project_dir_from_project_inspect_output(result_text) {
*state.known_project_dir = Some(dir.clone());
if project_inspect_reports_file_entries(result_text) {
state
.dirs_with_project_inspect_file_evidence
.insert(dir.clone());
if state.dirs_with_search_no_matches.contains(&dir) {
contradiction_dir = Some(dir);
}
}
}
} else if tc.name == "search_files" {
if let Some(dir) = extract_search_files_scanned_dir(result_text) {
*state.known_project_dir = Some(dir.clone());
if search_files_result_no_matches(result_text) {
state.dirs_with_search_no_matches.insert(dir.clone());
if state.dirs_with_project_inspect_file_evidence.contains(&dir) {
contradiction_dir = Some(dir);
}
}
}
}
if let Some(dir) = contradiction_dir {
*state.require_file_recheck_before_answer = true;
let guardrail =
SystemDirective::ContradictoryFileEvidenceExplicitPath { dir: dir.clone() };
state.pending_system_messages.push(guardrail.clone());
*result_text = format!("{}\n\n{}", result_text, guardrail.render());
}
}
let no_evidence_result =
tool_result_indicates_no_evidence(&strip_appended_diagnostics(result_text));
if no_evidence_result {
*state.no_evidence_result_streak = state.no_evidence_result_streak.saturating_add(1);
state.no_evidence_tools_seen.insert(tc.name.clone());
if should_nudge_vary_terms(*state.no_evidence_result_streak) {
let nudge = SystemDirective::EmptyResultStreakVaryTerms {
streak: *state.no_evidence_result_streak,
};
state.pending_system_messages.push(nudge.clone());
*result_text = format!("{}\n\n{}", result_text, nudge.render());
}
} else {
*state.no_evidence_result_streak = 0;
state.no_evidence_tools_seen.clear();
*state.evidence_gain_count = state.evidence_gain_count.saturating_add(1);
}
if env.restrict_to_personal_memory_tools
&& (env.is_reaffirmation_challenge_turn
|| state.no_evidence_tools_seen.len() >= 2
|| *state.no_evidence_result_streak >= 3)
&& no_evidence_result
{
let reaffirmation = if env.is_reaffirmation_challenge_turn {
"I checked again in your stored people/memory records, and I still do not have that information saved. If you want, share it and I will remember it.".to_string()
} else {
"I checked your stored people/memory records, and I do not have that information saved yet. If you share it, I can remember it for next time.".to_string()
};
let assistant_msg = Message {
id: Uuid::new_v4().to_string(),
session_id: env.session_id.to_string(),
role: "assistant".to_string(),
content: Some(reaffirmation.clone()),
tool_call_id: None,
tool_name: None,
tool_calls_json: None,
created_at: Utc::now(),
importance: 0.5,
..Message::runtime_defaults()
};
agent
.append_assistant_message_with_event(env.emitter, &assistant_msg, "system", None, None)
.await?;
agent
.emit_task_end(
env.emitter,
env.task_id,
TaskStatus::Completed,
TaskOutcome::Succeeded,
env.task_start,
env.iteration,
state.learning_ctx.tool_calls.len(),
None,
Some(reaffirmation.chars().take(200).collect()),
)
.await;
return Ok(ResultLearningOutcome {
control_flow: Some(ToolExecutionOutcome::Return(Ok(reaffirmation))),
semantic_failure: None,
});
}
if !env.restrict_to_personal_memory_tools
&& no_evidence_result
&& state.no_evidence_tools_seen.len() >= 5
{
*state.force_text_response = true;
state
.pending_system_messages
.push(SystemDirective::NoEvidenceRespondKnownUnknown);
}
*state.successful_tool_calls += 1;
*state.total_successful_tool_calls += 1;
if tc.name == "send_file" {
if let Some(key) = env.send_file_key.as_ref().cloned() {
state.successful_send_file_keys.insert(key);
}
append_tool_result_notice(
result_text,
&ToolResultNotice::SendFileSucceededStopAndReply,
);
}
if tc.name != "cli_agent" {
*state.cli_agent_boundary_injected = false;
}
if tc.name == "cli_agent" {
state.recent_tool_calls.clear();
*state.consecutive_same_tool = (String::new(), 0);
state.consecutive_same_tool_arg_hashes.clear();
state.recent_tool_names.clear();
if cli_result_is_substantive(result_text) {
let present_results_msg = SystemDirective::CliAgentPresentResults;
state
.pending_system_messages
.push(present_results_msg.clone());
*result_text = format!("{}\n\n{}", result_text, present_results_msg.render());
}
if agent.depth == 0 && !*state.cli_agent_boundary_injected {
let task_hint = build_task_boundary_hint(&state.learning_ctx.user_text, 120);
append_tool_result_notice(
result_text,
&ToolResultNotice::CliAgentInlineBoundary {
task_hint: task_hint.clone(),
},
);
state
.pending_system_messages
.push(SystemDirective::CliAgentTaskBoundary { task_hint });
*state.cli_agent_boundary_injected = true;
}
}
if !state.learning_ctx.errors.is_empty() {
if let Some(solution_id) = state.pending_error_solution_ids.first().copied() {
state.pending_error_solution_ids.clear();
let state_store = agent.state.clone();
tokio::spawn(async move {
if let Err(e) = state_store
.update_error_solution_outcome(solution_id, true)
.await
{
warn!(solution_id, error = %e, "Failed to record error solution success");
}
});
}
let reflection_solution_ids = take_pending_reflection_solution_ids_for_recovery(
state.pending_reflection_recoveries,
&tc.name,
env.iteration,
);
if !reflection_solution_ids.is_empty() {
let state_store = agent.state.clone();
tokio::spawn(async move {
for solution_id in reflection_solution_ids {
if let Err(e) = state_store
.update_error_solution_outcome(solution_id, true)
.await
{
warn!(
solution_id,
error = %e,
"Failed to record reflection learning success"
);
}
}
});
}
if let Some((failed_tool, failed_pattern)) = state.last_tool_failure.take() {
let key = (failed_tool.clone(), failed_pattern.clone());
let failures = state.tool_failure_patterns.get(&key).copied().unwrap_or(0);
if failures >= 3 && tc.name != failed_tool && state.in_session_learned.insert(key) {
let recovery_tool = tc.name.clone();
let solution = crate::memory::procedures::create_error_solution(
failed_pattern,
Some(failed_tool.clone()),
format!(
"After {} errors, recovered via {}",
failed_tool, recovery_tool
),
Some(vec![env.tool_summary.to_string()]),
);
let state_store = agent.state.clone();
tokio::spawn(async move {
if let Err(e) = state_store.insert_error_solution(&solution).await {
warn!(
error_pattern = %solution.error_pattern,
error = %e,
"Failed to save in-session error solution"
);
}
});
info!(
failed_tool = %failed_tool,
recovery_tool = %recovery_tool,
failures,
"In-session error solution learned"
);
}
}
state
.learning_ctx
.recovery_actions
.push(env.tool_summary.to_string());
if let Some((_, recovered)) = state.learning_ctx.errors.last_mut() {
*recovered = true;
}
}
Ok(ResultLearningOutcome {
control_flow: None,
semantic_failure: None,
})
}
fn format_error_coaching(key_line: &str) -> Option<ToolResultNotice> {
if key_line.is_empty() {
return None;
}
let lower = key_line.to_ascii_lowercase();
if lower.contains("permission denied")
|| lower.contains("access denied")
|| lower.contains("operation not permitted")
|| lower.contains("read-only file system")
{
return Some(ToolResultNotice::PermissionDeniedCoaching {
key_line: key_line.to_string(),
});
}
Some(ToolResultNotice::ErrorCoaching {
key_line: key_line.to_string(),
})
}
fn format_semantic_failure_coaching(semantic_count: usize, key_line: &str) -> ToolResultNotice {
ToolResultNotice::SemanticFailureCoaching {
semantic_count,
key_line: key_line.to_string(),
}
}
pub(super) fn expire_stale_pending_reflection_recoveries(
pending_reflection_recoveries: &mut HashMap<String, PendingReflectionRecovery>,
current_iteration: usize,
) {
pending_reflection_recoveries
.retain(|_, pending| pending.verify_on_iteration >= current_iteration);
}
fn clear_pending_reflection_recovery_on_failure(
pending_reflection_recoveries: &mut HashMap<String, PendingReflectionRecovery>,
tool_name: &str,
current_signature: &str,
current_iteration: usize,
) {
if pending_reflection_recoveries
.get(tool_name)
.is_some_and(|pending| {
pending.signature != current_signature
|| pending.verify_on_iteration <= current_iteration
})
{
pending_reflection_recoveries.remove(tool_name);
}
}
fn take_pending_reflection_solution_ids_for_recovery(
pending_reflection_recoveries: &mut HashMap<String, PendingReflectionRecovery>,
tool_name: &str,
current_iteration: usize,
) -> Vec<i64> {
let Some(verify_on_iteration) = pending_reflection_recoveries
.get(tool_name)
.map(|pending| pending.verify_on_iteration)
else {
return Vec::new();
};
if verify_on_iteration != current_iteration {
if verify_on_iteration < current_iteration {
pending_reflection_recoveries.remove(tool_name);
}
return Vec::new();
}
pending_reflection_recoveries
.remove(tool_name)
.map(|pending| pending.solution_ids)
.unwrap_or_default()
}
fn should_nudge_vary_terms(streak: usize) -> bool {
streak > 0 && streak.is_multiple_of(3)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn vary_terms_nudge_fires_at_streak_multiples_of_three() {
assert!(!should_nudge_vary_terms(1));
assert!(!should_nudge_vary_terms(2));
assert!(should_nudge_vary_terms(3));
assert!(!should_nudge_vary_terms(4));
assert!(should_nudge_vary_terms(6));
}
#[test]
fn vary_terms_directive_reorients_search_strategy() {
let rendered = SystemDirective::EmptyResultStreakVaryTerms { streak: 3 }.render();
assert!(rendered.contains("vary"), "must tell model to vary terms");
assert!(
rendered.contains("earlier messages") || rendered.contains("this conversation"),
"must point back at prior conversation evidence"
);
assert!(
rendered.to_lowercase().contains("not evidence of absence"),
"must counter the empty-result-means-missing fallacy"
);
}
#[test]
fn cli_result_substantive_detection_prefers_large_non_error_payloads() {
let payload = "x".repeat(600);
assert!(cli_result_is_substantive(&payload));
assert!(!cli_result_is_substantive("ERROR: agent failed to run"));
assert!(!cli_result_is_substantive("short output"));
}
#[test]
fn missing_goal_id_error_detection_is_tool_scoped() {
assert!(looks_like_missing_goal_id_error(
"scheduled_goal_runs",
"'goal_id' is required for run_history"
));
assert!(!looks_like_missing_goal_id_error(
"remember_fact",
"'goal_id' is required for run_history"
));
}
#[test]
fn detects_fact_storage_language() {
assert!(user_looks_like_fact_storage_request(
"Please remember these for later"
));
assert!(user_looks_like_fact_storage_request("learn this about me"));
assert!(user_looks_like_fact_storage_request("note this down"));
assert!(user_looks_like_fact_storage_request(
"I need you to know this"
));
assert!(!user_looks_like_fact_storage_request(
"run scheduled goals now"
));
}
#[test]
fn browser_launch_failure_detection_matches_singleton_lock() {
let error = "Failed to launch browser: Browser process exited before websocket URL could be resolved, stderr: BrowserStderr(\"SingletonLock: File exists\")";
assert!(is_browser_launch_failure("browser", error));
assert!(!is_browser_launch_failure("web_fetch", error));
assert!(!is_browser_launch_failure("browser", "HTTP 404 Not Found"));
}
#[test]
fn browser_launch_failure_detection_handles_profile_and_devtools_failures() {
assert!(is_browser_launch_failure(
"browser",
"Chrome failed to start: user data directory is already in use"
));
assert!(is_browser_launch_failure(
"browser",
"DevToolsActivePort file doesn't exist; browser process crashed"
));
assert!(is_browser_launch_failure(
"browser",
"failed to connect to browser websocket endpoint"
));
}
#[test]
fn test_repeated_signature_persists_without_reset() {
let mut counts = HashMap::new();
let mut signatures = HashMap::new();
let first = record_semantic_failure_signature(
&mut counts,
&mut signatures,
"read_file",
"Error: missing required field `path`",
);
let second = record_semantic_failure_signature(
&mut counts,
&mut signatures,
"read_file",
"Error: missing required field `path`",
);
assert_eq!(first.count, 1);
assert_eq!(second.count, 2);
assert_eq!(counts.get("read_file").copied(), Some(2));
}
#[test]
fn test_signature_counter_only_rises_on_repeated_same_error() {
let mut counts = HashMap::new();
let mut signatures = HashMap::new();
let first = record_semantic_failure_signature(
&mut counts,
&mut signatures,
"read_file",
"Error: missing required field `path`",
);
let second_unique = record_semantic_failure_signature(
&mut counts,
&mut signatures,
"read_file",
"Error: permission denied",
);
let third_repeat = record_semantic_failure_signature(
&mut counts,
&mut signatures,
"read_file",
"Error: missing required field `path`",
);
assert_eq!(first.count, 1);
assert_eq!(second_unique.count, 1);
assert_eq!(third_repeat.count, 2);
}
#[test]
fn reflection_verification_uses_current_pending_recovery_for_tool() {
let mut pending = HashMap::from([(
"http_request".to_string(),
PendingReflectionRecovery {
signature: "http 401 unauthorized".to_string(),
solution_ids: vec![22, 23],
verify_on_iteration: 4,
},
)]);
let ids =
take_pending_reflection_solution_ids_for_recovery(&mut pending, "http_request", 4);
assert_eq!(ids, vec![22, 23]);
assert!(!pending.contains_key("http_request"));
}
#[test]
fn reflection_verification_skips_later_successes_outside_recovery_turn() {
let mut pending = HashMap::from([(
"http_request".to_string(),
PendingReflectionRecovery {
signature: "http 401 unauthorized".to_string(),
solution_ids: vec![22, 23],
verify_on_iteration: 4,
},
)]);
let ids =
take_pending_reflection_solution_ids_for_recovery(&mut pending, "http_request", 5);
assert!(ids.is_empty());
assert!(!pending.contains_key("http_request"));
}
#[test]
fn changing_failure_signature_clears_pending_reflection_recovery() {
let mut pending = HashMap::from([(
"http_request".to_string(),
PendingReflectionRecovery {
signature: "http 401 unauthorized".to_string(),
solution_ids: vec![22, 23],
verify_on_iteration: 4,
},
)]);
clear_pending_reflection_recovery_on_failure(
&mut pending,
"http_request",
"http 404 not found",
3,
);
assert!(!pending.contains_key("http_request"));
}
#[test]
fn failed_recovery_attempt_clears_pending_reflection_recovery_even_with_same_signature() {
let mut pending = HashMap::from([(
"http_request".to_string(),
PendingReflectionRecovery {
signature: "http 401 unauthorized".to_string(),
solution_ids: vec![22, 23],
verify_on_iteration: 4,
},
)]);
clear_pending_reflection_recovery_on_failure(
&mut pending,
"http_request",
"http 401 unauthorized",
4,
);
assert!(!pending.contains_key("http_request"));
}
#[test]
fn test_signature_distinguishes_different_paths() {
let left =
derive_failure_signature("Error: Text not found in /Users/alice/project/src/a.rs:12:8");
let right =
derive_failure_signature("Error: Text not found in /Users/alice/project/src/b.rs:59:2");
assert_ne!(left, right);
assert!(left.contains("<path:a.rs>"));
assert!(right.contains("<path:b.rs>"));
}
#[test]
fn test_signature_normalizes_line_numbers_and_attempt_ids() {
let a = derive_failure_signature(
"Attempt 1: Error: command failed in /tmp/test/main.rs:10:2 (exit code: 1, pid=923)",
);
let b = derive_failure_signature(
"Attempt 7: Error: command failed in /tmp/test/main.rs:999:88 (exit code: 42, pid=11)",
);
assert_eq!(a, b);
assert!(a.contains("attempt <n>"));
assert!(a.contains(":<line>"));
assert!(a.contains("exit <n>"));
assert!(a.contains("pid=<n>"));
}
#[test]
fn test_file_lookup_miss_skips_transient_cooldown() {
assert!(should_skip_transient_cooldown(
"read_file",
"Error: ENOENT: no such file or directory, open '/tmp/missing.txt'",
));
assert!(!should_skip_transient_cooldown(
"terminal",
"Error: ENOENT: no such file or directory, open '/tmp/missing.txt'",
));
}
#[test]
fn test_error_coaching_quotes_key_line() {
let coaching = format_error_coaching("command not found: drush");
assert!(coaching.is_some());
let msg = coaching.unwrap().render();
assert!(msg.contains("[SYSTEM] IMPORTANT"));
assert!(msg.contains("command not found: drush"));
assert!(msg.contains("DIFFERENT approach"));
}
#[test]
fn test_error_coaching_returns_none_for_empty_key_line() {
assert!(format_error_coaching("").is_none());
}
#[test]
fn test_semantic_failure_coaching_includes_error_context() {
let msg = format_semantic_failure_coaching(3, "Error: ENOENT").render();
assert!(msg.contains("errored 3 semantic times"));
assert!(msg.contains("The error was: \"Error: ENOENT\"."));
assert!(msg.contains("DIFFERENT tool"));
}
#[test]
fn test_semantic_failure_coaching_omits_context_when_empty() {
let msg = format_semantic_failure_coaching(2, "").render();
assert!(msg.contains("errored 2 semantic times"));
assert!(!msg.contains("The error was"));
}
}