yc1838 commited on
Commit
c23b889
·
1 Parent(s): f774338

feat: implement robust web fetcher with redirect handling and add LLM-based answer formatting pipeline

Browse files
src/lilith_agent/tools/search.py CHANGED
@@ -4,6 +4,12 @@ from ddgs import DDGS
4
  from tavily import TavilyClient, UsageLimitExceededError
5
  from tavily.errors import ForbiddenError
6
 
 
 
 
 
 
 
7
 
8
  def _ddg_search(query: str, max_results: int) -> str:
9
  results = DDGS().text(query, max_results=max_results)
@@ -26,17 +32,19 @@ def web_search(query: str, api_key: str, max_results: int = 5) -> str:
26
 
27
  # 2. Fallback to Tavily if DDG failed or returned nothing
28
  if not api_key:
29
- return f"[Source: DuckDuckGo (Empty)]\n{ddg_results}\n[Tavily fallback skipped: no key]"
30
-
31
  client = TavilyClient(api_key=api_key)
32
  try:
33
  res = client.search(query=query, max_results=max_results)
34
  lines: list[str] = []
35
  for item in res.get("results", []):
36
  lines.append(f"- {item['title']} ({item['url']})\n {item['content']}")
37
- tavily_results = "\n".join(lines) if lines else "No results."
 
 
38
  return f"[Source: Tavily (DDG fallback: {ddg_results})]\n{tavily_results}"
39
  except (UsageLimitExceededError, ForbiddenError) as e:
40
- return f"[Source: DuckDuckGo (Empty)]\n{ddg_results}\n[Tavily fallback failed: limit/forbidden: {e}]"
41
  except Exception as e:
42
- return f"[Source: DuckDuckGo (Empty)]\n{ddg_results}\n[Tavily fallback failed: {e}]"
 
4
  from tavily import TavilyClient, UsageLimitExceededError
5
  from tavily.errors import ForbiddenError
6
 
7
+ _SEARCH_RECOVERY_HINT = (
8
+ "SEARCH_HINT: no useful results. Try: (1) rephrase with alternative keywords or narrower scope; "
9
+ "(2) for academic/scientific queries, use arxiv_search or crossref_search; "
10
+ "(3) for removed or historical pages, search via https://web.archive.org."
11
+ )
12
+
13
 
14
  def _ddg_search(query: str, max_results: int) -> str:
15
  results = DDGS().text(query, max_results=max_results)
 
32
 
33
  # 2. Fallback to Tavily if DDG failed or returned nothing
34
  if not api_key:
35
+ return f"[Source: DuckDuckGo (Empty)]\n{ddg_results}\n[Tavily fallback skipped: no key]\n{_SEARCH_RECOVERY_HINT}"
36
+
37
  client = TavilyClient(api_key=api_key)
38
  try:
39
  res = client.search(query=query, max_results=max_results)
40
  lines: list[str] = []
41
  for item in res.get("results", []):
42
  lines.append(f"- {item['title']} ({item['url']})\n {item['content']}")
43
+ if not lines:
44
+ return f"[Source: Tavily (DDG fallback: {ddg_results})]\nNo results.\n{_SEARCH_RECOVERY_HINT}"
45
+ tavily_results = "\n".join(lines)
46
  return f"[Source: Tavily (DDG fallback: {ddg_results})]\n{tavily_results}"
47
  except (UsageLimitExceededError, ForbiddenError) as e:
48
+ return f"[Source: DuckDuckGo (Empty)]\n{ddg_results}\n[Tavily fallback failed: limit/forbidden: {e}]\n{_SEARCH_RECOVERY_HINT}"
49
  except Exception as e:
50
+ return f"[Source: DuckDuckGo (Empty)]\n{ddg_results}\n[Tavily fallback failed: {e}]\n{_SEARCH_RECOVERY_HINT}"
src/lilith_agent/tools/web.py CHANGED
@@ -1,11 +1,44 @@
1
  import io
2
  import ipaddress
3
- from urllib.parse import urlparse
4
 
5
  import httpx
6
  import trafilatura
7
  from pypdf import PdfReader
8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
 
