Coverage for src/local_deep_research/web_search_engines/engines/full_search.py: 97%

236 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-22 21:20 +0000

1import time 

2from datetime import datetime, UTC 

3from typing import ( 

4 Any, 

5 Callable, 

6 Dict, 

7 List, 

8 Optional, 

9 Protocol, 

10 runtime_checkable, 

11) 

12 

13from langchain_core.language_models import BaseLLM 

14 

15from ...config.search_config import QUALITY_CHECK_DDG_URLS 

16from ..engine_availability import SearchFailure, engine_failure 

17from ...research_library.downloaders.extraction import ( 

18 batch_fetch_and_extract, 

19) 

20from ...security.egress.policy import EgressScope, PolicyDeniedError 

21from ...security.log_sanitizer import scrub_error 

22from ...security.secure_logging import logger 

23from ...security.ssrf_validator import redact_url_for_log, validate_url 

24from ...utilities.js_rendering import ( 

25 read_js_rendering_setting as _read_js_rendering_setting, 

26) 

27from ...utilities.json_utils import extract_json, get_llm_response_text 

28from ...utilities.type_utils import resolve_boolean_setting 

29from ...utilities.llm_utils import invoke_llm_sync 

30 

31 

32@runtime_checkable 

33class _Invokable(Protocol): 

34 def invoke(self, query: str) -> Any: ... 34 ↛ exitline 34 didn't return from function 'invoke' because

35 

36 

37# Minimum seconds between bulk-fetch heartbeat milestones. Each UI 

38# emit is a DB row + socket event, so per-page emits would spam the 

39# research log on a 46-page fetch; the start announcement is always 

40# emitted and the completion milestone ("From the web …") follows the 

41# fetch, so heartbeats only fill the silence in between. 

42_FETCH_HEARTBEAT_SECONDS = 15.0 

43 

44 

45def make_fetch_progress_adapter(progress_callback): 

46 """Adapt bulk-fetch ``(done, total)`` progress to UI milestones. 

47 

48 Shared by the pipeline-strategy wiring (``AdvancedSearchSystem`` → 

49 ``FullSearchResults``) and the langgraph-agent tool factories, so 

50 both surfaces throttle identically. 

51 

52 Returns a hook for engine page-fetch progress that: 

53 * announces the fetch upfront (``done == 0``) so a minutes-long 

54 fetch never looks stalled, 

55 * emits a heartbeat at most every ``_FETCH_HEARTBEAT_SECONDS``, 

56 * stays silent on the final page — the completion milestone 

57 ("From the web …") follows immediately, 

58 * performs a silent termination check on every page (no UI 

59 emission): each check doubles as a cancellation check, because 

60 the UI callback raises ``ResearchTerminatedException`` when the 

61 user cancels — and that derives ``BaseException``, so it 

62 propagates through the fetch pipeline's ``except Exception`` 

63 handlers untouched. Cancellation during a long fetch therefore 

64 takes effect promptly instead of only after the whole batch. 

65 

66 ``progress_callback`` is the 3-arg UI callback 

67 ``(message, percent, metadata)``; ``None`` disables everything. 

68 """ 

69 state = {"last_emit": 0.0} 

70 

71 def _on_fetch_progress(done: int, total: int) -> None: 

72 if progress_callback is None or total <= 0: 

73 return 

74 # Silent cancellation check on every page — no UI/logging. 

75 progress_callback( 

76 "Checking termination status", 

77 None, 

78 {"phase": "termination_check"}, 

79 ) 

80 if done >= total: 

81 return 

82 now = time.monotonic() 

83 if done > 0 and now - state["last_emit"] < _FETCH_HEARTBEAT_SECONDS: 

84 return 

85 state["last_emit"] = now 

86 if done <= 0: 

87 progress_callback( 

88 f"📄 Fetching full content for {total} pages — " 

89 "this can take a minute…", 

90 None, 

91 {"phase": "fetch", "type": "milestone", "total": total}, 

92 ) 

