Skip to content

Commit edb71b2

Browse files
Lexus2016Hermes Evolution
andauthored
fix(terminal): classify failures and stop retry spirals (Closes #365) (#368)
- Add tools/terminal_failure_classifier.py with FailureCategory and classify_terminal_failure() for missing_command, permission_denied, persistent_error, retryable_transient, timeout, unknown. - Integrate classifier into terminal_tool foreground retry loop: retry only retryable_transient/timeout, stop and surface category + suggestion for missing_command/permission_denied/persistent_error. - Add per-session terminal streak tracking with helpers and high-streak recommendation to nudge tool switching. - Add tests covering classification and retry/no-retry behavior. Co-authored-by: Hermes Evolution <evolution@hermes.ai>
1 parent 51dcae6 commit edb71b2

3 files changed

Lines changed: 1188 additions & 267 deletions

File tree

Lines changed: 325 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,325 @@
1+
"""Tests for tools.terminal_failure_classifier and terminal_tool integration."""
2+
3+
import json
4+
from unittest.mock import MagicMock
5+
6+
import pytest
7+
8+
import tools.terminal_failure_classifier as classifier
9+
import tools.terminal_tool as terminal_tool
10+
11+
12+
# ---------------------------------------------------------------------------
13+
# Unit tests for the classifier
14+
# ---------------------------------------------------------------------------
15+
16+
17+
class TestClassifyMissingCommand:
18+
def test_exit_code_127(self):
19+
result = classifier.classify_terminal_failure(
20+
"notarealcommand123", 127, "", "bash: notarealcommand123: command not found"
21+
)
22+
assert result.category == classifier.FailureCategory.missing_command
23+
assert result.should_retry is False
24+
25+
def test_stderr_pattern(self):
26+
result = classifier.classify_terminal_failure(
27+
"foo", 1, "", "foo: command not found"
28+
)
29+
assert result.category == classifier.FailureCategory.missing_command
30+
31+
32+
class TestClassifyPermissionDenied:
33+
def test_exit_code_126(self):
34+
result = classifier.classify_terminal_failure(
35+
"/root/secret", 126, "", "bash: /root/secret: Permission denied"
36+
)
37+
assert result.category == classifier.FailureCategory.permission_denied
38+
assert result.should_retry is False
39+
40+
def test_stderr_pattern(self):
41+
result = classifier.classify_terminal_failure(
42+
"cat /etc/shadow", 1, "", "Permission denied"
43+
)
44+
assert result.category == classifier.FailureCategory.permission_denied
45+
46+
47+
class TestClassifyTimeout:
48+
def test_exit_code_124(self):
49+
result = classifier.classify_terminal_failure("sleep 10", 124, "", "")
50+
assert result.category == classifier.FailureCategory.timeout
51+
assert result.should_retry is True
52+
53+
def test_high_streak_becomes_persistent(self):
54+
result = classifier.classify_terminal_failure(
55+
"sleep 10", 124, "", "", consecutive_count=5
56+
)
57+
assert result.category == classifier.FailureCategory.persistent_error
58+
assert result.should_retry is False
59+
60+
61+
class TestClassifyRetryableTransient:
62+
def test_network_unreachable(self):
63+
result = classifier.classify_terminal_failure(
64+
"curl http://example.test", 7, "", "curl: (6) Could not resolve host"
65+
)
66+
assert result.category == classifier.FailureCategory.retryable_transient
67+
assert result.should_retry is True
68+
69+
def test_high_streak_downgrades_to_persistent(self):
70+
result = classifier.classify_terminal_failure(
71+
"curl http://example.test",
72+
7,
73+
"",
74+
"Connection refused",
75+
consecutive_count=4,
76+
)
77+
assert result.category == classifier.FailureCategory.persistent_error
78+
assert result.should_retry is False
79+
80+
81+
class TestClassifyPersistentError:
82+
def test_generic_nonzero(self):
83+
result = classifier.classify_terminal_failure(
84+
"python -c 'raise RuntimeError(\"boom\")'", 1, "", "Traceback"
85+
)
86+
assert result.category == classifier.FailureCategory.persistent_error
87+
assert result.should_retry is False
88+
89+
90+
class TestClassifyInformationalCommands:
91+
@pytest.mark.parametrize(
92+
"command, exit_code",
93+
[
94+
("grep foo bar", 1),
95+
("diff a b", 1),
96+
("git diff", 1),
97+
("test -f missing", 1),
98+
],
99+
)
100+
def test_informational_exits_are_unknown(self, command, exit_code):
101+
result = classifier.classify_terminal_failure(command, exit_code, "", "")
102+
assert result.category == classifier.FailureCategory.unknown
103+
assert result.should_retry is False
104+
105+
106+
class TestStreakRecommendation:
107+
def test_low_streak_no_recommendation(self):
108+
assert classifier.streak_recommendation(1) is None
109+
assert classifier.streak_recommendation(2) is None
110+
111+
def test_high_streak_returns_recommendation(self):
112+
rec = classifier.streak_recommendation(4)
113+
assert rec is not None
114+
assert "4" in rec
115+
assert "read_file" in rec
116+
117+
118+
# ---------------------------------------------------------------------------
119+
# Integration tests for terminal_tool foreground failure handling
120+
# ---------------------------------------------------------------------------
121+
122+
123+
class FakeEnvironment:
124+
"""Minimal environment double for foreground execution tests."""
125+
126+
def __init__(self, responses):
127+
self._responses = list(responses)
128+
self._index = 0
129+
self.calls = []
130+
self.env = {}
131+
self.cwd = ""
132+
133+
def execute(self, command, **kwargs):
134+
self.calls.append((command, kwargs))
135+
# If only one response was provided, repeat it for every call so
136+
# retries see the same failure (and single-exception tests exhaust
137+
# all retries consistently).
138+
if len(self._responses) == 1:
139+
response = self._responses[0]
140+
else:
141+
response = self._responses[self._index]
142+
self._index += 1
143+
if isinstance(response, Exception):
144+
raise response
145+
return response
146+
147+
148+
@pytest.fixture(autouse=True)
149+
def _clean_streaks(monkeypatch):
150+
terminal_tool._reset_terminal_streak("test-streak")
151+
terminal_tool._reset_terminal_streak("default")
152+
yield
153+
terminal_tool._reset_terminal_streak("test-streak")
154+
terminal_tool._reset_terminal_streak("default")
155+
156+
157+
class TestTerminalToolForegroundFailures:
158+
def test_missing_command_stops_without_retry(self, monkeypatch):
159+
monkeypatch.setenv("TERMINAL_ENV", "local")
160+
fake = FakeEnvironment([
161+
{"output": "bash: notreal: command not found", "returncode": 127}
162+
])
163+
monkeypatch.setattr(terminal_tool, "_active_environments", {"default": fake})
164+
165+
result = terminal_tool.terminal_tool("notreal", task_id="test-streak")
166+
data = json.loads(result)
167+
168+
assert data["exit_code"] == 127
169+
assert data["failure_class"] == "missing_command"
170+
assert data["should_retry"] is False
171+
assert "suggestion" in data
172+
assert len(fake.calls) == 1
173+
assert fake.calls[0][0] == "notreal"
174+
assert "timeout" in fake.calls[0][1]
175+
assert "cwd" in fake.calls[0][1]
176+
177+
def test_permission_denied_stops_without_retry(self, monkeypatch):
178+
monkeypatch.setenv("TERMINAL_ENV", "local")
179+
fake = FakeEnvironment([{"output": "Permission denied", "returncode": 126}])
180+
monkeypatch.setattr(terminal_tool, "_active_environments", {"default": fake})
181+
182+
result = terminal_tool.terminal_tool("cat /root/secret", task_id="test-streak")
183+
data = json.loads(result)
184+
185+
assert data["exit_code"] == 126
186+
assert data["failure_class"] == "permission_denied"
187+
assert data["should_retry"] is False
188+
assert len(fake.calls) == 1
189+
assert fake.calls[0][0] == "cat /root/secret"
190+
191+
def test_transient_timeout_retries_then_stops(self, monkeypatch):
192+
monkeypatch.setenv("TERMINAL_ENV", "local")
193+
monkeypatch.setattr(terminal_tool.time, "sleep", lambda _s: None)
194+
# Four consecutive timeouts: initial + 3 retries should exhaust the loop.
195+
fake = FakeEnvironment([
196+
{"output": "", "returncode": 124},
197+
{"output": "", "returncode": 124},
198+
{"output": "", "returncode": 124},
199+
{"output": "", "returncode": 124},
200+
])
201+
monkeypatch.setattr(terminal_tool, "_active_environments", {"default": fake})
202+
monkeypatch.setattr(terminal_tool, "_last_activity", {"default": 0})
203+
204+
result = terminal_tool.terminal_tool(
205+
"slowcmd", timeout=1, task_id="test-streak"
206+
)
207+
data = json.loads(result)
208+
209+
assert data["exit_code"] == 124
210+
assert data["failure_class"] == "timeout"
211+
assert (
212+
data["should_retry"] is False
213+
) # retries exhausted; caller should switch strategy
214+
assert len(fake.calls) == 4
215+
216+
def test_successful_command_resets_streak(self, monkeypatch):
217+
monkeypatch.setenv("TERMINAL_ENV", "local")
218+
fake = FakeEnvironment([{"output": "ok", "returncode": 0}])
219+
monkeypatch.setattr(terminal_tool, "_active_environments", {"default": fake})
220+
221+
# Prime the streak with a prior failure.
222+
terminal_tool._increment_terminal_streak("test-streak")
223+
terminal_tool._increment_terminal_streak("test-streak")
224+
assert terminal_tool.get_terminal_streak("test-streak") == 2
225+
226+
result = terminal_tool.terminal_tool("echo ok", task_id="test-streak")
227+
data = json.loads(result)
228+
229+
assert data["exit_code"] == 0
230+
assert terminal_tool.get_terminal_streak("test-streak") == 0
231+
232+
def test_retryable_transient_retries_until_exhausted(self, monkeypatch):
233+
monkeypatch.setenv("TERMINAL_ENV", "local")
234+
monkeypatch.setattr(terminal_tool.time, "sleep", lambda _s: None)
235+
fake = FakeEnvironment([
236+
{"output": "connection refused", "returncode": 1},
237+
{"output": "connection refused", "returncode": 1},
238+
{"output": "connection refused", "returncode": 1},
239+
{"output": "connection refused", "returncode": 1},
240+
])
241+
monkeypatch.setattr(terminal_tool, "_active_environments", {"default": fake})
242+
243+
result = terminal_tool.terminal_tool("curl http://test", task_id="test-streak")
244+
data = json.loads(result)
245+
246+
assert data["exit_code"] == 1
247+
assert data["failure_class"] == "retryable_transient"
248+
assert data["should_retry"] is False # retries exhausted
249+
assert len(fake.calls) == 4
250+
251+
def test_persistent_error_includes_streak_recommendation(self, monkeypatch):
252+
monkeypatch.setenv("TERMINAL_ENV", "local")
253+
# Bump the streak high enough to trigger the recommendation.
254+
for _ in range(4):
255+
terminal_tool._increment_terminal_streak("test-streak")
256+
257+
fake = FakeEnvironment([{"output": "boom", "returncode": 1}])
258+
monkeypatch.setattr(terminal_tool, "_active_environments", {"default": fake})
259+
260+
result = terminal_tool.terminal_tool(
261+
"python -c 'raise SystemExit(1)'", task_id="test-streak"
262+
)
263+
data = json.loads(result)
264+
265+
assert data["failure_class"] == "persistent_error"
266+
assert data["should_retry"] is False
267+
assert "terminal_streak" in data
268+
assert "recommendation" in data
269+
assert "read_file" in data["recommendation"]
270+
271+
def test_execution_exception_classified(self, monkeypatch):
272+
monkeypatch.setenv("TERMINAL_ENV", "local")
273+
monkeypatch.setattr(terminal_tool.time, "sleep", lambda _s: None)
274+
fake = FakeEnvironment([Exception("connection refused by host")])
275+
monkeypatch.setattr(terminal_tool, "_active_environments", {"default": fake})
276+
277+
result = terminal_tool.terminal_tool("curl http://test", task_id="test-streak")
278+
data = json.loads(result)
279+
280+
assert data["exit_code"] == -1
281+
assert data["failure_class"] == "retryable_transient"
282+
assert data["should_retry"] is False # retries exhausted
283+
284+
def test_execution_exception_timeout_returns_timeout_class(self, monkeypatch):
285+
monkeypatch.setenv("TERMINAL_ENV", "local")
286+
monkeypatch.setattr(terminal_tool.time, "sleep", lambda _s: None)
287+
fake = FakeEnvironment([Exception("command timed out after 1 seconds")])
288+
monkeypatch.setattr(terminal_tool, "_active_environments", {"default": fake})
289+
290+
result = terminal_tool.terminal_tool(
291+
"sleep 99", timeout=1, task_id="test-streak"
292+
)
293+
data = json.loads(result)
294+
295+
assert data["exit_code"] == 124
296+
assert data["failure_class"] == "timeout"
297+
assert data["should_retry"] is True
298+
299+
def test_informational_grep_exit_does_not_classify_as_failure(self, monkeypatch):
300+
monkeypatch.setenv("TERMINAL_ENV", "local")
301+
fake = FakeEnvironment([{"output": "", "returncode": 1}])
302+
monkeypatch.setattr(terminal_tool, "_active_environments", {"default": fake})
303+
304+
result = terminal_tool.terminal_tool("grep foo bar", task_id="test-streak")
305+
data = json.loads(result)
306+
307+
assert data["exit_code"] == 1
308+
assert data.get("failure_class") is None
309+
assert data.get("exit_code_meaning") == "No matches found (not an error)"
310+
311+
312+
class TestTerminalStreakHelpers:
313+
def test_streak_counter_increments_and_resets(self):
314+
terminal_tool._reset_terminal_streak("s1")
315+
assert terminal_tool.get_terminal_streak("s1") == 0
316+
assert terminal_tool._increment_terminal_streak("s1") == 1
317+
assert terminal_tool._increment_terminal_streak("s1") == 2
318+
terminal_tool._reset_terminal_streak("s1")
319+
assert terminal_tool.get_terminal_streak("s1") == 0
320+
321+
def test_streak_default_key(self):
322+
terminal_tool._reset_terminal_streak(None)
323+
assert terminal_tool._increment_terminal_streak(None) == 1
324+
assert terminal_tool.get_terminal_streak() == 1
325+
terminal_tool._reset_terminal_streak(None)

0 commit comments

Comments
 (0)