use crate::config::HarnessConfig;
use crate::evidence_latch::EvidenceState;
use crate::harness_advisory::HarnessTurnAdvisory;
use crate::harness_loop_policy::resolve_guardrail_config;
use edgecrab_tools::tool_loop_guardrails::ToolLoopGuardrailController;
use edgecrab_types::Message;
#[derive(Debug, Default)]
pub struct DuplicateToolCallDetector {
prev_turn: std::collections::HashMap<(String, u64), String>,
current_turn: std::collections::HashMap<(String, u64), String>,
}
impl DuplicateToolCallDetector {
pub fn new() -> Self {
Self::default()
}
fn hash_args(args: &str) -> u64 {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
args.hash(&mut hasher);
hasher.finish()
}
pub fn check_duplicate(&self, name: &str, args: &str) -> Option<&str> {
let key = (name.to_string(), Self::hash_args(args));
self.prev_turn.get(&key).map(|s| s.as_str())
}
pub fn record(&mut self, name: &str, args: &str, result: &str) {
let key = (name.to_string(), Self::hash_args(args));
self.current_turn.insert(key, result.to_string());
}
pub fn end_turn(&mut self) {
std::mem::swap(&mut self.prev_turn, &mut self.current_turn);
self.current_turn.clear();
}
}
#[derive(Debug)]
pub struct ConsecutiveFailureTracker {
pub count: u32,
max_before_escalation: u32,
last_errors: Vec<String>,
}
impl ConsecutiveFailureTracker {
pub fn new(max: u32) -> Self {
Self {
count: 0,
max_before_escalation: max,
last_errors: Vec::new(),
}
}
pub fn record_failure(&mut self, error_summary: &str) -> bool {
self.count += 1;
self.last_errors.push(error_summary.to_string());
if self.last_errors.len() > 5 {
self.last_errors.remove(0);
}
self.count >= self.max_before_escalation
}
pub fn record_success(&mut self) {
self.count = 0;
self.last_errors.clear();
}
pub fn should_escalate(&self) -> bool {
self.count >= self.max_before_escalation
}
pub fn escalation_message(&self) -> String {
let recent = self
.last_errors
.iter()
.map(|e| format!(" - {e}"))
.collect::<Vec<_>>()
.join("\n");
format!(
"⚠ {count} consecutive tool calls have failed. Recent errors:\n{recent}\n\n\
Please stop retrying with similar arguments. Instead:\n\
1. Re-read the error messages carefully.\n\
2. Consider a completely different approach or tool.\n\
3. If you are stuck, ask the user for guidance.",
count = self.count
)
}
}
#[derive(Debug)]
pub struct TurnDispatchTrackers {
pub failure: ConsecutiveFailureTracker,
pub dedup: DuplicateToolCallDetector,
pub harness_advisory: HarnessTurnAdvisory,
pub tool_guardrail: ToolLoopGuardrailController,
pub guardrail_halt: bool,
pub evidence: EvidenceState,
}
impl TurnDispatchTrackers {
pub fn new(failure_threshold: u32) -> Self {
Self::with_harness(failure_threshold, &HarnessConfig::default())
}
pub fn with_harness(failure_threshold: u32, harness: &HarnessConfig) -> Self {
Self {
failure: ConsecutiveFailureTracker::new(failure_threshold),
dedup: DuplicateToolCallDetector::new(),
harness_advisory: HarnessTurnAdvisory::new(),
tool_guardrail: ToolLoopGuardrailController::new(resolve_guardrail_config(harness)),
guardrail_halt: false,
evidence: EvidenceState::new(harness.evidence_latch_config()),
}
}
pub fn reset_guardrail_turn(&mut self) {
self.tool_guardrail.reset_for_turn();
}
pub fn record_tool_outcome(
&mut self,
tool_name: &str,
args_json: &str,
tool_result: &str,
is_error: bool,
) {
let is_mutation = matches!(tool_name, "write_file" | "patch" | "apply_patch");
self.evidence.count_tool(tool_name, is_mutation);
if is_mutation
&& !is_error
&& let Ok(v) = serde_json::from_str::<serde_json::Value>(tool_result)
&& v.get("ok").and_then(|o| o.as_bool()) == Some(true)
&& let Some(path) = v.get("path").and_then(|p| p.as_str())
{
self.evidence.note_artifact_path(path);
if crate::task_class::is_media_output_path(path) {
let bytes = v.get("bytes").and_then(|b| b.as_u64()).unwrap_or(1);
self.evidence.note_media_artifact_ok(path, bytes.max(1));
}
}
if matches!(tool_name, "read_file" | "file_read") && !is_error {
let path_hint = serde_json::from_str::<serde_json::Value>(args_json)
.ok()
.and_then(|v| {
v.get("path")
.or_else(|| v.get("file_path"))
.and_then(|p| p.as_str())
.map(|s| s.to_string())
})
.or_else(|| {
serde_json::from_str::<serde_json::Value>(tool_result)
.ok()
.and_then(|v| {
v.get("path")
.and_then(|p| p.as_str())
.map(|s| s.to_string())
})
});
if let Some(path) = path_hint
&& let Some(root) = EvidenceState::demo_root_for_path(std::path::Path::new(&path))
{
self.evidence.seed_artifact_from_demo_dir(&root);
}
}
if matches!(tool_name, "terminal" | "run_process") && !is_error {
let cmd_blob = format!("{args_json}\n{tool_result}");
let oracle = crate::completion_assessor::visual_product_oracle_ok(tool_result)
|| (edgecrab_tools::terminal_result_succeeded(tool_result)
&& crate::completion_assessor::visual_product_oracle_command(&cmd_blob));
if oracle {
if let Some(cwd) = edgecrab_tools::parse_terminal_result(tool_result)
.map(|p| p.cwd.to_string())
.filter(|c| !c.is_empty())
{
self.evidence
.seed_artifact_from_demo_dir(std::path::Path::new(&cwd));
}
self.evidence.seed_artifact_from_known_dirs();
if self.evidence.artifact {
self.evidence.set_oracle_ok(true);
if self.evidence.visual_evidence_complete() {
self.evidence.phase = crate::evidence_latch::EvidencePhase::LatchedDone;
}
}
}
}
if matches!(tool_name, "terminal" | "run_process")
&& !is_error
&& let Some(cmd) = edgecrab_tools::dev_server::command_from_tool_args_json(args_json)
&& edgecrab_tools::dev_server::is_preview_server_command(&cmd)
&& let Some(port) =
edgecrab_tools::dev_server::collect_http_server_ports(std::iter::once(cmd.as_str()))
.into_iter()
.next()
{
let dir =
edgecrab_tools::recovery_catalog::infer_preview_serve_directory_from_text(&cmd);
let port = serde_json::from_str::<serde_json::Value>(tool_result)
.ok()
.and_then(|v| v.get("port").and_then(|p| p.as_u64()))
.map(|p| p as u16)
.unwrap_or(port);
self.evidence.note_preview_candidate(
std::path::PathBuf::from(&dir),
port,
format!("http://127.0.0.1:{port}/"),
None,
Some(200),
);
}
if matches!(
tool_name,
"browser_navigate"
| "browser_snapshot"
| "browser_vision"
| "vision_analyze"
| "browser_get_images"
) {
self.harness_advisory
.record_browser_navigate_result(tool_result);
if let Some(parsed) = edgecrab_tools::parse_structured_browser_result(tool_result) {
let url = parsed.final_url.as_deref().unwrap_or("");
self.evidence
.note_perceive(tool_name, url, parsed.content_class);
} else if is_error || edgecrab_types::parse_tool_error_payload(tool_result).is_some() {
let code = edgecrab_types::parse_tool_error_payload(tool_result)
.map(|p| p.code)
.unwrap_or_else(|| "tool_error".into());
self.evidence.note_transport_thrash(tool_name, &code);
}
}
}
}
pub fn apply_guardrail_result(
guardrail: &mut ToolLoopGuardrailController,
tool_name: &str,
args_json: &str,
tool_result: &str,
is_error: bool,
) -> String {
let decision = guardrail.after_call(tool_name, args_json, tool_result, Some(is_error));
edgecrab_tools::tool_loop_guardrails::append_guardrail_guidance(tool_result, &decision)
}
pub fn guardrail_before_dispatch(
guardrail: &ToolLoopGuardrailController,
tool_name: &str,
args_json: &str,
) -> Option<String> {
let decision = guardrail.before_call(tool_name, args_json);
if decision.allows_execution() {
None
} else {
Some(edgecrab_tools::tool_loop_guardrails::guardrail_block_result(&decision))
}
}
pub struct TurnDispatchTrackersView<'a> {
pub harness_advisory: &'a HarnessTurnAdvisory,
pub tool_guardrail: &'a ToolLoopGuardrailController,
pub evidence: &'a EvidenceState,
}
#[deprecated(note = "use turn_dispatch_policy::pre_dispatch_decision")]
pub fn guardrail_before_dispatch_checked(
trackers: &TurnDispatchTrackersView<'_>,
messages: &[Message],
tool_name: &str,
args_json: &str,
) -> Option<String> {
crate::turn_dispatch_policy::pre_dispatch_decision(trackers, messages, tool_name, args_json, "")
}
#[deprecated(note = "use turn_dispatch_policy::pre_dispatch_decision")]
pub fn guardrail_before_dispatch_checked_with_session(
trackers: &TurnDispatchTrackersView<'_>,
messages: &[Message],
tool_name: &str,
args_json: &str,
session_id: &str,
) -> Option<String> {
crate::turn_dispatch_policy::pre_dispatch_decision(
trackers, messages, tool_name, args_json, session_id,
)
}
pub struct ToolTurnFinalizeParams<'a> {
pub messages: &'a mut Vec<edgecrab_types::Message>,
pub tool_turn_start: usize,
pub tool_names: &'a [&'a str],
pub browser_navigate_results: &'a [&'a str],
pub known_dev_ports: &'a [u16],
pub result_turn_budget_chars: usize,
pub spill_config: edgecrab_tools::artifact_spill::SpillConfig,
pub session_id: &'a str,
pub cwd: &'a std::path::Path,
pub spill_seq: &'a crate::tool_result_spill::SpillSequence,
pub max_write_payload_bytes: usize,
pub provider: Option<&'a dyn edgequake_llm::LLMProvider>,
pub argument_loop_blocked: bool,
pub blocked_tool_names: Vec<String>,
}
pub async fn finalize_tool_turn(
trackers: &mut TurnDispatchTrackers,
params: ToolTurnFinalizeParams<'_>,
) {
crate::harness_advisory::apply_harness_advisories(
&mut trackers.harness_advisory,
params.messages,
params.tool_names,
params.browser_navigate_results,
params.known_dev_ports,
);
if params.result_turn_budget_chars > 0 {
let spilled = crate::tool_result_spill::enforce_turn_budget(
&mut params.messages[params.tool_turn_start..],
params.result_turn_budget_chars,
¶ms.spill_config,
params.session_id,
params.cwd,
params.spill_seq,
);
if spilled > 0 {
tracing::info!(
spilled,
turn_budget = params.result_turn_budget_chars,
"per-turn tool result budget enforced"
);
}
}
if params.argument_loop_blocked {
let recovery = edgecrab_tools::mutation_turn_policy::continuation_user_message(
edgecrab_tools::mutation_turn_policy::ContinuationFailureClass::InvalidToolArguments,
¶ms.blocked_tool_names,
params.max_write_payload_bytes,
params.provider,
);
params
.messages
.push(edgecrab_types::Message::user(&recovery));
}
if let Some(halt) =
crate::harness_loop_policy::consume_guardrail_halt_message(&mut trackers.tool_guardrail)
{
trackers.guardrail_halt = true;
params.messages.push(edgecrab_types::Message::user(&halt));
}
finalize_evidence_phase_injections(trackers, params.messages);
}
fn finalize_evidence_phase_injections(
trackers: &mut TurnDispatchTrackers,
messages: &mut Vec<edgecrab_types::Message>,
) {
if !trackers.evidence.is_enabled() {
return;
}
let snap = trackers.evidence.assess_snapshot();
if snap.in_heal && trackers.evidence.may_inject_advisory("heal_phase") {
let msg = trackers.evidence.allowed_action_message();
if !msg.is_empty() {
messages.push(edgecrab_types::Message::user(&msg));
}
}
if trackers.evidence.should_hard_stop() {
trackers.guardrail_halt = true;
if trackers.evidence.may_inject_advisory("evidence_hard_stop") {
let msg = trackers.evidence.allowed_action_message();
if !msg.is_empty() {
messages.push(edgecrab_types::Message::user(&msg));
}
}
} else if snap.latched_done && trackers.evidence.may_inject_advisory("latched_done") {
let msg = trackers.evidence.allowed_action_message();
if !msg.is_empty() {
messages.push(edgecrab_types::Message::user(&msg));
}
}
}
pub fn forward_process_watch_event(
event: edgecrab_tools::process_table::WatchEvent,
ev_tx: &tokio::sync::mpsc::UnboundedSender<crate::agent::StreamEvent>,
) {
use edgecrab_tools::process_table::WatchEventType;
match event.event_type {
WatchEventType::TailPreview => {
let command_preview = crate::safe_truncate(&event.command, 80).to_string();
crate::progress_sink::emit_optional(
Some(ev_tx),
crate::agent::StreamEvent::BackgroundProcessTail {
process_id: event.process_id,
command_preview,
tail: event.matched_output,
},
);
}
WatchEventType::Exited => {
crate::progress_sink::emit_optional(
Some(ev_tx),
crate::agent::StreamEvent::BackgroundProcessFinished {
process_id: event.process_id,
exit_code: event.exit_code,
},
);
}
_ => {
let notice = edgecrab_tools::process_table::format_watch_activity_notice(&event);
crate::progress_sink::emit_activity(Some(ev_tx), notice);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::HarnessConfig;
#[test]
fn consecutive_failure_tracker_escalates_after_threshold() {
let mut tracker = ConsecutiveFailureTracker::new(3);
assert!(!tracker.record_failure("err1"));
assert!(!tracker.record_failure("err2"));
assert!(tracker.record_failure("err3"));
assert!(tracker.escalation_message().contains("consecutive"));
}
#[test]
fn consecutive_failure_tracker_resets_on_success() {
let mut tracker = ConsecutiveFailureTracker::new(3);
tracker.record_failure("err1");
tracker.record_failure("err2");
tracker.record_success();
assert_eq!(tracker.count, 0);
}
#[test]
fn duplicate_detector_finds_prev_turn_match() {
let mut tracker = DuplicateToolCallDetector::new();
tracker.record("read_file", r#"{"path":"a.rs"}"#, "content");
tracker.end_turn();
assert!(
tracker
.check_duplicate("read_file", r#"{"path":"a.rs"}"#)
.is_some()
);
}
#[tokio::test]
async fn ha46_halt_sets_guardrail_halt_flag() {
let cfg = edgecrab_tools::tool_loop_guardrails::ToolLoopGuardrailConfig {
hard_stop_enabled: true,
same_tool_failure_halt_after: 2,
..edgecrab_tools::tool_loop_guardrails::ToolLoopGuardrailConfig::default()
};
let mut trackers = TurnDispatchTrackers::with_harness(3, &HarnessConfig::default());
trackers.tool_guardrail =
edgecrab_tools::tool_loop_guardrails::ToolLoopGuardrailController::new(cfg);
trackers
.tool_guardrail
.after_call("terminal", "{}", "err1", Some(true));
trackers
.tool_guardrail
.after_call("terminal", r#"{"cmd":"ls"}"#, "err2", Some(true));
let mut messages = Vec::new();
finalize_tool_turn(
&mut trackers,
ToolTurnFinalizeParams {
messages: &mut messages,
tool_turn_start: 0,
tool_names: &["terminal"],
browser_navigate_results: &[],
known_dev_ports: &[],
result_turn_budget_chars: 0,
spill_config: edgecrab_tools::artifact_spill::SpillConfig::default(),
session_id: "s",
cwd: std::path::Path::new("."),
spill_seq: &crate::tool_result_spill::SpillSequence::new(),
max_write_payload_bytes: 8000,
provider: None,
argument_loop_blocked: false,
blocked_tool_names: vec![],
},
)
.await;
assert!(trackers.guardrail_halt);
assert!(
messages
.iter()
.any(|m| m.text_content().contains("[harness]"))
);
}
#[test]
fn spill_blind_write_block_before_dispatch() {
let messages = vec![
edgecrab_types::Message::user("read big file"),
edgecrab_types::Message::tool_result(
"r1",
"read_file",
"[tool_result_spill] artifact=.edgecrab/artifacts/s1/read_001.md next_read=read_file",
),
];
let advisory = crate::harness_advisory::HarnessTurnAdvisory::new();
let guardrail = edgecrab_tools::tool_loop_guardrails::ToolLoopGuardrailController::new(
edgecrab_tools::tool_loop_guardrails::ToolLoopGuardrailConfig::default(),
);
let evidence = crate::evidence_latch::EvidenceState::default();
let trackers = TurnDispatchTrackersView {
harness_advisory: &advisory,
tool_guardrail: &guardrail,
evidence: &evidence,
};
#[allow(deprecated)]
let blocked = guardrail_before_dispatch_checked(
&trackers,
&messages,
"write_file",
r#"{"path":"out.rs","content":"x"}"#,
);
assert!(
blocked
.as_deref()
.is_some_and(|b| b.contains("spill_blind") || b.contains("spilled")),
"got {blocked:?}"
);
}
#[test]
fn ha26_forward_process_watch_emits_activity_notice() {
use edgecrab_tools::process_table::{WatchEvent, WatchEventType};
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
forward_process_watch_event(
WatchEvent {
process_id: "bg-1".into(),
command: "python3 -m http.server 8000".into(),
pattern: "Serving HTTP".into(),
matched_output: "Serving HTTP on :: port 8000".into(),
suppressed_count: 0,
event_type: WatchEventType::Match,
exit_code: None,
},
&tx,
);
let event = rx.try_recv().expect("activity notice");
assert!(matches!(
event,
crate::agent::StreamEvent::ActivityNotice(_)
));
}
}