10
  def _is_safe_http_url(url: str) -> tuple[bool, str]:
11
  """SSRF guard: allow only http/https to public hosts.
@@ -63,13 +96,13 @@ def fetch_url(url: str, max_chars: int = 8000, timeout: float = 60.0) -> str:
63
  # 1. Check for PDF - Local extraction is always more reliable for PDFs
64
  if url.lower().endswith(".pdf"):
65
  try:
66
- resp = httpx.get(url, timeout=timeout, follow_redirects=True)
67
  resp.raise_for_status()
68
  reader = PdfReader(io.BytesIO(resp.content))
69
  text = "\n".join((page.extract_text() or "") for page in reader.pages)
70
  return text[:max_chars] if len(text) > max_chars else text
71
  except Exception as e:
72
- return f"PDF Fetch Failed: {e}"
73
 
74
  # 2. Try Jina Reader (r.jina.ai) — It's specifically built to bypass anti-bot and return clean markdown
75
  jina_url = f"https://r.jina.ai/{url}"
@@ -81,7 +114,7 @@ def fetch_url(url: str, max_chars: int = 8000, timeout: float = 60.0) -> str:
81
  try:
82
  # Use a slightly shorter timeout for Jina to allow for fallback
83
  jina_timeout = min(timeout, 40.0)
84
- resp = httpx.get(jina_url, timeout=jina_timeout, follow_redirects=True, headers=headers)
85
  if resp.status_code == 200 and len(resp.text) > 50:
86
  text = resp.text
87
  return text[:max_chars] if len(text) > max_chars else text
@@ -91,10 +124,9 @@ def fetch_url(url: str, max_chars: int = 8000, timeout: float = 60.0) -> str:
91
 
92
  # 3. Fallback: Normal httpx + trafilatura
93
  try:
94
- resp = httpx.get(
95
  url,
96
  timeout=timeout,
97
- follow_redirects=True,
98
  headers={
99
  "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36",
100
  "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
@@ -120,11 +152,11 @@ def fetch_url(url: str, max_chars: int = 8000, timeout: float = 60.0) -> str:
120
  if any(marker in text_lower for marker in captcha_markers):
121
  return (
122
  "WEB_FETCH_ERROR: CAPTCHA/Bot detection encountered. This URL is currently inaccessible via automated tools. "
123
- "DO NOT retry this exact URL. Try a different source or use search snippets to answer."
124
  )
125
 
126
  if len(text) > max_chars:
127
  text = text[:max_chars] + "\n...[truncated]"
128
  return text
129
  except Exception as e:
130
- return f"All Fetch Attempts Failed: {e}"
 
1
  import io
2
  import ipaddress
3
+ from urllib.parse import urljoin, urlparse
4
 
5
  import httpx
6
  import trafilatura
7
  from pypdf import PdfReader
8
 
9
+ _FETCH_RECOVERY_HINT = (
10
+ "Try: (1) web_search for third-party citations or mirrors of this content; "
11
+ "(2) fetch the archived version at https://web.archive.org/web/<url>; "
12
+ "(3) use the snippet from your prior web_search result instead of fetching the page."
13
+ )
14
+
15
+ _MAX_REDIRECTS = 5
16
+
17
+
18
+ def _safe_get(url: str, **kwargs) -> httpx.Response:
19
+ """GET with bounded manual redirect following; SSRF-rechecks every hop.
20
+
21
+ The initial `_is_safe_http_url` check only validates the first URL; a
22
+ server can still 302 to a private/metadata address. This helper follows
23
+ redirects one hop at a time, re-running the static SSRF check before each
24
+ request, and raises `httpx.HTTPError` if any hop resolves to an unsafe URL
25
+ or the redirect budget is exceeded.
26
+ """
27
+ kwargs.pop("follow_redirects", None)
28
+ current = url
29
+ for _ in range(_MAX_REDIRECTS + 1):
30
+ ok, reason = _is_safe_http_url(current)
31
+ if not ok:
32
+ raise httpx.HTTPError(f"redirect to unsafe URL blocked ({reason})")
33
+ resp = httpx.get(current, follow_redirects=False, **kwargs)
34
+ if not resp.is_redirect:
35
+ return resp
36
+ location = resp.headers.get("Location")
37
+ if not location:
38
+ return resp
39
+ current = urljoin(current, location)
40
+ raise httpx.HTTPError(f"too many redirects (> {_MAX_REDIRECTS})")
41
+
42
 
43
  def _is_safe_http_url(url: str) -> tuple[bool, str]:
44
  """SSRF guard: allow only http/https to public hosts.
 
