Skip to content

Commit 6ddc2d0

Browse files
authored
Merge pull request #350 from Lexus2016/evolution/issue-347-structural-tool-failure-classifier
fix(introspection): structural tool-failure classifier (Closes #347)
2 parents 4918310 + 0c331a5 commit 6ddc2d0

2 files changed

Lines changed: 123 additions & 30 deletions

File tree

scripts/introspection_extract.py

Lines changed: 49 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -44,14 +44,55 @@
4444
from typing import Any, Dict, List
4545

4646
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
47-
try:
48-
from agent.loop_guard import _looks_like_failure # pure (re + typing only)
49-
except Exception: # pragma: no cover - keep standalone if import path differs
50-
_FALLBACK = ("error:", "failed", "permission denied", "command not found",
51-
"no such file", "timed out", "timeout", "traceback (most recent call")
5247

53-
def _looks_like_failure(content: Any) -> bool: # type: ignore
54-
return isinstance(content, str) and any(m in content.lower() for m in _FALLBACK)
48+
49+
def _tool_result_failed(content: Any) -> bool:
50+
"""Structural failure classifier for a tool result (issue #347).
51+
52+
Every Hermes tool serialises its result as a JSON envelope that already
53+
carries the authoritative status (``exit_code`` for terminal/code-exec,
54+
``error`` and/or ``success``/``status`` for the rest). We read that status
55+
instead of substring-scanning the body.
56+
57+
The old substring matcher scanned the WHOLE envelope string for marker
58+
words ("failed", "error:", "404", "timeout") and fired on file *content*
59+
returned by SUCCESSFUL calls — e.g. ``read_file`` of a page mentioning
60+
"HTTP 404", or a ``grep`` whose stdout contains the word "error:". That
61+
massively over-counted failures and misattributed them to tools with zero
62+
genuine errors (read_file/skill_view), corrupting the introspection signal.
63+
64+
A result counts as a failure ONLY when its structured status says so:
65+
* ``exit_code`` present (terminal/code-exec) → failure iff it is not 0;
66+
* a truthy ``error`` field, or ``status == "error"``;
67+
* an explicit ``success``/``ok`` field that is falsy.
68+
A result with no recognised status field — including any non-JSON / plain
69+
string body — is NOT counted: we no longer guess from content. This trades
70+
a few genuinely-plain-string failures (rare; tools emit JSON envelopes) for
71+
eliminating the false-positive flood, exactly as the issue prescribes.
72+
"""
73+
data = content
74+
if isinstance(data, (str, bytes)):
75+
text = data.decode("utf-8", "replace") if isinstance(data, bytes) else data
76+
stripped = text.strip()
77+
if not (stripped.startswith("{") and stripped.endswith("}")):
78+
return False # not a JSON envelope → no authoritative status → don't guess
79+
try:
80+
data = json.loads(stripped)
81+
except ValueError:
82+
return False
83+
if not isinstance(data, dict):
84+
return False
85+
if "exit_code" in data:
86+
try:
87+
return int(data["exit_code"]) != 0
88+
except (TypeError, ValueError):
89+
return False
90+
if data.get("error") or str(data.get("status", "")).lower() == "error":
91+
return True
92+
for ok_key in ("success", "ok"):
93+
if ok_key in data:
94+
return not bool(data[ok_key])
95+
return False
5596

5697

5798
_TIMEOUT_RE = re.compile(r"\b(timed out|timeout)\b", re.IGNORECASE)
@@ -120,7 +161,7 @@ def scan_messages(messages) -> Dict[str, Any]:
120161
elif role == "tool":
121162
content = obj.get("content")
122163
tool = id_to_tool.get(obj.get("tool_call_id"), "unknown")
123-
if _looks_like_failure(content):
164+
if _tool_result_failed(content):
124165
tool_failures[tool] += 1
125166
if isinstance(content, str) and _TIMEOUT_RE.search(content):
126167
timeouts += 1

tests/scripts/test_introspection_extract.py

Lines changed: 74 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,12 @@
1-
"""Tests for scripts/introspection_extract.py — deterministic, anonymized digest (#89)."""
1+
"""Tests for scripts/introspection_extract.py — deterministic, anonymized digest (#89).
2+
3+
Tool-failure detection is STRUCTURAL (#347): every Hermes tool serialises its
4+
result as a JSON envelope carrying the authoritative status (``exit_code`` for
5+
terminal/code-exec, ``error``/``success``/``status`` for the rest). The digest
6+
reads that status instead of substring-scanning the body, so marker words
7+
("404", "error:", "failed") inside a SUCCESSFUL result's output no longer count
8+
as failures. Fixtures therefore use realistic envelopes, not bare strings.
9+
"""
210

311
import json
412
import sys
@@ -28,21 +36,61 @@ def _tool(cid, content):
2836
return {"role": "tool", "tool_call_id": cid, "content": content}
2937

3038

39+
# --- realistic tool-result envelopes (#347) ----------------------------------
40+
def _term(output="", *, exit_code=0, error=None):
41+
"""Terminal / code-exec envelope: failure is signalled by exit_code != 0."""
42+
return json.dumps({"output": output, "exit_code": exit_code, "error": error}, ensure_ascii=False)
43+
44+
45+
def _ok(**fields):
46+
"""A successful non-terminal envelope (e.g. read_file → {"content": ...}).
47+
48+
No ``error``, no nonzero ``exit_code`` → never counted as a failure, even
49+
when ``fields`` carry marker words in their values."""
50+
return json.dumps(fields or {"success": True}, ensure_ascii=False)
51+
52+
53+
def _fail(error="error"):
54+
"""A failed non-terminal envelope (read_file/skill/etc. → {"error": ...})."""
55+
return json.dumps({"error": error}, ensure_ascii=False)
56+
57+
3158
class TestScanSession:
3259
def test_attributes_failures_to_tool(self, tmp_path):
3360
p = _session(tmp_path, "s1", [
3461
{"role": "session_meta"},
35-
_asst("terminal", "c1"), _tool("c1", "bash: foo: command not found"),
36-
_asst("terminal", "c2"), _tool("c2", "permission denied"),
37-
_asst("read_file", "c3"), _tool("c3", "ok, file contents here"),
62+
_asst("terminal", "c1"), _tool("c1", _term("bash: foo: command not found", exit_code=127)),
63+
_asst("terminal", "c2"), _tool("c2", _term("", exit_code=1, error="permission denied")),
64+
_asst("read_file", "c3"), _tool("c3", _ok(content="ok, file contents here")),
3865
])
3966
s = scan_session(p)
4067
assert s["tool_failures"] == {"terminal": 2}
4168
assert "read_file" not in s["tool_failures"]
4269

70+
def test_structural_ignores_marker_words_in_successful_output(self, tmp_path):
71+
"""#347 regression: marker words in the BODY of a SUCCESSFUL result
72+
must NOT be counted. The old substring matcher fired on file content
73+
("HTTP 404"), grep stdout ("error:"), and skill docs ("timeout") even
74+
though every call succeeded; the structural classifier counts none."""
75+
p = _session(tmp_path, "fp", [
76+
_asst("read_file", "c1"), _tool("c1", _ok(content="page says HTTP 404 Not Found; error: none")),
77+
_asst("terminal", "c2"), _tool("c2", _term("grep hit: error: deprecated\nbuild failed? no", exit_code=0)),
78+
_asst("skill_view", "c3"), _tool("c3", _ok(content="docs cover 404 and timeout handling")),
79+
])
80+
s = scan_session(p)
81+
assert s["tool_failures"] == {}
82+
83+
def test_error_field_counts_for_non_terminal_tools(self, tmp_path):
84+
p = _session(tmp_path, "ef", [
85+
_asst("read_file", "c1"), _tool("c1", _fail("no such file or directory")),
86+
_asst("patch", "c2"), _tool("c2", _ok(success=False)),
87+
])
88+
s = scan_session(p)
89+
assert s["tool_failures"] == {"read_file": 1, "patch": 1}
90+
4391
def test_counts_timeouts_and_refusals(self, tmp_path):
4492
p = _session(tmp_path, "s2", [
45-
_asst("mcp_health", "c1"), _tool("c1", "request timed out after 120s"),
93+
_asst("mcp_health", "c1"), _tool("c1", _term("", exit_code=-1, error="request timed out after 120s")),
4694
{"role": "assistant", "content": "I can't access that path."},
4795
])
4896
s = scan_session(p)
@@ -52,25 +100,27 @@ def test_counts_timeouts_and_refusals(self, tmp_path):
52100
def test_repeated_run_detected(self, tmp_path):
53101
lines = [{"role": "session_meta"}]
54102
for i in range(6):
55-
lines += [_asst("terminal", f"c{i}"), _tool(f"c{i}", "ok")]
103+
lines += [_asst("terminal", f"c{i}"), _tool(f"c{i}", _term("ok"))]
56104
p = _session(tmp_path, "s3", lines)
57105
s = scan_session(p)
58106
assert s["repeated_tool_runs"].get("terminal") == 6
59107

60108
def test_no_raw_text_in_output(self, tmp_path):
61-
secret = "USER SECRET email bob@example.com lives at 5 Main St"
109+
secret = "USER SECRET email <REDACTED:email:db677acc382bd26bb3a00162f3e668d3> lives at 5 Main St"
62110
p = _session(tmp_path, "s4", [
63-
_asst("terminal", "c1"), _tool("c1", f"error: {secret}"),
111+
_asst("terminal", "c1"), _tool("c1", _term("", exit_code=1, error=secret)),
64112
])
65113
s = scan_session(p)
66-
# Digest carries only counts/tool names — never the raw content.
114+
# A genuine failure is counted, but the digest carries only counts/tool
115+
# names — never the raw content/error text.
116+
assert s["tool_failures"] == {"terminal": 1}
67117
assert secret not in json.dumps(s)
68118

69119

70120
class TestBuildDigest:
71121
def test_window_excludes_old_sessions(self, tmp_path):
72-
_session(tmp_path, "recent", [_asst("terminal", "c1"), _tool("c1", "command not found")])
73-
_session(tmp_path, "old", [_asst("terminal", "c2"), _tool("c2", "command not found")], age_days=30)
122+
_session(tmp_path, "recent", [_asst("terminal", "c1"), _tool("c1", _term(exit_code=127))])
123+
_session(tmp_path, "old", [_asst("terminal", "c2"), _tool("c2", _term(exit_code=127))], age_days=30)
74124
d = build_digest(tmp_path, window_days=7)
75125
assert d["sessions_scanned"] == 1
76126
assert d["signals"]["tool_failures"] == {"terminal": 1}
@@ -79,7 +129,7 @@ def test_aggregates_across_sessions(self, tmp_path):
79129
for n in ("a", "b"):
80130
lines = [{"role": "session_meta"}]
81131
for i in range(5):
82-
lines += [_asst("terminal", f"{n}{i}"), _tool(f"{n}{i}", "ok")]
132+
lines += [_asst("terminal", f"{n}{i}"), _tool(f"{n}{i}", _term("ok"))]
83133
_session(tmp_path, n, lines)
84134
d = build_digest(tmp_path, window_days=7)
85135
assert d["sessions_scanned"] == 2
@@ -117,7 +167,7 @@ class TestRequestDump:
117167
def test_scanned_when_no_jsonl_present(self, tmp_path):
118168
# The exact regression: a dir with only request dumps, no *.jsonl.
119169
_dump(tmp_path, "d1", [
120-
_asst("terminal", "c1"), _tool("c1", "bash: foo: command not found"),
170+
_asst("terminal", "c1"), _tool("c1", _term("bash: foo: command not found", exit_code=127)),
121171
], session_id="sess-1", error={"type": "overloaded_error", "status_code": 529,
122172
"message": "x", "response_text": "y"})
123173
d = build_digest(tmp_path, window_days=7)
@@ -128,37 +178,39 @@ def test_scanned_when_no_jsonl_present(self, tmp_path):
128178

129179
def test_dedup_by_session_keeps_most_complete(self, tmp_path):
130180
# Two dumps of ONE session (growing prefix) count once, via the larger.
131-
short = [_asst("terminal", "c1"), _tool("c1", "permission denied")]
132-
full = short + [_asst("terminal", "c2"), _tool("c2", "command not found")]
181+
short = [_asst("terminal", "c1"), _tool("c1", _term("", exit_code=1, error="permission denied"))]
182+
full = short + [_asst("terminal", "c2"), _tool("c2", _term("bash: x: command not found", exit_code=127))]
133183
_dump(tmp_path, "early", short, session_id="sess-1")
134184
_dump(tmp_path, "late", full, session_id="sess-1")
135185
d = build_digest(tmp_path, window_days=7)
136186
assert d["sessions_scanned"] == 1 # one session, not two dumps
137187
assert d["signals"]["tool_failures"] == {"terminal": 2} # from the full one
138188

139189
def test_mixed_jsonl_and_dump_both_counted(self, tmp_path):
140-
_session(tmp_path, "s1", [_asst("terminal", "c1"), _tool("c1", "command not found")])
141-
_dump(tmp_path, "d1", [_asst("read_file", "c2"), _tool("c2", "no such file")],
190+
_session(tmp_path, "s1", [_asst("terminal", "c1"), _tool("c1", _term(exit_code=127))])
191+
_dump(tmp_path, "d1", [_asst("read_file", "c2"), _tool("c2", _fail("no such file"))],
142192
session_id="sess-2")
143193
d = build_digest(tmp_path, window_days=7)
144194
assert d["sessions_scanned"] == 2
145195
assert d["signals"]["tool_failures"] == {"terminal": 1, "read_file": 1}
146196

147197
def test_window_excludes_old_dumps(self, tmp_path):
148-
_dump(tmp_path, "old", [_asst("terminal", "c1"), _tool("c1", "command not found")],
198+
_dump(tmp_path, "old", [_asst("terminal", "c1"), _tool("c1", _term(exit_code=127))],
149199
session_id="sess-old", age_days=30)
150200
d = build_digest(tmp_path, window_days=7)
151201
assert d["sessions_scanned"] == 0
152202

153203
def test_no_raw_text_from_error_or_messages(self, tmp_path):
154-
secret = "bob@example.com at 5 Main St"
204+
secret = "<REDACTED:email:db677acc382bd26bb3a00162f3e668d3> at 5 Main St"
155205
_dump(tmp_path, "d1", [
156-
_asst("terminal", "c1"), _tool("c1", f"error: {secret}"),
206+
_asst("terminal", "c1"), _tool("c1", _term("", exit_code=1, error=secret)),
157207
], session_id="sess-1", error={"type": "bad_request", "status_code": 400,
158208
"message": secret, "response_text": secret,
159209
"body": secret})
160210
d = build_digest(tmp_path, window_days=7)
161-
# Provider error contributes only status:type — never the echoed content.
211+
# The failure is counted, but provider error contributes only status:type
212+
# and the digest never echoes the raw content.
213+
assert d["signals"]["tool_failures"] == {"terminal": 1}
162214
assert d["signals"]["provider_errors"] == {"400:bad_request": 1}
163215
assert secret not in json.dumps(d)
164216

@@ -172,7 +224,7 @@ def test_failure_category_preferred_over_raw_type(self, tmp_path):
172224
# #236: dumps now carry a structured failure_category; introspection keys
173225
# provider_errors by it (recovery class) so recurring bad provider-model
174226
# pairs group as e.g. 429:rate_limit instead of 429:RuntimeError (#237 pt3).
175-
_dump(tmp_path, "d1", [_asst("x", "c1"), _tool("c1", "ok")],
227+
_dump(tmp_path, "d1", [_asst("x", "c1"), _tool("c1", _term("ok"))],
176228
session_id="s1", error={"type": "RuntimeError", "status_code": 429,
177229
"failure_category": "rate_limit"})
178230
d = build_digest(tmp_path, window_days=7)

0 commit comments

Comments
 (0)