93 else: 

94 progress_callback( 

95 f"📄 Fetching page content ({done}/{total})…", 

96 None, 

97 { 

98 "phase": "fetch", 

99 "type": "milestone", 

100 "done": done, 

101 "total": total, 

102 }, 

103 ) 

104 

105 return _on_fetch_progress 

106 

107 

108# Egress scopes whose ``evaluate_url`` admits every http(s) host the SSRF 

109# validator admits (UNPROTECTED lifts only the scope restriction; the hard 

110# SSRF and cloud-metadata blocks stay and the download pipeline enforces 

111# those itself on every destination). Under any other scope the run policy 

112# restricts destinations by address class (PUBLIC_ONLY: no private hosts; 

113# PRIVATE_ONLY / STRICT: no public hosts; BOTH: classified hosts only), 

114# so the operator's private-result grant is withheld there from both result 

115# URL validation and the pipeline. The pipeline now checks each redirect and 

116# pinned address against the run policy; browser fallback is disabled for 

117# restricted scopes because Chromium resolves its own DNS. ADAPTIVE never 

118# reaches this module (resolved at context construction); an unknown scope 

119# fails closed. 

120_SCOPES_WITHOUT_HOST_CLASS_RESTRICTION = frozenset({EgressScope.UNPROTECTED}) 

121 

122 

123class FullSearchResults: 

124 def __init__( 

125 self, 

126 llm: Optional[BaseLLM], 

127 web_search: _Invokable, 

128 output_format: str = "list", 

129 language: str = "English", 

130 max_results: int = 10, 

131 region: str = "wt-wt", 

132 time: Optional[str] = "y", 

133 safesearch: str | int = "Moderate", 

134 settings_snapshot: Optional[Dict] = None, 

135 egress_context: Optional[Any] = None, 

136 allow_private_ips: bool = False, 

137 content_cache: Optional[Any] = None, 

138 progress_callback: Optional[Callable[[int, int], None]] = None, 

139 ): 

140 self.llm = llm 

141 self.output_format = output_format 

142 self.language = language 

143 self.max_results = max_results 

144 self.region = region 

145 self.time = time 

146 self.safesearch = safesearch 

147 self.web_search = web_search 

148 self.settings_snapshot = settings_snapshot 

149 # The parent engine's resolved result-fetch grant, handed over by 

150 # the factory when it builds this wrapper 

151 # (``BaseSearchEngine.allow_private_result_fetch``; e.g. SearXNG: 

152 # instance approved for private egress AND the env-only 

153 # ``search.allow_private_result_fetch`` opt-in). When True, SSRF 

154 # validation of result URLs may pass private/loopback IPs; the 

155 # default stays False so ungated/public engines never fetch internal 

156 # URLs. It applies (to that validation and to the download 

157 # pipeline) only when the run policy imposes no host-class 

158 # restriction, 

159 # i.e. without a run context or under ``unprotected`` 

160 # (``_effective_allow_private_ips``). 

161 # Link-local and the cloud-metadata literals in 

162 # ALWAYS_BLOCKED_METADATA_IPS stay blocked regardless (issue #2477). 

163 self.allow_private_ips = allow_private_ips 

164 # Optional ``(done, total)`` liveness hook for the bulk page 

165 # fetch below (see ``batch_fetch_and_extract``). Threaded in by 

166 # the pipeline strategy (``AdvancedSearchSystem`` → ``get_search``) 

167 # and by agent search tools so a minutes-long fetch emits progress 

168 # instead of silence; ``None`` disables it with zero overhead. 

169 self.progress_callback = progress_callback 

170 # Set by the factory when the parent engine is gated against a 

171 # specific scope; used to evaluate per-URL fetches below. 

172 self.egress_context = egress_context 

173 self.content_cache = content_cache 

174 # One operator-visible line per wrapper when the grant is set but 

