The salvaged commit adds the `guardrail_refusal` marker to the read-dedup block
and exempts it in `classify_tool_failure`. That classifier is only the fallback:
the executor (`agent/tool_executor.py::_commit_tool_result`) passes
`failed=_detect_tool_failure(...)` from `agent/display.py` into
`after_call`, so on the real path the refusal was still counted and the
escalation to `repeated_exact_failure_block` still fired (live probe: block at
call 8 of an identical read after 5 refusals, on the salvaged head as on main).
- `agent/tool_result_classification.py`: `GUARDRAIL_REFUSAL_KEY` +
`is_guardrail_refusal()` so the marker has one definition and both
classifiers share the predicate.
- `agent/display.py::_detect_tool_failure`: exempt refusals (the live seam).
- `tools/file_tools.py`: the two sibling loop refusals -- the consecutive-read
BLOCK in `read_file_tool` and the repeated-search BLOCK in `search_tool` --
carry the same marker; they are emitted via `tool_error` too and escalated
the same way.
- Tests trimmed to two invariants covering all three refusals on both
classifiers, plus the negative (a genuine error still counts; a non-boolean
marker is ignored).
Approval-denied write blocks (`file_tools_write_guards`) deliberately keep
counting: a user's denial retried unchanged is exactly the loop the streak
exists to stop.