Skip to content

Commit 97d671f

Browse files
Hermes EvolutionLexus2016
authored andcommitted
feat: web search fallback chain + loop-guard rework (#467 #544 #571)
- _search_with_fallbacks now tries the configured fallback chain when the primary backend reports failure OR when it returns empty results and a fallback chain is configured. Empty results with no fallback chain remain success=True so real 'no hits' outcomes are not turned into provider-dead errors (#467 rework). - DDGS provider surfaces provider-dead only when empty results occur and a fallback chain exists. - Update fallback-chain tests to match the new semantics and keep regression coverage. - Carry forward prior loop-guard / tool-diagnostics improvements from #544. Closes #467 Closes #544 Closes #571 Co-Authored-By: Hermes Evolution <evolution@hermes.ai>
1 parent cdfe707 commit 97d671f

9 files changed

Lines changed: 2073 additions & 1096 deletions

File tree

agent/loop_guard.py

Lines changed: 117 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434

3535
from __future__ import annotations
3636

37+
import json
3738
import re
3839
from typing import Any, Dict, List, Optional, Tuple
3940

@@ -57,6 +58,8 @@
5758
"exit status",
5859
"is not recognized",
5960
"could not be found",
61+
"no results",
62+
"no results found",
6063
)
6164

6265
_EXIT_CODE_RE = re.compile(r"exit code[:\s]+([1-9]\d*)", re.IGNORECASE)
@@ -69,49 +72,52 @@
6972
_NON_RETRYABLE = frozenset({"timeout", "permission", "missing_command", "limit"})
7073
_NONRETRY_THRESHOLD = 2
7174

75+
# Idempotent tools that are especially prone to content-free repetition and that
76+
# the issue evidence shows spiraling with no progress even when individual calls
77+
# return "success". Count these as non-progress after a shorter run so the model
78+
# is nudged toward a different query / tool / strategy.
79+
_SHORT_CIRCUIT_IDEMPOTENT = frozenset({"search_files", "web_search", "web_extract"})
80+
_SHORT_CIRCUIT_REPEAT_THRESHOLD = 4
81+
7282
# Mutating tools get LOWER thresholds than idempotent tools because a fixation
7383
# on mutating operations (writing files, running commands) is more costly and
7484
# indicates a deeper strategy problem (#432).
75-
_IDEMPOTENT_TOOLS = frozenset(
76-
{
77-
"read_file",
78-
"search_files",
79-
"web_search",
80-
"web_extract",
81-
"session_search",
82-
"browser_snapshot",
83-
"browser_console",
84-
"browser_get_images",
85-
"mcp_filesystem_read_file",
86-
"mcp_filesystem_read_text_file",
87-
"mcp_filesystem_read_multiple_files",
88-
"mcp_filesystem_list_directory",
89-
"mcp_filesystem_list_directory_with_sizes",
90-
"mcp_filesystem_directory_tree",
91-
"mcp_filesystem_get_file_info",
92-
"mcp_filesystem_search_files",
93-
}
94-
)
95-
_MUTATING_TOOLS = frozenset(
96-
{
97-
"terminal",
98-
"execute_code",
99-
"write_file",
100-
"patch",
101-
"todo",
102-
"memory",
103-
"skill_manage",
104-
"browser_click",
105-
"browser_type",
106-
"browser_press",
107-
"browser_scroll",
108-
"browser_navigate",
109-
"send_message",
110-
"cronjob",
111-
"delegate_task",
112-
"process",
113-
}
114-
)
85+
_IDEMPOTENT_TOOLS = frozenset({
86+
"read_file",
87+
"search_files",
88+
"web_search",
89+
"web_extract",
90+
"session_search",
91+
"browser_snapshot",
92+
"browser_console",
93+
"browser_get_images",
94+
"mcp_filesystem_read_file",
95+
"mcp_filesystem_read_text_file",
96+
"mcp_filesystem_read_multiple_files",
97+
"mcp_filesystem_list_directory",
98+
"mcp_filesystem_list_directory_with_sizes",
99+
"mcp_filesystem_directory_tree",
100+
"mcp_filesystem_get_file_info",
101+
"mcp_filesystem_search_files",
102+
})
103+
_MUTATING_TOOLS = frozenset({
104+
"terminal",
105+
"execute_code",
106+
"write_file",
107+
"patch",
108+
"todo",
109+
"memory",
110+
"skill_manage",
111+
"browser_click",
112+
"browser_type",
113+
"browser_press",
114+
"browser_scroll",
115+
"browser_navigate",
116+
"send_message",
117+
"cronjob",
118+
"delegate_task",
119+
"process",
120+
})
115121
# Default thresholds: lower for mutating tools, higher for idempotent (#432).
116122
# Mutating: repeat at 4, fail at 2, escalate at 8
117123
# Idempotent: repeat at 8, fail at 4, escalate at 15
@@ -143,27 +149,49 @@ def _looks_like_failure(content: Any) -> bool:
143149
return bool(_EXIT_CODE_RE.search(content))
144150

145151

146-
def _recent_tool_runs(messages: List[Dict[str, Any]]) -> List[Tuple[str, bool, Optional[str]]]:
147-
"""Most-recent-first list of (single_tool_name, result_failed, failure_class)
152+
def _tool_result_arg_hash(content: Any) -> Optional[str]:
153+
"""Return a canonical JSON key of the tool-call arguments embedded in a
154+
tool result, or None if no arguments can be parsed. Used to detect
155+
identical-query repetition for tools like web_search where the same
156+
arguments produce the same empty result and drive a spiral (#467).
157+
"""
158+
if not isinstance(content, str) or not content:
159+
return None
160+
try:
161+
parsed = json.loads(content)
162+
except Exception:
163+
return None
164+
if not isinstance(parsed, dict):
165+
return None
166+
args = parsed.get("parameters") or parsed.get("args") or parsed.get("arguments")
167+
if not isinstance(args, dict):
168+
return None
169+
return json.dumps(args, sort_keys=True, ensure_ascii=False, separators=(",", ":"))
170+
171+
172+
def _recent_tool_runs(
173+
messages: List[Dict[str, Any]],
174+
) -> List[Tuple[str, bool, Optional[str], Optional[str]]]:
175+
"""Most-recent-first list of
176+
(single_tool_name, result_failed, failure_class, arg_hash)
148177
for the trailing run of assistant turns that each called EXACTLY ONE tool.
149178
``failure_class`` is the tool_diagnostics category of the failing result (or
150-
None when the turn did not fail).
179+
None when the turn did not fail). ``arg_hash`` is a canonical JSON of the
180+
parsed arguments when they can be recovered from the tool result.
151181
152182
Stops at the first assistant turn that is not a single-tool call (a text
153183
reply, or a multi-tool turn) — that breaks the "stuck on one tool" run.
154184
Multi-tool turns are normal varied work, not a single-tool spiral.
155185
"""
156-
runs: List[Tuple[str, bool, Optional[str]]] = []
186+
runs: List[Tuple[str, bool, Optional[str], Optional[str]]] = []
157187
i = len(messages) - 1
158188
# Collect tool results by id as we walk back so we can mark failures.
159189
while i >= 0:
160190
msg = messages[i]
161191
if msg.get("role") == "assistant" and msg.get("tool_calls"):
162192
tcs = [tc for tc in msg["tool_calls"] if isinstance(tc, dict)]
163193
names = [
164-
tc.get("function", {}).get("name")
165-
for tc in tcs
166-
if tc.get("function")
194+
tc.get("function", {}).get("name") for tc in tcs if tc.get("function")
167195
]
168196
names = [n for n in names if n]
169197
if len(set(names)) != 1:
@@ -174,14 +202,17 @@ def _recent_tool_runs(messages: List[Dict[str, Any]]) -> List[Tuple[str, bool, O
174202
# Results for this turn are the "tool" messages that follow it.
175203
failed = False
176204
category: Optional[str] = None
205+
arg_hash: Optional[str] = None
177206
for j in range(i + 1, len(messages)):
178207
tm = messages[j]
179208
if tm.get("role") != "tool":
180209
break
181-
if _looks_like_failure(tm.get("content")):
210+
content = tm.get("content")
211+
if _looks_like_failure(content):
182212
failed = True
183-
category = _failure_category(tm.get("content")) or category
184-
runs.append((tool, failed, category))
213+
category = _failure_category(content) or category
214+
arg_hash = _tool_result_arg_hash(content) or arg_hash
215+
runs.append((tool, failed, category, arg_hash))
185216
i -= 1
186217
elif msg.get("role") == "tool":
187218
i -= 1 # skip result messages; handled with their assistant turn
@@ -223,11 +254,13 @@ def maybe_nudge(
223254
) -> Optional[str]:
224255
"""Return a nudge string if the trailing single-tool run is stuck, else None.
225256
226-
Three trigger levels (each is lower for mutating tools than idempotent):
257+
Trigger levels (each is lower for mutating tools than idempotent):
227258
1. Non-retryable failure class repeated twice (highest priority, #231)
228259
2. Generic failures >= fail_threshold
229260
3. Same tool called >= repeat_threshold times in a row
230261
4. Escalated interrupt at higher counts (#432)
262+
5. Same *arguments* repeated for short-circuit idempotent tools
263+
(search_files / web_search / web_extract) >= 4 times (#467)
231264
232265
Returns None when the agent is making varied progress (not stuck).
233266
"""
@@ -243,18 +276,30 @@ def maybe_nudge(
243276
is_unknown = cat == "unknown"
244277
if repeat_threshold is None:
245278
repeat_threshold = (
246-
_MUTATING_REPEAT_THRESHOLD if (is_mutating or is_unknown)
279+
_MUTATING_REPEAT_THRESHOLD
280+
if (is_mutating or is_unknown)
247281
else _IDEMPOTENT_REPEAT_THRESHOLD
248282
)
249283
if fail_threshold is None:
250284
fail_threshold = (
251-
_MUTATING_FAIL_THRESHOLD if (is_mutating or is_unknown)
285+
_MUTATING_FAIL_THRESHOLD
286+
if (is_mutating or is_unknown)
252287
else _IDEMPOTENT_FAIL_THRESHOLD
253288
)
254289
escalate_threshold = (
255-
_MUTATING_ESCALATE_THRESHOLD if (is_mutating or is_unknown)
290+
_MUTATING_ESCALATE_THRESHOLD
291+
if (is_mutating or is_unknown)
256292
else _IDEMPOTENT_ESCALATE_THRESHOLD
257293
)
294+
short_circuit_threshold = (
295+
_MUTATING_REPEAT_THRESHOLD
296+
if (is_mutating or is_unknown)
297+
else (
298+
_SHORT_CIRCUIT_REPEAT_THRESHOLD
299+
if tool in _SHORT_CIRCUIT_IDEMPOTENT
300+
else repeat_threshold
301+
)
302+
)
258303

259304
# All entries in `runs` share the same tool (run breaks on tool change),
260305
# but guard anyway:
@@ -264,7 +309,7 @@ def maybe_nudge(
264309
consec_nonretry = 0
265310
nonretry_class: Optional[str] = None
266311
counting_nonretry = True
267-
for _t, failed, category in same:
312+
for _t, failed, category, _arg_hash in same:
268313
if failed:
269314
consec_fail += 1
270315
else:
@@ -312,6 +357,23 @@ def maybe_nudge(
312357
f"again the same way."
313358
)
314359

360+
# Same-argument repetition for known spiral-prone idempotent tools (#467).
361+
# This catches web_search returning "no results" / search_files returning
362+
# nothing, where each individual call technically "succeeded" but repeating
363+
# the exact same query is still a loop.
364+
if tool in _SHORT_CIRCUIT_IDEMPOTENT and count >= short_circuit_threshold:
365+
arg_hashes = [r[3] for r in same if r[3] is not None]
366+
if arg_hashes and len(set(arg_hashes)) == 1:
367+
score = _tool_spiral_score(tool, count, short_circuit_threshold)
368+
score_line = f"\n{score}" if score else ""
369+
return (
370+
f"[loop-guard] You have called `{tool}` {count} times with the "
371+
f"SAME arguments and the result is not making progress.{score_line} "
372+
f"Do NOT repeat `{tool}` with those identical arguments. Rephrase "
373+
f"the query, broaden or narrow it, switch to a different information "
374+
f"source, or state the blocker if no relevant results are available."
375+
)
376+
315377
if count >= repeat_threshold:
316378
# Build diversity score for the nudge.
317379
score = _tool_spiral_score(tool, count, repeat_threshold)

agent/tool_diagnostics.py

Lines changed: 65 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -19,30 +19,71 @@
1919

2020
# Ordered most-specific first; first match wins. (regex, category, hint).
2121
_RULES: tuple[tuple[re.Pattern, str, str], ...] = (
22-
(re.compile(r"command not found|not recognized as|: No such file or directory.*\b(sh|bash|exec)\b|executable file not found", re.I),
23-
"missing_command",
24-
"A required binary/command is missing. Check prerequisites first "
25-
"(`which <cmd>` / install it), or use a different tool — do NOT repeat the same command."),
26-
(re.compile(r"permission denied|access denied|not permitted|forbidden|refusing to write|operation not permitted|EACCES", re.I),
27-
"permission",
28-
"Access is denied and the agent can't elevate. Do NOT retry the same path — "
29-
"use an allowed path/tool, or report exactly what access is needed."),
30-
(re.compile(r"timed out|timeout|deadline exceeded|ClosedResourceError|unreachable|connection refused|ETIMEDOUT", re.I),
31-
"timeout",
32-
"The operation timed out / the resource is unreachable. Set it aside, route to a "
33-
"fallback if one exists, and do NOT loop on health checks or retry blindly."),
34-
(re.compile(r"\b(char(acter)?s?|byte)s?\b.*\b(limit|exceed|too (long|large|big)|maximum)\b|exceeds the maximum|max(imum)? (length|size)|too many tokens|context length", re.I),
35-
"limit",
36-
"A size/length limit was hit. Don't resend as-is — chunk the work, summarize, "
37-
"or raise the relevant config limit; a near-identical retry will fail the same way."),
38-
(re.compile(r"no such file or directory|not found|does not exist|cannot find|no matches found|0 results|no results", re.I),
39-
"not_found",
40-
"The target wasn't found. Re-check the path/name (it may be dynamic), broaden the "
41-
"search, or create the prerequisite first — don't repeat the same lookup."),
42-
(re.compile(r"traceback \(most recent call|exit code[:\s]+[1-9]|exit status [1-9]|non-zero exit|error:|exception|failed", re.I),
43-
"runtime_error",
44-
"The call errored. Read the message, fix the root cause, and CHANGE the call — "
45-
"retrying the same arguments will reproduce it."),
22+
(
23+
re.compile(
24+
r"command not found|not recognized as|: No such file or directory.*\b(sh|bash|exec)\b|executable file not found",
25+
re.I,
26+
),
27+
"missing_command",
28+
"A required binary/command is missing. Check prerequisites first "
29+
"(`which <cmd>` / install it), or use a different tool — do NOT repeat the same command.",
30+
),
31+
(
32+
re.compile(
33+
r"permission denied|access denied|not permitted|forbidden|refusing to write|operation not permitted|EACCES",
34+
re.I,
35+
),
36+
"permission",
37+
"Access is denied and the agent can't elevate. Do NOT retry the same path — "
38+
"use an allowed path/tool, or report exactly what access is needed.",
39+
),
40+
(
41+
re.compile(
42+
r"timed out|timeout|deadline exceeded|ClosedResourceError|unreachable|connection refused|ETIMEDOUT",
43+
re.I,
44+
),
45+
"timeout",
46+
"The operation timed out / the resource is unreachable. Set it aside, route to a "
47+
"fallback if one exists, and do NOT loop on health checks or retry blindly.",
48+
),
49+
(
50+
re.compile(
51+
r"\b(char(acter)?s?|byte)s?\b.*\b(limit|exceed|too (long|large|big)|maximum)\b|exceeds the maximum|max(imum)? (length|size)|too many tokens|context length",
52+
re.I,
53+
),
54+
"limit",
55+
"A size/length limit was hit. Don't resend as-is — chunk the work, summarize, "
56+
"or raise the relevant config limit; a near-identical retry will fail the same way.",
57+
),
58+
(
59+
re.compile(
60+
r"\bno results\b|\bno results found\b|duckduckgo search failed|brave search returned http|could not reach .* search|searxng returned http",
61+
re.I,
62+
),
63+
"provider_dead",
64+
"The active search provider is not returning results. Switch to a different "
65+
"search_backend in config.yaml or via hermes tools, or report the blocker "
66+
"if no alternative provider is configured. Do not retry the same query with "
67+
"the same provider.",
68+
),
69+
(
70+
re.compile(
71+
r"no such file or directory|not found|does not exist|cannot find|no matches found|0 results|no results",
72+
re.I,
73+
),
74+
"not_found",
75+
"The target wasn't found. Re-check the path/name (it may be dynamic), broaden the "
76+
"search, or create the prerequisite first — don't repeat the same lookup.",
77+
),
78+
(
79+
re.compile(
80+
r"traceback \(most recent call|exit code[:\s]+[1-9]|exit status [1-9]|non-zero exit|error:|exception|failed",
81+
re.I,
82+
),
83+
"runtime_error",
84+
"The call errored. Read the message, fix the root cause, and CHANGE the call — "
85+
"retrying the same arguments will reproduce it.",
86+
),
4687
)
4788

4889

0 commit comments

Comments
 (0)