175 # the run scope keeps it from applying 

176 # (``_effective_allow_private_ips``). 

177 self._grant_withheld_logged = False 

178 

179 def _resolve_cache(self) -> Optional[Any]: 

180 if self.content_cache is not None: 

181 return self.content_cache 

182 try: 

183 from ...utilities.research_content_cache import get_current_cache 

184 except Exception: # pragma: no cover - defensive 

185 return None 

186 try: 

187 return get_current_cache() 

188 except Exception: # pragma: no cover - defensive 

189 return None 

190 

191 def set_fetch_progress_callback(self, hook) -> None: 

192 """Late-bind the ``(done, total)`` bulk-fetch hook. 

193 

194 Used by ``AdvancedSearchSystem.set_progress_callback`` when the UI 

195 callback arrives after engine construction. 

196 """ 

197 self.progress_callback = hook 

198 

199 @property 

200 def last_search_failure(self) -> SearchFailure | None: 

201 """Expose provider failures through the optional full-content wrapper.""" 

202 return engine_failure(self.web_search) 

203 

204 def _effective_allow_private_ips(self) -> bool: 

205 """The private result-fetch grant as it applies to this run. 

206 

207 Used for BOTH the SSRF validation of result URLs in ``run`` / 

208 ``_get_full_content`` and the download pipeline. The pipeline 

209 checks each redirect and pinned address against the run scope. The 

210 private-result grant remains limited to no run context at all, 

211 or a scope in ``_SCOPES_WITHOUT_HOST_CLASS_RESTRICTION`` 

212 (``unprotected``). Under every other scope the grant is withheld 

213 from both, so a private result URL is refused by ``validate_url`` 

214 exactly as if the operator had not set the opt-in. That refusal 

215 happens before ``evaluate_url`` runs, so it does not consume the 

216 run's ``MAX_DENIED_FETCHES_PER_RUN`` quota. Hosts the SSRF validator 

217 admits but the policy cannot classify public (including benchmark 

218 and documentation hosts) are refused uncounted by ``_refuse_private_uncounted`` instead, under 

219 the same scopes (every scope but ``unprotected``). 

220 """ 

221 if not self.allow_private_ips: 

222 return False 

223 egress_ctx = self.egress_context 

224 if egress_ctx is None: 

225 return True 

226 scope = getattr(egress_ctx, "scope", None) 

227 if scope in _SCOPES_WITHOUT_HOST_CLASS_RESTRICTION: 

228 return True 

229 if not getattr(self, "_grant_withheld_logged", False): 

230 self._grant_withheld_logged = True 

231 scope_name = getattr(scope, "value", scope) 

232 logger.info( 

233 "full_search: private result-fetch grant " 

234 "(LDR_SEARCH_ALLOW_PRIVATE_RESULT_FETCH) not applied under " 

235 f"egress scope {scope_name}; it takes effect only under the " 

236 "unprotected scope, so private result URLs stay blocked" 

237 ) 

238 return False 

239 

240 @staticmethod 

241 def _refuse_private_uncounted(url: str, egress_ctx: Any) -> bool: 

242 """Drop a result URL whose host the policy cannot classify public, 

243 uncounted, under every scope that withholds the private-fetch grant. 

244 

245 Private result URLs stay blocked under every scope but 

246 ``unprotected`` (``_effective_allow_private_ips``). ``validate_url`` 

247 refuses most of them, but its ``PRIVATE_IP_RANGES`` is narrower than 

248 the policy's classifier: it also admits benchmark, documentation and 

249 other special-purpose address ranges. ``evaluate_url`` counts 

250 denials under every scope, so a page of these URLs could exhaust 

251 the run's ``MAX_DENIED_FETCHES_PER_RUN`` quota and block later public 

252 fetches. It may admit private hosts under ``strict`` / 

253 ``private_only`` / ``both``. So they are refused 

254 here, before ``evaluate_url``, through the policy's own 

255 non-counting ``host_not_classified_public`` (same host 

256 classification as ``evaluate_url``), under every scope except the 

257 ones in ``_SCOPES_WITHOUT_HOST_CLASS_RESTRICTION``; an unknown scope 

258 fails closed. A URL with an empty host is refused too; an unresolvable 

259 host or a DNS timeout is classified public (the classifier's fail-safe) 

260 and is left to ``validate_url`` and ``evaluate_url``. 

261 This uncounted check covers the result URL. Static redirects and 

262 pinned connect addresses receive the transport's SSRF and run-scope 

263 checks. Browser fallback is disabled under restricted scopes; when 

264 available under Both or Unprotected, it retains SSRF checks. 

265 """ 