96
  # 1. Check for PDF - Local extraction is always more reliable for PDFs
97
  if url.lower().endswith(".pdf"):
98
  try:
99
+ resp = _safe_get(url, timeout=timeout)
100
  resp.raise_for_status()
101
  reader = PdfReader(io.BytesIO(resp.content))
102
  text = "\n".join((page.extract_text() or "") for page in reader.pages)
103
  return text[:max_chars] if len(text) > max_chars else text
104
  except Exception as e:
105
+ return f"WEB_FETCH_ERROR: PDF fetch failed for {url}: {e}. " + _FETCH_RECOVERY_HINT
106
 
107
  # 2. Try Jina Reader (r.jina.ai) — It's specifically built to bypass anti-bot and return clean markdown
108
  jina_url = f"https://r.jina.ai/{url}"
 
114
  try:
115
  # Use a slightly shorter timeout for Jina to allow for fallback
116
  jina_timeout = min(timeout, 40.0)
117
+ resp = _safe_get(jina_url, timeout=jina_timeout, headers=headers)
118
  if resp.status_code == 200 and len(resp.text) > 50:
119
  text = resp.text
120
  return text[:max_chars] if len(text) > max_chars else text
 
124
 
125
  # 3. Fallback: Normal httpx + trafilatura
126
  try:
