use std::collections::VecDeque;
use std::time::{Duration, Instant};
use crate::task_class::{TaskClass, classify_from_messages, is_verification_tool_for_class};
const PERCEPTION_TOOLS: &[&str] = &[
"browser_navigate",
"browser_snapshot",
"browser_vision",
"browser_click",
"browser_console",
"computer_use",
"capture_screenshot",
"analyze_image",
"vision",
];
const ACT_STORM_TOOLS: &[&str] = &[
"terminal",
"run_process",
"write_file",
"patch",
"apply_patch",
"execute_code",
];
const VERIFICATION_THEATER_TOOLS: &[&str] = &["execute_code", "tool_search"];
#[derive(Debug)]
pub struct HarnessTurnAdvisory {
window: VecDeque<(Instant, String)>,
window_secs: u64,
storm_threshold: usize,
preview_recovery_sent: bool,
last_storm_advisory: Option<Instant>,
browser_nav_failures: u32,
browser_nav_success: bool,
last_browser_fail_class: Option<String>,
}
impl Default for HarnessTurnAdvisory {
fn default() -> Self {
Self::new()
}
}
impl HarnessTurnAdvisory {
pub fn new() -> Self {
Self {
window: VecDeque::new(),
window_secs: 60,
storm_threshold: 5,
preview_recovery_sent: false,
last_storm_advisory: None,
browser_nav_failures: 0,
browser_nav_success: false,
last_browser_fail_class: None,
}
}
pub fn browser_nav_failures(&self) -> u32 {
self.browser_nav_failures
}
pub fn browser_nav_succeeded(&self) -> bool {
self.browser_nav_success
}
pub fn record_browser_navigate_result(&mut self, tool_result: &str) {
if let Some(parsed) = edgecrab_tools::parse_structured_browser_result(tool_result) {
if parsed.ok && parsed.content_class.is_evidence() {
self.browser_nav_success = true;
} else {
self.browser_nav_failures = self.browser_nav_failures.saturating_add(1);
self.last_browser_fail_class =
Some(content_class_label(parsed.content_class).into());
}
return;
}
if let Some(ok) = edgecrab_tools::structured_browser_nav_succeeded(tool_result) {
if ok {
self.browser_nav_success = true;
} else {
self.browser_nav_failures = self.browser_nav_failures.saturating_add(1);
}
return;
}
if let Some(payload) = edgecrab_types::parse_tool_error_payload(tool_result) {
self.browser_nav_failures = self.browser_nav_failures.saturating_add(1);
self.last_browser_fail_class = Some(payload.code);
}
}
fn browser_nav_fail_hint(&self) -> String {
match self.last_browser_fail_class.as_deref() {
Some("http_error_page") => {
"last failure: HTTP error page (wrong process/dir on the port — heal re-serve \
with python3 -m http.server PORT --directory <demo-dir>, not SSRF)."
.into()
}
Some("empty_document") => {
"last failure: empty document (server up but no page content).".into()
}
Some("app_fail") => {
"last failure: app fail overlay (page loaded; product/WebGL issue).".into()
}
Some("chrome_error") => {
"last failure: chrome-error interstitial (navigation/transport).".into()
}
Some("transport_fail") => {
"last failure: transport (CDP/nav net error — not necessarily SSRF).".into()
}
Some(code) if code.contains("ssrf") => {
format!(
"last failure: SSRF/preview policy ({code}) — enable security.preview or grant loopback."
)
}
Some(code) => format!("last failure class: {code}"),
None => {
"last failure: unknown (check content_class / tool_error code — not always SSRF)."
.into()
}
}
}
pub fn record_tool(&mut self, tool_name: &str) {
let now = Instant::now();
self.window.push_back((now, tool_name.to_string()));
let cutoff = now - Duration::from_secs(self.window_secs);
while self.window.front().is_some_and(|(t, _)| *t < cutoff) {
self.window.pop_front();
}
}
pub fn maybe_preview_recovery(
&mut self,
tool_name: &str,
tool_result: &str,
known_ports: &[u16],
) -> Option<String> {
if self.preview_recovery_sent || tool_name != "browser_navigate" {
return None;
}
let payload = edgecrab_types::parse_tool_error_payload(tool_result);
let blocked = payload.as_ref().is_some_and(|p| {
let code = p.code.to_ascii_lowercase();
let cat = p.category.to_ascii_lowercase();
matches!(
code.as_str(),
"ssrf_blocked"
| "ssrf"
| "scheme_not_allowed"
| "url_scheme_blocked"
| "capability_denied"
) || cat == "security"
|| code.starts_with("ssrf")
|| code.contains("scheme_not_allowed")
});
let structured_fail = edgecrab_tools::parse_structured_browser_result(tool_result)
.is_some_and(|r| r.is_chrome_error || !r.ok);
let failed_nav = self.browser_nav_failures > 0 && !self.browser_nav_success;
if !blocked && !structured_fail && !failed_nav && payload.is_none() {
return None;
}
self.preview_recovery_sent = true;
if let Some(hint) = edgecrab_tools::dev_server::format_dev_server_ports_hint(known_ports) {
return Some(format!(
"[harness] browser verification unavailable — do not use file://, execute_code screenshots, \
or read ~/.edgecrab/config.yaml. {hint} Call browser_navigate to that URL, then \
browser_snapshot. Do not try other localhost ports. Do not write markdown \
verification reports."
));
}
let serve_dir = edgecrab_tools::recovery_catalog::infer_preview_serve_directory_from_text(
"",
);
let recipe =
edgecrab_tools::recovery_catalog::preview_serve_then_navigate_recipe(&serve_dir);
let command = recipe
.get("command")
.and_then(|c| c.as_str())
.unwrap_or("python3 -m http.server 8000 --directory .");
Some(format!(
"[harness] browser verification unavailable — no session HTTP server recorded. \
Do not guess localhost ports (8080/5050/5000/…). Call terminal with \
`{command}` (background), then browser_navigate http://127.0.0.1:8000/ and \
browser_snapshot. Ensure security.preview.enabled. Do not write markdown \
verification reports."
))
}
pub fn maybe_loopback_port_shopping_block(
&self,
tool_name: &str,
args_json: &str,
session_ports: &[u16],
serve_directory: &str,
) -> Option<String> {
if tool_name != "browser_navigate" || !session_ports.is_empty() {
return None;
}
if self.browser_nav_success || self.browser_nav_failures < 1 {
return None;
}
let url = serde_json::from_str::<serde_json::Value>(args_json)
.ok()
.and_then(|v| v.get("url").and_then(|u| u.as_str()).map(str::to_string))?;
if !is_loopback_http_url(&url) {
return None;
}
let recipe =
edgecrab_tools::recovery_catalog::preview_serve_then_navigate_recipe(serve_directory);
let command = recipe
.get("command")
.and_then(|c| c.as_str())
.unwrap_or("python3 -m http.server 8000 --directory .");
Some(
edgecrab_tools::tool_loop_guardrails::guardrail_block_result(
&edgecrab_tools::tool_loop_guardrails::ToolGuardrailDecision {
action: edgecrab_tools::tool_loop_guardrails::GuardrailAction::Block,
code: "loopback_port_shopping_block",
message: format!(
"Blocked localhost browser_navigate — no session HTTP server is recorded. \
Do not try other ports. Call terminal with `{command}`, then \
browser_navigate http://127.0.0.1:8000/ once."
),
tool_name: tool_name.to_string(),
count: self.browser_nav_failures,
},
),
)
}
pub fn maybe_verification_theater_block(
&self,
task_class: TaskClass,
tool_name: &str,
) -> Option<String> {
if !matches!(task_class, TaskClass::VisualUx) {
return None;
}
if !VERIFICATION_THEATER_TOOLS.contains(&tool_name) {
return None;
}
if self.browser_nav_success || self.browser_nav_failures < 2 {
return None;
}
Some(
edgecrab_tools::tool_loop_guardrails::guardrail_block_result(
&edgecrab_tools::tool_loop_guardrails::ToolGuardrailDecision {
action: edgecrab_tools::tool_loop_guardrails::GuardrailAction::Block,
code: "verification_theater_block",
message:
"Blocked verification workaround — browser_navigate failed repeatedly. \
Call terminal with `python3 -m http.server 8000 --directory <demo-dir>`, \
then browser_navigate http://127.0.0.1:8000/ and browser_snapshot. \
Do not try other localhost ports. Do not script screenshots via execute_code."
.into(),
tool_name: tool_name.to_string(),
count: self.browser_nav_failures,
},
),
)
}
pub fn maybe_repeated_browser_nav_block(&self, tool_name: &str) -> Option<String> {
if tool_name != "browser_navigate" {
return None;
}
if self.browser_nav_success || self.browser_nav_failures < 3 {
return None;
}
Some(
edgecrab_tools::tool_loop_guardrails::guardrail_block_result(
&edgecrab_tools::tool_loop_guardrails::ToolGuardrailDecision {
action: edgecrab_tools::tool_loop_guardrails::GuardrailAction::Block,
code: "browser_nav_loop_block",
message: format!(
"Blocked repeated browser_navigate — {}. \
Do not thrash alternate ports. If HTTP error page: one heal re-serve of the \
demo directory on the latched port, then browser_snapshot. \
If SSRF: enable security.preview. If CDP: check Chrome headless is running.",
self.browser_nav_fail_hint()
),
tool_name: tool_name.to_string(),
count: self.browser_nav_failures,
},
),
)
}
pub fn act_tool_count_in_window(&self) -> usize {
self.window
.iter()
.filter(|(_, name)| ACT_STORM_TOOLS.contains(&name.as_str()))
.count()
}
fn window_lacks_perception(&self, task_class: TaskClass) -> bool {
!self.window.iter().any(|(_, name)| {
if !is_verification_tool_for_class(name, task_class)
|| !PERCEPTION_TOOLS.contains(&name.as_str())
{
return false;
}
if name == "browser_navigate" {
return self.browser_nav_success;
}
true
})
}
pub fn is_act_storm_without_perception(&self, task_class: TaskClass) -> bool {
if !matches!(task_class, TaskClass::VisualUx | TaskClass::CodeChange) {
return false;
}
self.act_tool_count_in_window() >= self.storm_threshold
&& self.window_lacks_perception(task_class)
}
pub fn maybe_iteration_storm_advisory(&mut self, task_class: TaskClass) -> Option<String> {
if !matches!(task_class, TaskClass::VisualUx | TaskClass::CodeChange) {
return None;
}
if self
.last_storm_advisory
.is_some_and(|t| t.elapsed() < Duration::from_secs(120))
{
return None;
}
if !self.is_act_storm_without_perception(task_class) {
return None;
}
let act_count = self.act_tool_count_in_window();
self.last_storm_advisory = Some(Instant::now());
tracing::warn!(
act_tools = act_count,
window_secs = self.window_secs,
?task_class,
"harness: iteration storm without perception evidence"
);
let recipe = match task_class {
TaskClass::VisualUx => {
"enable preview (/config), browser_navigate to the dev server URL, then \
browser_snapshot. Do not write markdown verification reports."
}
TaskClass::CodeChange => {
"run compile/test gates (cargo test / npm test / pytest) before claiming done. \
Do not open a browser or spawn a static preview server for code_change tasks."
}
_ => "gather class-appropriate verification evidence before claiming done.",
};
Some(format!(
"[harness] {act_count} mutation/discovery tools in the last {window}s without \
verification — stop terminal/config debugging. For {class} tasks: {recipe}",
window = self.window_secs,
class = task_class_label(task_class),
))
}
}
fn content_class_label(c: edgecrab_tools::ContentClass) -> &'static str {
use edgecrab_tools::ContentClass;
match c {
ContentClass::Ok => "ok",
ContentClass::ChromeError => "chrome_error",
ContentClass::HttpErrorPage => "http_error_page",
ContentClass::EmptyDocument => "empty_document",
ContentClass::TransportFail => "transport_fail",
ContentClass::AppFail => "app_fail",
}
}
fn task_class_label(class: TaskClass) -> &'static str {
match class {
TaskClass::VisualUx => "visual_ux",
TaskClass::Document => "document",
TaskClass::CodeChange => "code_change",
TaskClass::MediaRender => "media_render",
TaskClass::Research => "research",
TaskClass::General => "general",
}
}
fn is_loopback_http_url(url: &str) -> bool {
let Ok(parsed) = url::Url::parse(url) else {
return false;
};
if !matches!(parsed.scheme(), "http" | "https") {
return false;
}
match parsed.host_str() {
Some("localhost") | Some("127.0.0.1") | Some("::1") => true,
Some(h) => h.eq_ignore_ascii_case("localhost"),
None => false,
}
}
pub fn apply_harness_advisories(
harness: &mut HarnessTurnAdvisory,
messages: &mut Vec<edgecrab_types::Message>,
tool_names: &[&str],
browser_navigate_results: &[&str],
known_dev_ports: &[u16],
) {
for name in tool_names {
harness.record_tool(name);
}
for result in browser_navigate_results {
harness.record_browser_navigate_result(result);
if let Some(recovery) =
harness.maybe_preview_recovery("browser_navigate", result, known_dev_ports)
{
messages.push(edgecrab_types::Message::user(&recovery));
break;
}
}
let class = classify_from_messages(messages);
if let Some(storm) = harness.maybe_iteration_storm_advisory(class) {
messages.push(edgecrab_types::Message::user(&storm));
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ha16_preview_recovery_on_cdp_failure() {
let mut adv = HarnessTurnAdvisory::new();
let fail = edgecrab_tools::StructuredBrowserResult::navigate_err(
"http://127.0.0.1:8000/",
"CDP connection refused",
)
.to_tool_result_json();
adv.record_browser_navigate_result(&fail);
let msg = adv
.maybe_preview_recovery("browser_navigate", &fail, &[8000])
.expect("cdp recovery");
assert!(msg.contains("browser_navigate"));
assert!(
adv.maybe_preview_recovery("browser_navigate", "again", &[])
.is_none()
);
}
fn tool_error_json(code: &str, error: &str) -> String {
serde_json::json!({
"type": "tool_error",
"category": "execution",
"code": code,
"code_num": 1006,
"error": error,
"retryable": true,
"suppress_retry": false,
})
.to_string()
}
fn structured_nav_fail() -> String {
edgecrab_tools::StructuredBrowserResult::navigate_err(
"http://127.0.0.1:8000/",
"Navigation error: net::ERR_CONNECTION_REFUSED",
)
.to_tool_result_json()
}
#[test]
fn verification_theater_blocks_execute_code_after_browser_failures() {
let mut adv = HarnessTurnAdvisory::new();
let fail = structured_nav_fail();
adv.record_browser_navigate_result(&fail);
adv.record_browser_navigate_result(&fail);
assert!(
adv.maybe_verification_theater_block(TaskClass::VisualUx, "execute_code")
.is_some()
);
assert!(
adv.maybe_verification_theater_block(TaskClass::VisualUx, "write_file")
.is_none()
);
}
#[test]
fn repeated_nav_block_names_http_error_not_ssrf() {
let mut adv = HarnessTurnAdvisory::new();
let fail = edgecrab_tools::StructuredBrowserResult::navigate_ok(
"http://127.0.0.1:8000/",
"Error response",
)
.to_tool_result_json();
for _ in 0..3 {
adv.record_browser_navigate_result(&fail);
}
let block = adv
.maybe_repeated_browser_nav_block("browser_navigate")
.expect("block after 3 fails");
assert!(
block.contains("HTTP error page") || block.contains("http_error"),
"must name content class, not only SSRF: {block}"
);
assert!(
!block.contains("SSRF, CDP, or no session HTTP server"),
"legacy vague SSRF message must be gone: {block}"
);
}
#[test]
fn repeated_browser_nav_blocked_after_three_failures() {
let mut adv = HarnessTurnAdvisory::new();
let fail = structured_nav_fail();
adv.record_browser_navigate_result(&fail);
adv.record_browser_navigate_result(&fail);
assert!(
adv.maybe_repeated_browser_nav_block("browser_navigate")
.is_none()
);
adv.record_browser_navigate_result(&fail);
let block = adv
.maybe_repeated_browser_nav_block("browser_navigate")
.expect("block");
assert!(block.contains("browser_nav_loop_block") || block.contains("Blocked"));
assert!(
adv.maybe_repeated_browser_nav_block("browser_snapshot")
.is_none()
);
}
#[test]
fn ha16_preview_recovery_once_per_session() {
let mut adv = HarnessTurnAdvisory::new();
let err = tool_error_json("ssrf_blocked", "URL blocked by SSRF policy");
assert!(
adv.maybe_preview_recovery("browser_navigate", &err, &[])
.is_some()
);
assert!(
adv.maybe_preview_recovery("browser_navigate", &err, &[])
.is_none()
);
}
#[test]
fn ha20c_preview_recovery_includes_detected_ports() {
let mut adv = HarnessTurnAdvisory::new();
let err = tool_error_json("ssrf_blocked", "URL blocked by SSRF policy");
let msg = adv
.maybe_preview_recovery("browser_navigate", &err, &[8000])
.expect("recovery");
assert!(msg.contains("127.0.0.1:8000"));
}
#[test]
fn empty_ports_preview_recovery_forbids_port_shopping() {
let mut adv = HarnessTurnAdvisory::new();
let err = tool_error_json("execution_failed", "connection refused");
let msg = adv
.maybe_preview_recovery("browser_navigate", &err, &[])
.expect("recovery");
assert!(msg.contains("http.server"));
assert!(msg.contains("127.0.0.1:8000"));
assert!(
msg.contains("Do not guess") || msg.contains("do not guess"),
"must forbid port guessing: {msg}"
);
}
#[test]
fn loopback_port_shopping_blocked_after_one_failure() {
let mut adv = HarnessTurnAdvisory::new();
assert!(
adv.maybe_loopback_port_shopping_block(
"browser_navigate",
r#"{"url":"http://127.0.0.1:8080/"}"#,
&[],
"demo/game002",
)
.is_none()
);
adv.record_browser_navigate_result(&structured_nav_fail());
let block = adv
.maybe_loopback_port_shopping_block(
"browser_navigate",
r#"{"url":"http://127.0.0.1:5050/"}"#,
&[],
"demo/game002",
)
.expect("block port shopping");
assert!(block.contains("loopback_port_shopping_block") || block.contains("Blocked"));
assert!(block.contains("demo/game002") || block.contains("http.server"));
assert!(
adv.maybe_loopback_port_shopping_block(
"browser_navigate",
r#"{"url":"http://127.0.0.1:8000/"}"#,
&[8000],
"demo/game002",
)
.is_none()
);
}
#[test]
fn ha20e_storm_after_five_terminals_without_perception() {
let mut adv = HarnessTurnAdvisory::new();
for _ in 0..6 {
adv.record_tool("terminal");
}
let msg = adv
.maybe_iteration_storm_advisory(TaskClass::VisualUx)
.expect("storm");
assert!(msg.contains("without verification"));
assert!(msg.contains("browser_navigate") || msg.contains("browser_snapshot"));
}
#[test]
fn code_change_storm_does_not_prescribe_browser() {
let mut adv = HarnessTurnAdvisory::new();
for _ in 0..6 {
adv.record_tool("terminal");
}
let msg = adv
.maybe_iteration_storm_advisory(TaskClass::CodeChange)
.expect("storm");
assert!(msg.contains("code_change") || msg.contains("compile") || msg.contains("test"));
assert!(
!msg.contains("browser_navigate"),
"CodeChange must not prescribe browser theater: {msg}"
);
assert!(!msg.contains("dev server"));
}
#[test]
fn apply_harness_injects_preview_and_storm() {
use edgecrab_types::Message;
let mut harness = HarnessTurnAdvisory::new();
let mut messages = vec![Message::user("make demo/games003/index.html beautiful UX")];
let tool_names: Vec<&str> = std::iter::repeat("terminal").take(6).collect();
apply_harness_advisories(
&mut harness,
&mut messages,
&tool_names,
&[r#"{"error":"URL blocked"}"#],
&[8000],
);
let joined: String = messages
.iter()
.map(|m| m.text_content())
.collect::<Vec<_>>()
.join("\n");
assert!(joined.contains("/config") || joined.contains("preview"));
assert!(joined.contains("without verification"));
}
#[test]
fn perception_tool_clears_storm() {
let mut adv = HarnessTurnAdvisory::new();
for _ in 0..4 {
adv.record_tool("terminal");
}
adv.record_tool("browser_snapshot");
adv.record_tool("terminal");
assert!(
adv.maybe_iteration_storm_advisory(TaskClass::VisualUx)
.is_none()
);
}
}