266 scope = getattr(egress_ctx, "scope", None) 

267 if scope in _SCOPES_WITHOUT_HOST_CLASS_RESTRICTION: 

268 return False 

269 from ...security.egress.policy import host_not_classified_public 

270 

271 if not host_not_classified_public(url, egress_ctx): 

272 return False 

273 scope_name = getattr(scope, "value", scope) 

274 logger.bind(policy_audit=True).warning( 

275 "full_search URL denied by egress policy (non-public result " 

276 "host, not counted toward the denied-fetch quota)", 

277 url=redact_url_for_log(url), 

278 scope=scope_name, 

279 reason=f"nonpublic_result_host_{scope_name}", 

280 ) 

281 return True 

282 

283 def _url_quality_prompt(self, results: List[Dict], query: str) -> str: 

284 """Build the URL-quality prompt shared by the sync and async paths.""" 

285 now = datetime.now(UTC) 

286 current_time = now.strftime("%Y-%m-%d") 

287 return f"""ONLY Return a JSON array. The response contains no letters. Evaluate these URLs for: 

288 1. Timeliness (today: {current_time}) 

289 2. Factual accuracy (cross-reference major claims) 

290 3. Source reliability (prefer official company websites, established news outlets) 

291 4. Direct relevance to query: {query} 

292 

293 URLs to evaluate: 

294 {results} 

295 

296 Return a JSON array of indices (0-based) for sources that meet ALL criteria. 

297 ONLY Return a JSON array of indices (0-based) and nothing else. No letters. 

298 Example response: \n[0, 2, 4]\n\n""" 

299 

300 def _keep_selected(self, results: List[Dict], response: Any) -> List[Dict]: 

301 """Turn the LLM's index array into the filtered result list.""" 

302 response_text = get_llm_response_text(response) 

303 good_indices = extract_json(response_text, expected_type=list) 

304 

305 if good_indices is None: 

306 good_indices = [] 

307 

308 return [r for i, r in enumerate(results) if i in good_indices] 

309 

310 def _url_filter_error_message(self, error: Exception) -> str: 

311 """Build the scrubbed log message for an LLM-side filter failure.""" 

312 safe_msg = scrub_error(error) 

313 return f"URL filtering error ({type(error).__name__}): {safe_msg}" 

314 

315 def _unfiltered_fallback(self, results: List[Dict]) -> List[Dict]: 

316 """Fall back to unfiltered results after an LLM-side filter failure. 

317 

318 The ``logger.exception(...)`` call itself stays in each 

319 ``except Exception as e:`` block below (sync and async) rather than 

320 here, so the caught exception stays visible to 

321 ``.pre-commit-hooks/check-sensitive-logging.py`` — it tracks 

322 ``except ... as name`` bindings, and a helper taking the exception 

323 as a plain parameter falls outside what it can see. 

324 """ 

325 logger.warning( 

326 "URL quality filter unavailable — returning {} unfiltered " 

327 "results as fallback", 

328 len(results), 

329 ) 

330 return results # Fall back to original results on LLM error 

331 

332 def check_urls(self, results: List[Dict], query: str) -> List[Dict]: 