127
+ resp = _safe_get(
128
  url,
129
  timeout=timeout,
 
130
  headers={
131
  "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36",
132
  "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
 
152
  if any(marker in text_lower for marker in captcha_markers):
153
  return (
154
  "WEB_FETCH_ERROR: CAPTCHA/Bot detection encountered. This URL is currently inaccessible via automated tools. "
155
+ "DO NOT retry this exact URL. " + _FETCH_RECOVERY_HINT
156
  )
157
 
158
  if len(text) > max_chars:
159
  text = text[:max_chars] + "\n...[truncated]"
160
  return text
161
  except Exception as e:
162
+ return f"WEB_FETCH_ERROR: all fetch attempts failed for {url}: {e}. " + _FETCH_RECOVERY_HINT
tests/test_formatter.py ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ import pytest
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class Case:
10
+ id: str
11
+ raw: str
12
+ expected_deterministic: str
13
+ needs_llm: bool
14
+
15
+
16
+ # Regression table: 17 real-shape GAIA answer fixtures.
17
+ # CI runs assertions against the deterministic layer + gating layer only.
18
+ # No network / LLM calls — the LLM path is exercised with a mock further down.
19
+ CASES: list[Case] = [
20
+ # ---- Already clean, short — must pass through untouched and skip LLM ----
21
+ Case("bare_number", "42", "42", False),
22
+ Case("bare_word", "Paris", "Paris", False),
23
+ Case("year", "1969", "1969", False),
24
+ Case("short_phrase", "Mount Everest", "Mount Everest", False),
25
+ Case("abbrev_period", "Mr.", "Mr.", False),
26
+ Case("abbrev_dots", "U.S.", "U.S.", False),
27
+ Case("scene_descriptor", "INT. OFFICE - DAY", "INT. OFFICE - DAY", False),
28
+ Case("large_number", "3000", "3000", False),
29
+
30
+ # ---- Clean after safe wrapper-strip — must skip LLM ----
31
+ Case("markdown_bold", "**42**", "42", False),
32
+ Case("double_quoted", '"Paris"', "Paris", False),
33
+ Case("backticked", "`code`", "code", False),
34
+ Case("final_answer", "Final Answer: 42", "42", False),
35
+ Case("answer_colon", "Answer: Paris", "Paris", False),
36
+ Case("bold_and_prefix", "**Final Answer: 42**","42", False),
37
+
38
+ # ---- Verbose — deterministic leaves alone; gate must route to LLM ----
39
+ Case("verbose_filler", "The answer is 42",
40
+ "The answer is 42", True),
41
+ Case("verbose_approx",
42
+ "Based on my calculations, the answer is approximately 1500",
43
+ "Based on my calculations, the answer is approximately 1500",
44
+ True),
45
+ Case("long_sentence",
46
+ "I found that the total number of papers published was 127 in 2019",
47
+ "I found that the total number of papers published was 127 in 2019",
48
+ True),
49
+ ]
50
+
51
+ _CASE_IDS = [c.id for c in CASES]
52
+
53
+
54
+ @pytest.mark.parametrize("case", CASES, ids=_CASE_IDS)
55
+ def test_deterministic_format_table(case: Case):
56
+ from lilith_agent.runner import _deterministic_format
57
+
58
+ assert _deterministic_format(case.raw) == case.expected_deterministic
59
+
60
+
61
+ @pytest.mark.parametrize("case", CASES, ids=_CASE_IDS)
62
+ def test_gating_table(case: Case):
63
+ from lilith_agent.runner import _needs_llm_formatter
64
+
65
+ assert _needs_llm_formatter(case.expected_deterministic) is case.needs_llm
66
+
67
+
68
+ class _RaiseIfCalled:
69
+ def __init__(self):
70
+ self.called = False
71
+
72
+ def invoke(self, _messages): # pragma: no cover - should never run
73
+ self.called = True
74
+ raise AssertionError("LLM formatter was called when it shouldn't have been")
75
+
76
+
77
+ class _FakeModel:
78
+ def __init__(self, response: str):
79
+ self.response = response
80
+ self.called = False
81
+
82
+ def invoke(self, _messages):
83
+ self.called = True
84
+
85
+ class _Resp:
86
+ pass
87
+
88
+ r = _Resp()
89
+ r.content = self.response
90
+ return r
91
+
92
+
93
+ def test_short_clean_answer_bypasses_llm():
94
+ from lilith_agent.runner import _final_formatting_cleanup
95
+
96
+ model = _RaiseIfCalled()
97
+ out = _final_formatting_cleanup(model, "What is 6*7?", "42")
98
+ assert out == "42"
99
+ assert model.called is False
100
+
101
+
102
+ def test_verbose_answer_invokes_llm_by_default():
103
+ from lilith_agent.runner import _final_formatting_cleanup
104
+
105
+ model = _FakeModel(response="42")
106
+ out = _final_formatting_cleanup(
107
+ model,
108
+ "What is 6*7?",
109
+ "Based on my calculations, the answer is approximately 42",
110
+ )
111
+ assert model.called is True
112
+ assert out == "42"
113
+
114
+
115
+ def test_llm_formatter_disabled_returns_deterministic_only():
116
+ from lilith_agent.runner import _final_formatting_cleanup
117
+
118
+ model = _RaiseIfCalled()
119
+ out = _final_formatting_cleanup(
120
+ model,
121
+ "What is 6*7?",
122
+ "Based on my calculations, the answer is approximately 42",
123
+ llm_formatter_enabled=False,
124
+ )
125
+ assert model.called is False
126
+ # deterministic is a no-op on verbose text — raw is returned unchanged
127
+ assert out == "Based on my calculations, the answer is approximately 42"
128
+
129
+
130
+ def test_llm_formatter_disabled_still_applies_deterministic_strip():
131
+ from lilith_agent.runner import _final_formatting_cleanup
132
+
133
+ model = _RaiseIfCalled()
134
+ out = _final_formatting_cleanup(
135
+ model,
136
+ "Q",
137
+ "**Final Answer: 42**",
138
+ llm_formatter_enabled=False,
139
+ )
140
+ assert model.called is False
141
+ assert out == "42"
142
+
143
+
144
+ def test_config_llm_formatter_enabled_defaults_on_and_is_env_overridable(monkeypatch):
145
+ from lilith_agent.config import Config
146
+
147
+ monkeypatch.delenv("GAIA_LLM_FORMATTER_ENABLED", raising=False)
148
+ assert Config.from_env().llm_formatter_enabled is True
149
+
150
+ monkeypatch.setenv("GAIA_LLM_FORMATTER_ENABLED", "false")
151
+ assert Config.from_env().llm_formatter_enabled is False
tests/test_search.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from lilith_agent.tools import search as search_mod
4
+
5
+
6
+ def test_web_search_empty_without_tavily_key_includes_recovery_hint(monkeypatch):
7
+ monkeypatch.setattr(search_mod, "_ddg_search", lambda q, n: "No results.")
8
+
9
+ out = search_mod.web_search("obscure unlikely query", api_key="")
10
+
11
+ lower = out.lower()
12
+ assert "no results" in lower
13
+ assert "rephrase" in lower or "alternative keywords" in lower
14
+ assert "archive.org" in lower or "arxiv" in lower or "crossref" in lower
15
+
16
+
17
+ def test_web_search_empty_with_tavily_failure_includes_recovery_hint(monkeypatch):
18
+ monkeypatch.setattr(search_mod, "_ddg_search", lambda q, n: "No results.")
19
+
20
+ class _BoomClient:
21
+ def __init__(self, api_key):
22
+ pass
23
+
24
+ def search(self, **_kw):
25
+ raise RuntimeError("tavily offline")
26
+
27
+ monkeypatch.setattr(search_mod, "TavilyClient", _BoomClient)
28
+
29
+ out = search_mod.web_search("obscure unlikely query", api_key="fake-key")
30
+
31
+ lower = out.lower()
32
+ assert "rephrase" in lower or "alternative keywords" in lower
33
+ assert "archive.org" in lower or "arxiv" in lower or "crossref" in lower
tests/test_web.py CHANGED
@@ -69,3 +69,150 @@ def test_fetch_url_rejects_unsafe_scheme_without_network_call():
69
  def test_fetch_url_rejects_private_address_without_network_call():
70
  out = fetch_url("http://169.254.169.254/latest/meta-data/")
71
  assert out.startswith("WEB_FETCH_ERROR")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
  def test_fetch_url_rejects_private_address_without_network_call():
70
  out = fetch_url("http://169.254.169.254/latest/meta-data/")
71
  assert out.startswith("WEB_FETCH_ERROR")
72
+
73
+
74
+ class _FakeResp:
75
+ def __init__(self, status_code=200, text="", headers=None):
76
+ import httpx as _httpx
77
+
78
+ self.status_code = status_code
79
+ self.text = text
80
+ self.content = text.encode("utf-8")
81
+ self.headers = headers or {"Content-Type": "text/html"}
82
+ self._httpx = _httpx
83
+
84
+ @property
85
+ def is_redirect(self):
86
+ return self.status_code in (301, 302, 303, 307, 308)
87
+
88
+ def raise_for_status(self):
89
+ if self.status_code >= 400:
90
+ raise self._httpx.HTTPStatusError(
91
+ f"{self.status_code} error", request=None, response=None
92
+ )
93
+
94
+
95
+ def test_fetch_url_captcha_response_includes_recovery_hints(monkeypatch):
96
+ import httpx as _httpx
97
+
98
+ def fake_get(url, *args, **kwargs):
99
+ # Jina path fails (non-200), fallback path returns CAPTCHA page
100
+ if url.startswith("https://r.jina.ai/"):
101
+ return _FakeResp(status_code=403, text="")
102
+ return _FakeResp(
103
+ status_code=200,
104
+ text="<html><body>Please complete the captcha to continue</body></html>",
105
+ )
106
+
107
+ monkeypatch.setattr(_httpx, "get", fake_get)
108
+
109
+ out = fetch_url("https://example.com/blocked")
110
+ lower = out.lower()
111
+ assert "captcha" in lower or "bot detection" in lower
112
+ assert "third-party" in lower or "citation" in lower
113
+ assert "archive.org" in lower
114
+ assert "snippet" in lower
115
+
116
+
117
+ def test_fetch_url_exception_path_includes_recovery_hints(monkeypatch):
118
+ import httpx as _httpx
119
+
120
+ def fake_get(url, *args, **kwargs):
121
+ raise _httpx.HTTPError("403 Forbidden")
122
+
123
+ monkeypatch.setattr(_httpx, "get", fake_get)
124
+
125
+ out = fetch_url("https://example.com/blocked")
126
+ assert out.startswith("WEB_FETCH_ERROR")
127
+ lower = out.lower()
128
+ assert "third-party" in lower or "citation" in lower
129
+ assert "archive.org" in lower
130
+ assert "snippet" in lower
131
+
132
+
133
+ def test_fetch_url_pdf_failure_includes_recovery_hints(monkeypatch):
134
+ import httpx as _httpx
135
+
136
+ def fake_get(url, *args, **kwargs):
137
+ raise _httpx.HTTPError("403 Forbidden")
138
+
139
+ monkeypatch.setattr(_httpx, "get", fake_get)
140
+
141
+ out = fetch_url("https://example.com/paper.pdf")
142
+ assert out.startswith("WEB_FETCH_ERROR")
143
+ lower = out.lower()
144
+ assert "third-party" in lower or "citation" in lower
145
+ assert "archive.org" in lower
146
+ assert "snippet" in lower
147
+
148
+
149
+ def test_fetch_url_blocks_redirect_to_private_ip(monkeypatch):
150
+ """Post-redirect SSRF re-check: server sends 302 to link-local metadata IP."""
151
+ import httpx as _httpx
152
+
153
+ visited: list[str] = []
154
+
155
+ def fake_get(url, *args, **kwargs):
156
+ visited.append(url)
157
+ # Redirect every request to the AWS metadata IP.
158
+ return _FakeResp(
159
+ status_code=302,
160
+ headers={"Location": "http://169.254.169.254/latest/meta-data/iam/"},
161
+ )
162
+
163
+ monkeypatch.setattr(_httpx, "get", fake_get)
164
+
165
+ out = fetch_url("https://example.com/bounce")
166
+ assert out.startswith("WEB_FETCH_ERROR")
167
+ # Agent must never have actually requested the private IP.
168
+ assert not any("169.254.169.254" in u for u in visited)
169
+
170
+
171
+ def test_fetch_url_allows_redirect_to_public_hostname(monkeypatch):
172
+ """A legitimate 302 to a public hostname must still work end-to-end."""
173
+ import httpx as _httpx
174
+
175
+ def fake_get(url, *args, **kwargs):
176
+ # Jina path: fail so fallback path exercises the redirect logic.
177
+ if url.startswith("https://r.jina.ai/"):
178
+ return _FakeResp(status_code=403, text="")
179
+ if url == "https://example.com/start":
180
+ return _FakeResp(
181
+ status_code=302,
182
+ headers={"Location": "https://www.example.com/final"},
183
+ )
184
+ if url == "https://www.example.com/final":
185
+ return _FakeResp(
186
+ status_code=200,
187
+ text="<html><body>hello world from final page</body></html>",
188
+ )
189
+ return _FakeResp(status_code=404, text="")
190
+
191
+ monkeypatch.setattr(_httpx, "get", fake_get)
192
+
193
+ out = fetch_url("https://example.com/start")
194
+ assert not out.startswith("WEB_FETCH_ERROR")
195
+ assert "hello world" in out.lower() or "final page" in out.lower()
196
+
197
+
198
+ def test_fetch_url_blocks_redirect_loop(monkeypatch):
199
+ """Bounded redirect follower stops instead of looping forever."""
200
+ import httpx as _httpx
201
+
202
+ calls = {"n": 0}
203
+
204
+ def fake_get(url, *args, **kwargs):
205
+ if url.startswith("https://r.jina.ai/"):
206
+ return _FakeResp(status_code=403, text="")
207
+ calls["n"] += 1
208
+ return _FakeResp(
209
+ status_code=302,
210
+ headers={"Location": "https://example.com/next"},
211
+ )
212
+
213
+ monkeypatch.setattr(_httpx, "get", fake_get)
214
+
215
+ out = fetch_url("https://example.com/start")
216
+ assert out.startswith("WEB_FETCH_ERROR")
217
+ # Must have stopped — not burning arbitrary calls.
218
+ assert calls["n"] <= 10