Make DuckDuckGo rate-limit non-fatal so the agent can still answer
Browse filesBefore this change, a 202 Ratelimit from html.duckduckgo.com (common
when many HF Spaces share the same outbound IP) propagated all the way
up to run_ui and replaced the result with a raw "Error: ...
202 Ratelimit" string — no table, no answer, nothing.
Catch all exceptions in _run_search_single, retry once with a short
backoff to ride out transient 202s, and on persistent failure return a
structured tool error with an "answer from your own knowledge" hint so
the agent loop can still produce a final <answer> for questions the
model already knows (e.g. iPhone 15 vs 16 comparisons).
Made-with: Cursor
app.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
import json
|
| 2 |
import os
|
| 3 |
import re
|
|
|
|
| 4 |
from dataclasses import dataclass, field
|
| 5 |
from datetime import date
|
| 6 |
from pathlib import Path
|
|
@@ -950,26 +951,64 @@ def parse_tool_call(text: str) -> Tuple[Optional[str], Optional[Dict[str, Any]],
|
|
| 950 |
return name, arguments, None
|
| 951 |
|
| 952 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 953 |
def _run_search_single(query: str, max_results: int) -> Dict[str, Any]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 954 |
if not query.strip():
|
| 955 |
return {"ok": False, "error": "Search query cannot be empty."}
|
| 956 |
cache_key = f"{query.strip().lower()}::{max_results}"
|
| 957 |
if cache_key in SEARCH_CACHE:
|
| 958 |
return {**SEARCH_CACHE[cache_key], "cached": True}
|
| 959 |
|
| 960 |
-
|
| 961 |
-
|
| 962 |
-
|
| 963 |
-
rows
|
| 964 |
-
|
| 965 |
-
|
| 966 |
-
|
| 967 |
-
|
| 968 |
-
|
| 969 |
-
|
| 970 |
-
|
| 971 |
-
|
| 972 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 973 |
|
| 974 |
|
| 975 |
def run_search(query: Union[str, List[str]], max_results: int = 5) -> Dict[str, Any]:
|
|
|
|
| 1 |
import json
|
| 2 |
import os
|
| 3 |
import re
|
| 4 |
+
import time
|
| 5 |
from dataclasses import dataclass, field
|
| 6 |
from datetime import date
|
| 7 |
from pathlib import Path
|
|
|
|
| 951 |
return name, arguments, None
|
| 952 |
|
| 953 |
|
| 954 |
+
_SEARCH_UNAVAILABLE_HINT = (
|
| 955 |
+
"The web-search backend is currently rate-limited or unreachable. "
|
| 956 |
+
"If this question can be answered confidently from your own training "
|
| 957 |
+
"knowledge (e.g. common product specs, historical facts, definitions), "
|
| 958 |
+
"please produce your best answer now inside <answer>...</answer>, and "
|
| 959 |
+
"mention any value that might be out of date. Only ask the user to "
|
| 960 |
+
"retry later if the question truly requires a fresh web lookup."
|
| 961 |
+
)
|
| 962 |
+
|
| 963 |
+
|
| 964 |
def _run_search_single(query: str, max_results: int) -> Dict[str, Any]:
|
| 965 |
+
"""Run one DuckDuckGo query.
|
| 966 |
+
|
| 967 |
+
Returns a structured dict on both success and failure, never raises. If
|
| 968 |
+
the search backend rate-limits us (Space IPs share outbound NAT and
|
| 969 |
+
often trip DuckDuckGo's anti-scraping throttle), we return an
|
| 970 |
+
`ok: False` payload with a hint that lets the agent fall back to its
|
| 971 |
+
own knowledge instead of aborting the whole research run.
|
| 972 |
+
"""
|
| 973 |
if not query.strip():
|
| 974 |
return {"ok": False, "error": "Search query cannot be empty."}
|
| 975 |
cache_key = f"{query.strip().lower()}::{max_results}"
|
| 976 |
if cache_key in SEARCH_CACHE:
|
| 977 |
return {**SEARCH_CACHE[cache_key], "cached": True}
|
| 978 |
|
| 979 |
+
last_exc: Optional[BaseException] = None
|
| 980 |
+
for attempt in range(2):
|
| 981 |
+
try:
|
| 982 |
+
rows: List[Dict[str, str]] = []
|
| 983 |
+
with DDGS() as ddgs:
|
| 984 |
+
for item in ddgs.text(query, max_results=max_results):
|
| 985 |
+
rows.append(
|
| 986 |
+
{
|
| 987 |
+
"title": item.get("title", ""),
|
| 988 |
+
"href": item.get("href", ""),
|
| 989 |
+
"body": item.get("body", ""),
|
| 990 |
+
}
|
| 991 |
+
)
|
| 992 |
+
payload = {"ok": True, "query": query, "results": rows, "cached": False}
|
| 993 |
+
SEARCH_CACHE[cache_key] = payload
|
| 994 |
+
return payload
|
| 995 |
+
except Exception as exc:
|
| 996 |
+
last_exc = exc
|
| 997 |
+
# One retry with a small backoff covers most transient 202
|
| 998 |
+
# Ratelimit / transient network hiccups; on the second failure
|
| 999 |
+
# we give up and return a graceful error to the agent.
|
| 1000 |
+
if attempt == 0:
|
| 1001 |
+
time.sleep(1.5)
|
| 1002 |
+
continue
|
| 1003 |
+
|
| 1004 |
+
err = f"{type(last_exc).__name__}: {last_exc}" if last_exc else "unknown error"
|
| 1005 |
+
return {
|
| 1006 |
+
"ok": False,
|
| 1007 |
+
"query": query,
|
| 1008 |
+
"error": f"Search backend unavailable ({err}).",
|
| 1009 |
+
"results": [],
|
| 1010 |
+
"hint": _SEARCH_UNAVAILABLE_HINT,
|
| 1011 |
+
}
|
| 1012 |
|
| 1013 |
|
| 1014 |
def run_search(query: Union[str, List[str]], max_results: int = 5) -> Dict[str, Any]:
|