333 """Filter results down to the URLs the LLM judges usable. 

334 

335 Stays on the synchronous LangChain API: FullSearchResults runs inside 

336 per-run research threads with no event loop, and langchain's async 

337 httpx client is process-cached and loop-bound, so driving the async 

338 core on a throwaway per-call loop would break or re-send every call 

339 after the first (see #6293). 

340 """ 

341 if not results: 

342 return results 

343 

344 prompt = self._url_quality_prompt(results, query) 

345 

346 try: 

347 if self.llm is None: 

348 return results 

349 return self._keep_selected( 

350 results, invoke_llm_sync(self.llm, prompt) 

351 ) 

352 except PolicyDeniedError: 

353 # The URL-quality LLM was denied by egress policy (e.g. a cloud 

354 # LLM under require_local / PRIVATE_ONLY). Do NOT fall through to 

355 # the unfiltered-results fallback below — that would let the run 

356 # proceed despite the user's policy refusing the LLM. Fail closed. 

357 raise 

358 except Exception as e: 

359 safe_msg = self._url_filter_error_message(e) 

360 logger.exception(safe_msg) 

361 return self._unfiltered_fallback(results) 

362 

363 async def _check_urls_async( 

364 self, results: List[Dict], query: str 

365 ) -> List[Dict]: 

366 """Async counterpart of :meth:`check_urls`. 

367 

368 Additive: no production caller awaits it yet. It is intended to be 

369 submitted to the process's one shared event loop via 

370 ``asyncio.run_coroutine_threadsafe`` (ADR-0013, 

371 docs/decisions/0013-research-runs-keep-daemon-thread.md); which 

372 thread hosts that loop is not decided yet (#6467). 

373 :meth:`check_urls` remains the canonical production path on the 

374 research thread, and this must never go through ``asyncio.run`` or 

375 a per-thread loop (#6293). 

376 

377 Not loop-safe yet, for three reasons: ``TokenCountingCallback`` 

378 writes call-usage rows to the user's encrypted database from the 

379 loop's default executor rather than the daemon thread (rule 5; 

380 #6294 is related); the egress audit context is thread-local and 

381 must be passed in and re-armed on the loop side; and the submit 

382 helper must run the coroutine in an explicit minimal context so it 

383 does not inherit the daemon thread's ambient search context, 

384 including the password (rule 4). 

385 """ 

386 if not results: 386 ↛ 387line 386 didn't jump to line 387 because the condition on line 386 was never true

387 return results 

388 

389 prompt = self._url_quality_prompt(results, query) 

390 

391 try: 

392 if self.llm is None: 392 ↛ 393line 392 didn't jump to line 393 because the condition on line 392 was never true

393 return results 

394 return self._keep_selected(results, await self.llm.ainvoke(prompt)) 

395 except PolicyDeniedError: 

396 # Fail closed exactly like the sync path: an egress-policy denial 

397 # must not fall through to the unfiltered-results fallback. 

398 raise 

399 except Exception as e: 

400 safe_msg = self._url_filter_error_message(e) 

401 logger.exception(safe_msg) 

402 return self._unfiltered_fallback(results) 

403 

404 def run( 

405 self, query: str, research_context: Dict[str, Any] | None = None 

406 ) -> List[Dict[str, Any]]: 

407 # ``research_context`` is accepted for signature parity with 

408 # ``BaseSearchEngine.run`` and its other overrides (e.g. SearXNG, 

409 # arXiv), which callers such as ``FocusedIterationStrategy`` 

410 # invoke positionally by keyword. This engine derives everything 

411 # it needs from ``query`` and its own settings, so the value is 

412 # deliberately unused. 

413 # Step 1: Get search results 

414 search_results = self.web_search.invoke(query) 

415 if not isinstance(search_results, list): 

416 raise ValueError("Expected the search results in list format.") 

417 

418 # Step 2: Filter URLs using LLM if enabled 

419 should_check_urls = resolve_boolean_setting( 

420 self.settings_snapshot, 

421 "search.quality_check_urls", 

422 default=QUALITY_CHECK_DDG_URLS, 

423 ) 

424 if should_check_urls and self.llm is not None: 

425 logger.info( 

426 "Evaluating search result URLs with LLM before fetching full content..." 

427 ) 

428 filtered_results = self.check_urls(search_results, query) 

429 else: 

430 filtered_results = search_results 

431 

432 # Extract URLs from filtered results 

433 urls = [ 

434 result.get("link") 

435 for result in filtered_results 

436 if result.get("link") 

437 ] 

438 

439 if not urls: 

440 logger.error("\n === NO VALID LINKS ===\n") 

441 return [] 

442 

443 # SSRF-validate + egress-scope-validate each URL. SSRF only checks 

444 # IP class; scope enforcement is the separate axis that blocks 

445 # public hosts under PRIVATE_ONLY / STRICT. 

446 evaluate_url_fn = None 

447 egress_ctx = self.egress_context 

448 if egress_ctx is not None: 

449 from ...security.egress.policy import evaluate_url as _ev_url 

450 

451 evaluate_url_fn = _ev_url 

452 allow_private = self._effective_allow_private_ips() 

453 safe_urls: List[str] = [] 

454 for url in urls: 

455 if url is None: 455 ↛ 456line 455 didn't jump to line 456 because the condition on line 455 was never true

456 continue 

457 # block_link_local=True is the carve-out inside the private-IP 

458 # grant: the rest of the link-local range hosts provider metadata 

459 # beyond ALWAYS_BLOCKED_METADATA_IPS (Scaleway's endpoint, for 

460 # one) and no legitimate result page lives there. No effect 

461 # without the grant, where link-local is already blocked. 

462 if not validate_url( 

463 url, 

464 allow_private_ips=allow_private, 

465 block_link_local=True, 

466 ): 

467 logger.warning( 

468 "SSRF validation blocked URL from full content fetch: " 

469 f"{redact_url_for_log(url)}." 

470 ) 

471 continue 

472 if egress_ctx is not None and self._refuse_private_uncounted( 

473 url, egress_ctx 

474 ): 

475 continue 

476 if evaluate_url_fn is not None and egress_ctx is not None: 

477 url_decision = evaluate_url_fn(url, egress_ctx) 

478 if not url_decision.allowed: 

479 logger.bind(policy_audit=True).warning( 

480 "full_search URL denied by egress policy", 

481 url=redact_url_for_log(url), 

482 scope=egress_ctx.scope.value, 

483 reason=url_decision.reason, 

484 ) 

485 continue 

486 safe_urls.append(url) 

487 

488 if not safe_urls: 

489 logger.warning( 

490 "All URLs were blocked by SSRF validation — returning results " 

491 "without full content. This can happen when search results " 

492 "point to internal/private network addresses." 

493 ) 

494 for result in filtered_results: 

495 result["full_content"] = None 

496 return filtered_results 

497 

498 # Fetch and extract all pages — specialized downloaders (arXiv, 

499 # PubMed, etc.) are tried first, with HTML crawling as fallback for 

500 # every type but arXiv paper URLs, which their downloader owns 

501 # outright: for those a failure is terminal and yields no content. 

502 # Other arXiv-host pages are not owned and do crawl. 

503 logger.info( 

504 f"Full search: fetching and extracting full content for {len(safe_urls)} pages..." 

505 ) 

506 url_to_content = batch_fetch_and_extract( 

507 safe_urls, 

508 language=self.language, 

509 enable_js_rendering=_read_js_rendering_setting( 

510 self.settings_snapshot 

511 ), 

512 content_cache=self._resolve_cache(), 

513 allow_private_ips=allow_private, 

514 block_link_local=True, 

515 egress_context=egress_ctx, 

516 progress_callback=self.progress_callback, 

517 ) 

518 

519 nr_full_text = sum(1 for v in url_to_content.values() if v) 

520 for result in filtered_results: 

521 link = result.get("link") 

522 result["full_content"] = url_to_content.get(link) if link else None 

523 result["full_content_is_complete"] = bool(result["full_content"]) 

524 

525 logger.info(f"Full search: retrieved content from {nr_full_text} pages") 

526 return filtered_results 

527 

528 def _get_full_content( 

529 self, relevant_items: List[Dict[str, Any]] 

530 ) -> List[Dict[str, Any]]: 

531 """Fetch and attach full content to an existing list of items.""" 

532 evaluate_url_fn = None 

533 egress_ctx = self.egress_context 

534 if egress_ctx is not None: 

535 from ...security.egress.policy import evaluate_url as _ev_url 

536 

537 evaluate_url_fn = _ev_url 

538 allow_private = self._effective_allow_private_ips() 

539 urls: List[str] = [] 

540 for item in relevant_items: 

541 link = item.get("link") 

542 if link is None: 

543 continue 

544 if not validate_url( 

545 link, 

546 allow_private_ips=allow_private, 

547 block_link_local=True, 

548 ): 

549 logger.warning( 

550 "SSRF validation blocked URL from full content fetch: " 

551 f"{redact_url_for_log(link)}." 

552 ) 

553 continue 

554 if egress_ctx is not None and self._refuse_private_uncounted( 

555 link, egress_ctx 

556 ): 

557 continue 

558 if evaluate_url_fn is not None and egress_ctx is not None: 

559 url_decision = evaluate_url_fn(link, egress_ctx) 

560 if not url_decision.allowed: 

561 logger.bind(policy_audit=True).warning( 

562 "full_search _get_full_content URL denied by egress policy", 

563 url=redact_url_for_log(link), 

564 scope=egress_ctx.scope.value, 

565 reason=url_decision.reason, 

566 ) 

567 continue 

568 urls.append(link) 

569 

570 if not urls: 

571 for item in relevant_items: 

572 item["full_content"] = None 

573 return relevant_items 

574 

575 try: 

576 url_to_content = batch_fetch_and_extract( 

577 urls, 

578 language=self.language, 

579 enable_js_rendering=_read_js_rendering_setting( 

580 self.settings_snapshot 

581 ), 

582 content_cache=self._resolve_cache(), 

583 allow_private_ips=allow_private, 

584 block_link_local=True, 

585 egress_context=egress_ctx, 

586 progress_callback=self.progress_callback, 

587 ) 

588 except PolicyDeniedError: 

589 raise 

590 except Exception as e: 

591 safe_msg = scrub_error(e) 

592 logger.exception( 

593 f"Error fetching full content ({type(e).__name__}): {safe_msg}" 

594 ) 

595 for item in relevant_items: 

596 item["full_content"] = None 

597 return relevant_items 

598 

599 for item in relevant_items: 

600 link = item.get("link") 

601 item["full_content"] = url_to_content.get(link) if link else None 

602 item["full_content_is_complete"] = bool(item["full_content"]) 

603 

604 return relevant_items 

605 

606 def close(self) -> None: 

607 """Close the underlying search engine and any held resources.""" 

608 from ...utilities.resource_utils import safe_close 

609 

610 if hasattr(self, "web_search") and self.web_search is not None: 610 ↛ exitline 610 didn't return from function 'close' because the condition on line 610 was always true

611 safe_close( 

612 self.web_search, 

613 "wrapped web search engine", 

614 closing_optional=True, 

615 ) 

616 

617 def __enter__(self): 

618 """Support context manager usage.""" 

619 return self 

620 

621 def __exit__(self, exc_type, exc_val, exc_tb): 

622 """Cleanup on context exit.""" 

623 self.close() 

624 return False 

625 

626 def invoke(self, query: str) -> Any: 

627 return self.run(query) 

628 

629 def __call__(self, query: str) -> Any: 

630 return self.invoke(query)