Coverage for src/local_deep_research/advanced_search_system/tools/fetch/__init__.py: 96%

485 statements  

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

1"""Agent-facing ``fetch_content`` tool builders. 

2 

3Public API: 

4 FETCH_MODES — tuple of valid mode strings. 

5 build_fetch_tool() — returns a LangChain ``@tool`` (or ``None`` when 

6 mode == "disabled" so the caller can skip 

7 registration). 

8 

9Modes: 

10 disabled — fetch tool is not registered with the agent. 

11 full — return the full extracted page text (legacy 

12 behavior; can flood small-model context with 

13 boilerplate / metadata enrichment). 

14 summary_focus — LLM extracts only spans relevant to a focus 

15 question the agent supplies per call. 

16 summary_focus_query — same as above, but the prompt also includes 

17 the original research query (passed in 

18 programmatically by the strategy) so the 

19 extractor can disambiguate vague focuses. 

20 

21Each tool registers fetched URLs in the strategy's 

22``SearchResultsCollector`` for citation tracking, returning the result as 

23``[N] Title: ...\nURL: ...\n\n<body>`` exactly like the original 

24in-strategy implementation, so downstream prompt formatting is unchanged. 

25""" 

26 

27from __future__ import annotations 

28 

29from typing import Any 

30 

31from langchain_core.language_models import BaseChatModel 

32from langchain_core.tools import tool 

33from loguru import logger 

34 

35from local_deep_research.utilities.js_rendering import ( 

36 read_js_rendering_setting as _read_js_rendering_setting, 

37) 

38from local_deep_research.security import ( 

39 redact_url_for_log, 

40 sanitize_error_for_agent, 

41) 

42from local_deep_research.settings.exceptions import SettingsValueError 

43from local_deep_research.utilities.type_utils import ( 

44 to_positive_int, 

45 unwrap_setting, 

46) 

47from local_deep_research.utilities.llm_utils import invoke_llm_sync 

48 

49from .library_resolver import ( 

50 is_citation_reference, 

51 make_library_resolver, 

52 resolve_citation_reference as _resolve_citation_reference, 

53) 

54from .prompts import SUMMARY_FOCUS_PROMPT, SUMMARY_FOCUS_QUERY_PROMPT 

55 

56 

57# Per-call timeouts and caps. Kept here rather than in the strategy file 

58# because they are properties of the fetch tool, not of agent 

59# orchestration. 

60CONTENT_FETCH_TIMEOUT = 30 

61CONTENT_MAX_LENGTH = 10_000 

62SUMMARY_INPUT_MAX_LENGTH = 500_000 

63 

64# Bounds for the user-configurable ``search.fetch.content_max_length`` 

65# setting in full mode (see default_settings.json). Summary modes have a 

66# separate input budget with no fixed upper setting limit. 

67CONTENT_MAX_LENGTH_MIN = 1_000 

68CONTENT_MAX_LENGTH_MAX = 50_000 

69 

70# The summary prompt limit is counted in characters, estimated at four per 

71# token. That rate fits English-like text. Text that needs more tokens per 

72# character, such as Chinese or Japanese or long runs of digits, can still 

73# exceed the window. 

74SUMMARY_INPUT_CHARS_PER_TOKEN = 4 

75 

76# Fixed text: it never echoes the configured value. It names the 

77# environment variable because an override there takes precedence and is 

78# not editable in the web UI. 

79SUMMARY_INPUT_MAX_LENGTH_ERROR = ( 

80 "search.fetch.summary_input_max_length must be a positive integer. " 

81 "Check the setting and any LDR_SEARCH_FETCH_SUMMARY_INPUT_MAX_LENGTH " 

82 "environment override." 

83) 

84 

85 

86FETCH_MODES = ( 

87 "disabled", 

88 "full", 

89 "summary_focus", 

90 "summary_focus_query", 

91) 

92 

93# Default call budget for fetch_content per subagent (and lead agent) per topic. 

94# Prevents autonomous agents from exhausting recursion limits on fetches. 

95DEFAULT_MAX_FETCHES_PER_TOPIC = 2 

96 

97 

98def _read_max_fetches_setting( 

99 settings_snapshot: dict | None, 

100 default: int = DEFAULT_MAX_FETCHES_PER_TOPIC, 

101) -> int: 

102 """Read the ``search.fetch.max_per_topic`` setting from a settings snapshot. 

103 

104 Retained as a fallback for direct callers of ``build_fetch_tool`` outside 

105 ``LangGraphAgentStrategy`` (which passes ``max_fetches`` explicitly). 

106 

107 Returns the configured non-negative integer limit, or default if it is 

108 missing or an untyped value is not a usable limit. Negative values fall 

109 back to default, and values above 50 are clamped to 50. 0 means unlimited 

110 fetches. 

111 

112 Typed number rows use the canonical accessor's float conversion (fractional 

113 strings truncate) and environment priority. A non-empty 

114 ``LDR_SEARCH_FETCH_MAX_PER_TOPIC`` overrides such a row. A typed row or an 

115 override that is not a finite number (a boolean included), or a row whose 

116 type is unsupported, propagates SettingsValueError instead of selecting 

117 another limit. A negative resulting limit falls back to the caller's 

118 default. 

119 

120 Raw values and sparse legacy rows retain snapshot-only int() coercion. 

121 An absent exact key keeps the caller's default even with an environment or 

122 thread-local value. Explicit ``max_fetches`` bypasses this fallback reader. 

123 """ 

124 key = "search.fetch.max_per_topic" 

125 # Missing snapshots keep the caller's default, not a thread-local value. 

126 if not settings_snapshot or key not in settings_snapshot: 

127 return default 

128 from local_deep_research.config.thread_settings import ( 

129 get_setting_from_snapshot, 

130 ) 

131 

132 lookup_snapshot = settings_snapshot 

133 entry = settings_snapshot[key] 

134 if ( 

135 isinstance(entry, dict) 

136 and "value" in entry 

137 and "ui_element" not in entry 

138 ): 

139 # Sparse legacy rows have no UI type. Preserve their int() coercion 

140 # instead of letting the accessor infer text for floats/booleans. 

141 raw = unwrap_setting(entry) 

142 if isinstance(raw, dict): 

143 return default 

144 lookup_snapshot = {key: raw} 

145 

146 try: 

147 val = get_setting_from_snapshot( 

148 key, default=None, settings_snapshot=lookup_snapshot 

149 ) 

150 except SettingsValueError: 

151 raise 

152 except Exception: 

153 return default 

154 if val is None: 

155 return default 

156 unwrapped = unwrap_setting(val) 

157 try: 

158 limit = int(unwrapped) 

159 if limit < 0: 

160 return default 

161 return min(50, limit) 

162 except (TypeError, ValueError, OverflowError): 

163 return default 

164 

165 

166def _read_content_max_length_setting( 

167 settings_snapshot: dict | None, 

168 default: int = CONTENT_MAX_LENGTH, 

169) -> int: 

170 """Read the ``search.fetch.content_max_length`` setting from a settings snapshot. 

171 

172 The value caps page text (HTTP or local library document) before it is 

173 returned directly in ``full`` mode. Summary modes use their own input 

174 budget instead. 

175 

176 Returns the configured length clamped to 

177 [CONTENT_MAX_LENGTH_MIN, CONTENT_MAX_LENGTH_MAX]. Missing, non-positive, 

178 or unconvertible untyped legacy values retain the default. Invalid managed 

179 settings propagate SettingsValueError instead of selecting another limit. 

180 """ 

181 if not settings_snapshot: 

182 return default 

183 from local_deep_research.config.thread_settings import ( 

184 get_setting_from_snapshot, 

185 ) 

186 

187 try: 

188 val = get_setting_from_snapshot( 

189 "search.fetch.content_max_length", 

190 default=None, 

191 settings_snapshot=settings_snapshot, 

192 ) 

193 except SettingsValueError: 

194 raise 

195 except Exception: 

196 return default 

197 if val is None: 

198 return default 

199 unwrapped = unwrap_setting(val) 

200 try: 

201 if isinstance(unwrapped, str): 

202 stripped = unwrapped.strip() 

203 try: 

204 limit = int(stripped) 

205 except ValueError: 

206 # Allow float strings like "10000.7" (truncate toward zero, 

207 # matching int(float) semantics for numeric inputs). 

208 limit = int(float(stripped)) 

209 else: 

210 limit = int(unwrapped) 

211 if limit <= 0: 

212 return default 

213 return min(CONTENT_MAX_LENGTH_MAX, max(CONTENT_MAX_LENGTH_MIN, limit)) 

214 except (TypeError, ValueError, OverflowError): 

215 return default 

216 

217 

218def _read_summary_input_max_length_setting( 

219 settings_snapshot: dict | None, 

220) -> int: 

221 """Read the positive summary-input character budget without an upper clamp. 

222 

223 Missing settings use the generous default. Invalid configured values 

224 raise rather than silently increasing a user's intended budget. Settings 

225 loads keep an invalid value, including an invalid environment override, 

226 unchanged, so this per-fetch read is where it fails. Zero is not an 

227 unlimited sentinel: HTTP and library slicing treat it differently. 

228 """ 

229 from local_deep_research.config.thread_settings import ( 

230 get_setting_from_snapshot, 

231 ) 

232 

233 value = unwrap_setting( 

234 get_setting_from_snapshot( 

235 "search.fetch.summary_input_max_length", 

236 default=SUMMARY_INPUT_MAX_LENGTH, 

237 settings_snapshot=settings_snapshot, 

238 ) 

239 ) 

240 try: 

241 return to_positive_int(value) 

242 except ValueError: 

243 pass 

244 raise ValueError(SUMMARY_INPUT_MAX_LENGTH_ERROR) 

245 

246 

247def _built_model_provider( 

248 model: Any, settings_snapshot: dict | None 

249) -> tuple[str | None, dict | None]: 

250 """Return the provider and settings ``get_llm`` built *model* with. 

251 

252 ``get_llm`` records both on the ``ProcessingLLMWrapper`` it returns, 

253 after any per-run provider override, so they describe the model that 

254 runs. Any other model has no known provider (``None``), and the 

255 tool's own snapshot stands in for its settings. 

256 """ 

257 from local_deep_research.config.llm_config import ProcessingLLMWrapper 

258 from local_deep_research.llm.providers.base import normalize_provider 

259 

260 if not isinstance(model, ProcessingLLMWrapper): 

261 return None, settings_snapshot 

262 provider = getattr(model, "_provider", None) 

263 snapshot = getattr(model, "_settings_snapshot", None) 

264 if not isinstance(snapshot, dict): 264 ↛ 265line 264 didn't jump to line 265 because the condition on line 264 was never true

265 snapshot = settings_snapshot 

266 # Registry overrides can use a built-in provider's name for an arbitrary 

267 # shared instance or factory. Its invocation contract and context are not 

268 # those of the provider LDR builds, even though get_llm wraps it. 

269 if model._owns_llm is False: 

270 return None, snapshot 

271 if not isinstance(provider, str) or not provider.strip(): 271 ↛ 272line 271 didn't jump to line 272 because the condition on line 271 was never true

272 return None, snapshot 

273 return normalize_provider(provider.strip()), snapshot 

274 

275 

276def _summary_invocation_budget( 

277 model: Any, settings_snapshot: dict | None 

278) -> tuple[int | None, dict[str, int]]: 

279 """Return the summary prompt character limit and per-call model options. 

280 

281 ``None`` means no limit: the model was built for a cloud provider with 

282 unrestricted context, which enforces its own limit. 

283 

284 Otherwise the limit is the context window minus the response 

285 allowance, in tokens, at ``SUMMARY_INPUT_CHARS_PER_TOKEN`` characters 

286 per token. The whole prompt counts, so the page gets what its 

287 instructions, focus, query, title and URL leave. 

288 

289 The window is resolved with ``get_context_window_for_provider``, as 

290 the built-in providers' builds resolve it, from the provider and 

291 settings ``get_llm`` used (see ``_built_model_provider``): 

292 

293 - Ollama: ``llm.local_context_window_size``, sent as ``num_ctx``. 

294 - LM Studio and llama.cpp: ``llm.local_context_window_size``. It caps 

295 their ``max_tokens`` but is not sent; each server keeps its own 

296 context length. 

297 - Any other provider: ``llm.context_window_size`` when 

298 ``llm.context_window_unrestricted`` is off. 

299 

300 When that resolves no window and the provider is not a known cloud 

301 provider (the egress policy's ``_CLOUD_LLM_PROVIDERS``), 

302 ``llm.local_context_window_size`` is assumed. That covers an 

303 OpenAI- or Anthropic-compatible endpoint, which may be local or 

304 private, a provider LDR does not ship, and a model ``get_llm`` did not 

305 build. A window that is unusable or below 1 falls back to that local 

306 window, then to ``DEFAULT_LOCAL_CONTEXT_WINDOW_SIZE``. 

307 

308 The response allowance is ``compute_max_tokens`` for that window: 

309 ``llm.max_tokens``, capped at ``MAX_TOKENS_CONTEXT_SHARE`` (80%) of 

310 it, which is the ``max_tokens`` OpenAI-compatible and Anthropic builds 

311 request. When that yields no positive value, for example with 

312 ``llm.supports_max_tokens`` off, the full 80% share is kept. Ollama is 

313 sent no ``max_tokens``, but its reply must fit in ``num_ctx`` too, so 

314 it keeps the same allowance. 

315 

316 An unrestricted compatible endpoint's existing model may request more 

317 output than the assumed local window permits. Pass the capped allowance 

318 through the existing model wrappers for this summary call only; do not 

319 mutate the shared model or bind around its rate limiter. 

320 """ 

321 from local_deep_research.config.thread_settings import ( 

322 NoSettingsContextError, 

323 ) 

324 from local_deep_research.llm.providers._helpers import ( 

325 DEFAULT_LOCAL_CONTEXT_WINDOW_SIZE, 

326 MAX_TOKENS_CONTEXT_SHARE, 

327 compute_max_tokens, 

328 get_context_window_for_provider, 

329 get_local_context_window, 

330 ) 

331 from local_deep_research.security.egress.policy import ( 

332 _CLOUD_LLM_PROVIDERS, 

333 ) 

334 

335 unusable = (TypeError, ValueError, OverflowError, NoSettingsContextError) 

336 provider, snapshot = _built_model_provider(model, settings_snapshot) 

337 try: 

338 window = get_context_window_for_provider( 

339 provider, settings_snapshot=snapshot 

340 ) 

341 except SettingsValueError: 

342 raise 

343 except unusable: 

344 window = None 

345 else: 

346 if window is None and provider in _CLOUD_LLM_PROVIDERS: 

347 return None, {} 

348 local_window_assumed = window is None or window < 1 

349 if local_window_assumed: 

350 try: 

351 window = get_local_context_window(snapshot) 

352 except SettingsValueError: 

353 raise 

354 except unusable: 

355 window = None 

356 if window is None or window < 1: 

357 window = DEFAULT_LOCAL_CONTEXT_WINDOW_SIZE 

358 

359 try: 

360 allowance = compute_max_tokens( 

361 settings_snapshot=snapshot, context_window_size=window 

362 ) 

363 except SettingsValueError: 

364 raise 

365 except unusable: 

366 allowance = None 

367 invocation_kwargs = {} 

368 if not isinstance(allowance, int) or allowance < 1: 

369 allowance = int(window * MAX_TOKENS_CONTEXT_SHARE) 

370 elif local_window_assumed and provider in ( 

371 "openai_endpoint", 

372 "anthropic_endpoint", 

373 ): 

374 invocation_kwargs["max_tokens"] = allowance 

375 return ( 

376 max(1, (window - allowance) * SUMMARY_INPUT_CHARS_PER_TOKEN), 

377 invocation_kwargs, 

378 ) 

379 

380 

381_warned_collector_missing_fetch_cap = False 

382 

383 

384def _warn_collector_lacks_fetch_recording() -> None: 

385 global _warned_collector_missing_fetch_cap 

386 if not _warned_collector_missing_fetch_cap: 

387 _warned_collector_missing_fetch_cap = True 

388 logger.warning( 

389 "Collector does not implement try_record_fetch; fetch content budget cap is disabled." 

390 ) 

391 

392 

393def _check_and_record_fetch( 

394 collector: Any, 

395 max_fetches: int, 

396 subagent_id: str | None = None, 

397) -> tuple[str | None, int | None]: 

398 """Check if the fetch budget has been exhausted. If not, record the fetch attempt. 

399 

400 Requires ``try_record_fetch`` on the collector (provided by 

401 ``SearchResultsCollector``). If collector is None, budget enforcement is skipped. 

402 If collector does not implement ``try_record_fetch``, a one-time warning is logged. 

403 

404 Accounting: Only invocations that reach the network consume a slot. Pre-resolved 

405 local library reads and egress policy denials do not consume budget slots. 

406 

407 Returns: 

408 (budget_err, current_count) 

409 budget_err is an informative error string if budget is exhausted, or None if the fetch can proceed. 

410 current_count is the current fetch count for this subagent/topic, or None. 

411 """ 

412 if collector is not None and hasattr(collector, "try_record_fetch"): 

413 try: 

414 allowed, count = collector.try_record_fetch( 

415 max_fetches, subagent_id=subagent_id 

416 ) 

417 except TypeError: 

418 allowed, count = collector.try_record_fetch(max_fetches) 

419 if not allowed: 

420 logger.warning( 

421 f"fetch_content call budget exhausted ({count}/{max_fetches}) for this topic" 

422 ) 

423 return ( 

424 f"Fetch content budget exhausted ({count}/{max_fetches}) for this topic. " 

425 "Rely on search snippets or synthesize your findings with the information already gathered.", 

426 count, 

427 ) 

428 return None, count 

429 if collector is not None: 429 ↛ 431line 429 didn't jump to line 431 because the condition on line 429 was always true

430 _warn_collector_lacks_fetch_recording() 

431 return None, None 

432 

433 

434def _register_in_collector( 

435 collector: Any, 

436 url: str, 

437 title: str, 

438 snippet_source: str, 

439 source_metadata: dict | None = None, 

440) -> int: 

441 """Register a fetched URL in the collector and return its 1-based citation index. 

442 

443 If the URL was already tracked (via a prior search hit) the existing 

444 index is reused so the agent sees a stable citation per URL. 

445 

446 .. note:: 

447 The ``find_or_add_result``-less branch below is **speculative 

448 generality**: ``SearchResultsCollector`` is the only collector in 

449 this repo and it has that method, so nothing in-tree reaches the 

450 fallback and no test exercises it against a real collector. It 

451 exists for third-party or pre-#5363 collectors. Treat it as 

452 unverified against any live caller — do not assume a bug there is 

453 affecting production, and do not grow it further without a real 

454 consumer to justify it. 

455 

456 This branch was left at its pre-existing shape deliberately. An 

457 earlier revision of this follow-up grew it — duck-typed tuple 

458 unpacking for a bare-``int`` ``add_results`` contract, a ``getattr`` 

459 guard on ``find_by_url`` — for a collector that does not exist in 

460 any repo we can see, which is precisely what the paragraph above 

461 forbids. That growth was reverted rather than shipped. 

462 """ 

463 metadata = _complete_archive_metadata(url, source_metadata) or {} 

464 source_engine = ( 

465 "wayback" if metadata.get("source_type") == "archive" else "fetch" 

466 ) 

467 if metadata.get("source_type") == "archive": 

468 title = f"{title or metadata['original_url']} (archived {metadata['formatted_date']})" 

469 snippet = snippet_source[:200].strip() 

470 if len(snippet_source) > 200: 

471 snippet += "..." 

472 if not hasattr(collector, "find_or_add_result"): 

473 # Legacy/custom collectors without find_or_add_result fallback 

474 _, indexed = collector.add_results( 

475 [{**metadata, "title": title, "link": url, "snippet": snippet}], 

476 engine_name=source_engine, 

477 ) 

478 if indexed: 478 ↛ 488line 478 didn't jump to line 488 because the condition on line 478 was always true

479 raw_idx = indexed[0].get("index") 

480 try: 

481 return int(raw_idx) 

482 except (ValueError, TypeError): 

483 logger.warning( 

484 "Failed to parse citation index for url={}: {!r}", 

485 redact_url_for_log(url), 

486 raw_idx, 

487 ) 

488 existing_idx = collector.find_by_url(url) 

489 if existing_idx is not None: 

490 return existing_idx 

491 raise RuntimeError( 

492 f"Failed to register fetched URL in collector: {redact_url_for_log(url)}" 

493 ) 

494 

495 return collector.find_or_add_result( 

496 {**metadata, "title": title, "link": url, "snippet": snippet}, 

497 engine_name=source_engine, 

498 ) 

499 

500 

501def _format_fetch_message(cite_idx, title, url, content, source_metadata): 

502 message = f"[{cite_idx}] Title: {title}\nURL: {url}\n\n{content}" 

503 if source_metadata and source_metadata.get("source_type") == "archive": 

504 message += ( 

505 f"\n\nOriginal URL: {source_metadata['original_url']}" 

506 f"\nCaptured: {source_metadata['formatted_date']}" 

507 ) 

508 requested = source_metadata.get("requested_url") 

509 if requested and requested != source_metadata["original_url"]: 

510 message += f"\nRequested URL: {requested}" 

511 return message 

512 

513 

514def _enforce_url_policy(url: str, egress_context: Any) -> None: 

515 """Run ``evaluate_url`` against ``egress_context`` and raise 

516 ``PolicyDeniedError`` on denial. 

517 

518 No-op when no context is configured (callers without policy enforcement, 

519 e.g. legacy non-LangGraph strategies, see the legacy behavior). 

520 """ 

521 if egress_context is None: 

522 return 

523 from local_deep_research.security.egress.policy import ( 

524 PolicyDeniedError, 

525 evaluate_url, 

526 ) 

527 

528 decision = evaluate_url(url, egress_context) 

529 if not decision.allowed: 

530 raise PolicyDeniedError(decision, target=url) 

531 

532 

533def _denial_reason(exc: Any) -> str: 

534 """Best-effort egress-denial reason code for an agent-facing message.""" 

535 return getattr(getattr(exc, "decision", None), "reason", "policy_denied") 

536 

537 

538def _is_fetch_cache_hit_allowed( 

539 url: str, egress_context: Any, _cache: Any | None = None 

540) -> bool: 

541 """Whether a shared-cache hit for *url* may be served under *egress_context*. 

542 

543 Defense-in-depth for the shared research cache: ``_enforce_url_policy`` 

544 (``evaluate_url``, scope-only) already passed at this point, but the 

545 uncached path still runs ``ContentFetcher``'s SSRF gate 

546 (``policy_aware_validate_url``) which refuses private/loopback hosts 

547 unless the run holds a private grant (``PRIVATE_ONLY``). The cache fast 

548 path must apply the same SSRF gate before serving — otherwise an 

549 ``UNPROTECTED``-scope run (where ``evaluate_url`` allows any host) 

550 would serve a cached private entry that the fetch path refuses. 

551 

552 Library routes never reach here as cache hits: valid references return 

553 early via ``_try_resolve_url`` (local DB read, no HTTP), and anything 

554 else fails this check closed (``validate_url`` rejects non-http schemes 

555 and unresolvable ``library.document`` aliases). 

556 

557 Fail-closed: when the validator cannot be imported the hit is refused 

558 (callers fall through to ``ContentFetcher``, which enforces its own 

559 gate). When *_cache* is a ``ResearchContentCache`` successful verdicts 

560 are memoized per-run so repeat hits skip DNS and stay servable when 

561 DNS drops mid-run after a success; refusals (including import-failure 

562 and transient DNS failures) are never pinned, so recovery stays open. 

563 """ 

564 

565 def _compute() -> bool: 

566 try: 

567 from local_deep_research.security.egress.fetch import ( 

568 policy_aware_validate_url, 

569 ) 

570 except Exception: # pragma: no cover - defensive 

571 logger.debug( 

572 "fetch cache grant check: validator unavailable, refusing hit" 

573 ) 

574 return False 

575 try: 

576 return bool(policy_aware_validate_url(url, egress_context)) 

577 except Exception: # pragma: no cover - defensive 

578 return False 

579 

580 # Per-run memo keyed by the exact SSRF booleans the compute uses: 

581 # PRIVATE_ONLY maps to (allow_private=True, block_link_local=True), 

582 # mirroring the fetcher wrapper; every other scope/None maps to the 

583 # strict defaults (False, False). Only True is pinned — a False only 

584 # skips the cache fast path while the fetcher re-validates, so a 

585 # transient DNS blip or import failure never pins the URL. 

586 try: 

587 _memo = getattr(_cache, "validated", None) 

588 if callable(_memo): 588 ↛ 610line 588 didn't jump to line 610 because the condition on line 588 was always true

589 try: 

590 from local_deep_research.security.egress.policy import ( 

591 EgressScope, 

592 ) 

593 except Exception: # pragma: no cover - defensive 

594 EgressScope = None # type: ignore[assignment] 

595 allow_private = bool( 

596 EgressScope is not None 

597 and egress_context is not None 

598 and getattr(egress_context, "scope", None) 

599 == EgressScope.PRIVATE_ONLY 

600 ) 

601 block_link_local = bool(allow_private) 

602 try: 

603 return bool( 

604 _memo(url, allow_private, block_link_local, _compute) 

605 ) 

606 except Exception: # pragma: no cover - defensive 

607 return False 

608 except Exception: # pragma: no cover - defensive 

609 logger.debug("fetch cache grant check: memo lookup failed") 

610 return _compute() 

611 

612 

613# Shared instruction appended to every per-URL egress denial returned to the 

614# agent. Tells it WHY the fetch was refused and what to do instead, so it 

615# adapts (stays in-scope) rather than retrying the same out-of-scope URL. 

616_EGRESS_DENIAL_HINT = ( 

617 "In this run only local collection/library documents can be fetched; " 

618 "skip external URLs." 

619) 

620 

621# --------------------------------------------------------------------------- 

622# Pre-resolution: rewrite non-network URLs BEFORE the egress gate. 

623# 

624# Two URL-shaped strings reach the fetch tool that aren't actually network 

625# fetches and that the egress policy correctly rejects as 

626# ``unsupported_scheme``. Resolving them here lets the agent get the page 

627# content (or a helpful error) instead of a generic denial — A3 from 

628# research_f3045c5b_issue_analysis.md. 

629# --------------------------------------------------------------------------- 

630 

631# ``_KIND_RESULT`` marks a pre-resolved local document; the fetch tool 

632# consumes the dict directly. ``_KIND_ERROR`` short-circuits the fetch 

633# tool with the message in ``payload`` (already routed through the 

634# credential scrubber by the caller). ``_KIND_REWRITTEN`` means "this 

635# URL was rewritten to a new one — use the new URL going forward but 

636# keep running the normal HTTP flow" (e.g. a ``[N]`` citation marker 

637# that resolves to an external URL). ``None`` means "no pre-resolution; 

638# run the normal HTTP path with the original URL." 

639_KIND_RESULT = "result" 

640_KIND_ERROR = "error" 

641_KIND_REWRITTEN = "rewritten" 

642 

643 

644def _try_resolve_url( 

645 url: str, 

646 library_resolver: Any, 

647 collector: Any, 

648 _visited: set[str] | None = None, 

649 content_max_length: int | None = None, 

650) -> tuple[str, Any] | None: 

651 """Pre-resolve a fetch URL that isn't a network URL. 

652 

653 Returns one of: 

654 

655 - ``(``_KIND_RESULT``, dict)`` — local-content payload shaped like a 

656 ``ContentFetcher.fetch()`` success result, ready for the post-fetch 

657 pipeline. The caller MUST skip the egress gate (it's a local read). 

658 - ``(``_KIND_ERROR``, str)`` — short-circuit with this error message. 

659 - ``(``_KIND_REWRITTEN``, str)`` — *url* was rewritten (e.g. a 

660 citation marker ``[N]`` that resolved to an external URL); use 

661 the new URL but otherwise run the normal HTTP flow (the egress 

662 gate still applies). 

663 - ``None`` — no pre-resolution; run the normal HTTP path with *url*. 

664 

665 Handles: 

666 

667 - ``[N]`` citation markers → ``SearchResultsCollector.find_by_index``, 

668 then recurse with the resolved URL (so a citation whose source is a 

669 library doc still hits the local fast path, while a citation whose 

670 source is external surfaces a ``_KIND_REWRITTEN`` URL for the gate). 

671 - ``/library/document/<uuid>[/pdf]`` → ``Document.text_content`` read 

672 from the user DB via the library resolver. 

673 """ 

674 if not isinstance(url, str): 

675 return None 

676 

677 if _visited is None: 

678 _visited = set() 

679 if url in _visited: 

680 return ( 

681 _KIND_ERROR, 

682 f"Circular citation reference detected for '{url}'.", 

683 ) 

684 if len(_visited) >= 5: 

685 return ( 

686 _KIND_ERROR, 

687 f"Citation resolution depth limit exceeded for '{url}'.", 

688 ) 

689 _visited.add(url) 

690 

691 # 1. Citation marker [N] → resolve to citation's URL. 

692 citation = _resolve_citation_reference(url, collector) 

693 if citation is not None: 

694 resolved_url = citation.get("link") or citation.get("url") or "" 

695 if not resolved_url: 

696 return ( 

697 _KIND_ERROR, 

698 ( 

699 f"Citation {url} has no URL field. " 

700 "Use the citation's URL or a different source instead." 

701 ), 

702 ) 

703 # First, check if the citation's URL is itself a library doc 

704 # (the recursive call sees the library path and returns _KIND_RESULT 

705 # directly). Otherwise surface the citation's URL as a rewrite so 

706 # the fetch tool updates its ``url`` variable before the egress 

707 # gate / HTTP fetch runs against the resolved URL — not the marker. 

708 recursive = _try_resolve_url( 

709 resolved_url, 

710 library_resolver, 

711 collector, 

712 _visited=_visited, 

713 content_max_length=content_max_length, 

714 ) 

715 if recursive is not None: 

716 return recursive 

717 return (_KIND_REWRITTEN, resolved_url) 

718 

719 # 2. Citation marker that doesn't match a tracked citation. 

720 # ``_resolve_citation_reference`` only returns the citation for a 

721 # well-formed ``[N]`` marker; anything else falls through to the 

722 # library-resolver check below. 

723 if is_citation_reference(url) is not None: 

724 return ( 

725 _KIND_ERROR, 

726 ( 

727 f"No registered citation matches {url}. The agent's " 

728 "search results use citation markers; use the source URL " 

729 "(the link next to the marker) or a tracked citation " 

730 "instead of a raw marker." 

731 ), 

732 ) 

733 

734 # 3. Library document URL. 

735 if library_resolver is not None: 

736 content = library_resolver(url) 

737 if content is not None: 

738 text = content.get("content") or "" 

739 cap = ( 

740 content_max_length 

741 if content_max_length is not None 

742 else CONTENT_MAX_LENGTH 

743 ) 

744 if len(text) > cap: 

745 text = text[:cap] 

746 return ( 

747 _KIND_RESULT, 

748 { 

749 "status": "success", 

750 "title": content.get("title") or "", 

751 "content": text, 

752 "url": content.get("url") or url, 

753 }, 

754 ) 

755 

756 return None 

757 

758 

759def _complete_archive_metadata(url: str, metadata: dict | None) -> dict | None: 

760 """Recover missing provenance from a verified capture before using it.""" 

761 if metadata is None or metadata.get("source_type") != "archive": 

762 return metadata 

763 from local_deep_research.web_search_engines.wayback_client import ( 

764 capture_metadata_from_replay_url, 

765 validate_capture_fetch_url, 

766 ) 

767 

768 captured = capture_metadata_from_replay_url(url) 

769 if captured is None: 

770 raise ValueError("Archive provenance requires an exact replay URL") 

771 # Missing metadata is recoverable, but supplied constraints must not 

772 # be replaced silently when they disagree with the actual capture. 

773 timestamp = metadata.get("timestamp") 

774 original_url = metadata.get("original_url") 

775 if timestamp is None or timestamp == "": 

776 timestamp = captured["timestamp"] 

777 if original_url is None or original_url == "": 

778 original_url = captured["original_url"] 

779 if not isinstance(timestamp, str) or not isinstance(original_url, str): 779 ↛ 780line 779 didn't jump to line 780 because the condition on line 779 was never true

780 raise ValueError("Invalid archive provenance") 

781 validate_capture_fetch_url(url, timestamp, original_url) 

782 return { 

783 **metadata, 

784 **captured, 

785 "requested_url": metadata.get("requested_url") 

786 or captured["requested_url"], 

787 } 

788 

789 

790def _archive_fetch_context( 

791 url: str, 

792 collector: Any, 

793 source_metadata: dict | None, 

794 url_guard: Any, 

795) -> tuple[dict | None, Any]: 

796 """Retain a tracked capture's provenance when fetching it again. 

797 

798 Resolve both citation chains and literal replay links using the 

799 collector's persistent index, including after a subsection reset. 

800 The resulting guard stays local to this call. 

801 """ 

802 metadata = source_metadata 

803 if is_citation_reference(url) is not None: 

804 # Use the HTTP path's cycle/depth checks to reach the final target. 

805 # Leave local document reads to that path's actual library resolver. 

806 resolved = _try_resolve_url(url, None, collector) 

807 if resolved is None or resolved[0] != _KIND_REWRITTEN: 

808 return metadata, url_guard 

809 url = resolved[1] 

810 tracked = None 

811 if metadata is None and collector is not None: 

812 find_url = getattr(collector, "find_by_url", None) 

813 find_index = getattr(collector, "find_by_index", None) 

814 if callable(find_url) and callable(find_index): 

815 index = find_url(url) 

816 if isinstance(index, int): 

817 tracked = find_index(index) 

818 if ( 

819 not isinstance(tracked, dict) 

820 or tracked.get("source_type") != "archive" 

821 ): 

822 replay = ( 

823 tracked.get("link") or tracked.get("url") 

824 if isinstance(tracked, dict) 

825 else url 

826 ) 

827 find_archive = getattr(collector, "find_archive_by_url", None) 

828 if isinstance(replay, str) and callable(find_archive): 

829 archived = find_archive(replay) 

830 if isinstance(archived, dict): 830 ↛ 831line 830 didn't jump to line 831 because the condition on line 830 was never true

831 tracked = archived 

832 if ( 

833 isinstance(tracked, dict) 

834 and tracked.get("source_type") == "archive" 

835 ): 

836 metadata = { 

837 field: tracked[field] 

838 for field in ( 

839 "source_type", 

840 "original_url", 

841 "requested_url", 

842 "timestamp", 

843 "formatted_date", 

844 ) 

845 if field in tracked 

846 } 

847 from local_deep_research.web_search_engines.wayback_client import ( 

848 capture_metadata_from_replay_url, 

849 validate_capture_fetch_url, 

850 ) 

851 

852 candidate = ( 

853 tracked.get("link") or tracked.get("url") 

854 if collector is not None and isinstance(tracked, dict) 

855 else url 

856 ) 

857 if metadata is None: 

858 if isinstance(candidate, str): 858 ↛ 860line 858 didn't jump to line 860 because the condition on line 858 was always true

859 metadata = capture_metadata_from_replay_url(candidate) 

860 if metadata is None or metadata.get("source_type") != "archive": 

861 return metadata, url_guard 

862 metadata = _complete_archive_metadata(candidate, metadata) 

863 

864 def capture_guard(candidate): 

865 validate_capture_fetch_url( 

866 candidate, metadata["timestamp"], metadata["original_url"] 

867 ) 

868 if url_guard is not None: 

869 url_guard(candidate) 

870 

871 return metadata, capture_guard 

872 

873 

874def _archive_failure_message(exc, availability, source_metadata): 

875 """Classify archive outages without disabling a live fetch provider.""" 

876 if availability is None or not source_metadata: 

877 return None 

878 if source_metadata.get("source_type") != "archive": 878 ↛ 879line 878 didn't jump to line 879 because the condition on line 878 was never true

879 return None 

880 from local_deep_research.web_search_engines.engine_availability import ( 

881 failure_from_exception, 

882 ) 

883 

884 failure = failure_from_exception(exc) 

885 if failure is not None: 

886 return availability.disable("wayback", failure).describe() 

887 return None 

888 

889 

890def _fetch_raw_content( 

891 url: str, 

892 library_resolver: Any, 

893 collector: Any, 

894 egress_context: Any, 

895 settings_snapshot: dict | None, 

896 mode_label: str, 

897 max_fetches: int | None = None, 

898 subagent_id: str | None = None, 

899 content_cache: Any = None, 

900 content_max_length: int | None = None, 

901 url_guard: Any = None, 

902 availability: Any = None, 

903 source_metadata: dict | None = None, 

904) -> tuple[dict | None, str, str | None, int | None]: 

905 """Pre-resolve non-network URL shapes or fetch via HTTP. 

906 

907 ``content_cache`` is the single shared cache for the whole research 

908 run (quick phase, subsections, lead + subagents). When None, the 

909 cache bound to the current research (if any) is used via 

910 ``ContentFetcher``'s own registry fallback. Repeat URLs are therefore 

911 served from previously fetched content instead of hitting the network 

912 on the spot. A guarded archive read (``url_guard`` set) never reads or 

913 stores cached page content. 

914 

915 Returns: 

916 (result, final_url, error_string, current_count) 

917 If error_string is not None, a pre-resolution error or budget exhaustion 

918 occurred (already scrubbed) and should be returned immediately by the fetch tool. 

919 current_count is the current fetch count if a network fetch was recorded, or None. 

920 """ 

921 from local_deep_research.content_fetcher import ContentFetcher 

922 from local_deep_research.utilities.research_content_cache import ( 

923 resolve_cache, 

924 ) 

925 

926 try: 

927 _cache = resolve_cache(content_cache) 

928 except Exception: # pragma: no cover - defensive 

929 _cache = None 

930 if url_guard is not None: 

931 # A guarded (archive) read neither reads nor stores cached page 

932 # content, here or in ContentFetcher: an entry under the same replay 

933 # URL can come from an unguarded fetch whose redirects left the 

934 # capture. 

935 _cache = None 

936 

937 effective_max_length = ( 

938 content_max_length 

939 if content_max_length is not None 

940 else _read_content_max_length_setting(settings_snapshot) 

941 ) 

942 pre = _try_resolve_url( 

943 url, 

944 library_resolver, 

945 collector, 

946 content_max_length=effective_max_length, 

947 ) 

948 result: dict | None = None 

949 if pre is not None: 

950 kind, payload = pre 

951 if kind == _KIND_ERROR: 

952 return None, url, sanitize_error_for_agent(payload), None 

953 if kind == _KIND_RESULT: 

954 # A local library document was resolved directly from the user DB. 

955 # Skip both the egress gate (local read) and ContentFetcher (no HTTP). 

956 # It does not consume a network fetch budget slot. 

957 result = payload 

958 url = result.get("url") or url 

959 logger.info( 

960 f"[FETCH] mode={mode_label} source=library " 

961 f"url={redact_url_for_log(url)} — " 

962 "resolved local library document directly" 

963 ) 

964 else: # _KIND_REWRITTEN — citation marker resolved to a URL 

965 url = payload 

966 

967 current_count: int | None = None 

968 if result is None: 

969 # Either no pre-resolution or a citation-marker rewrite — 

970 # run the normal HTTP path. Per-URL egress gate (pre-fetch) 

971 # + ContentFetcher's own per-redirect gate both raise 

972 # PolicyDeniedError on an out-of-scope URL. 

973 # Check the egress gate BEFORE recording the fetch so that 

974 # egress denials do not consume budget slots. 

975 if source_metadata and source_metadata.get("source_type") == "archive": 

976 if availability is not None: 

977 state = availability.state("wayback") 

978 if state is not None: 

979 return None, url, state.describe(), None 

980 if max_fetches is not None: 

981 # An ordinary reread reserves its slot here. The archive 

982 # wrapper has already checked these gates before lookup. 

983 from local_deep_research.security.egress.policy import ( 

984 PolicyDeniedError, 

985 evaluate_engine, 

986 ) 

987 from local_deep_research.web_search_engines.wayback_client import ( 

988 AVAILABLE_API, 

989 ) 

990 

991 if egress_context is not None: 

992 decision = evaluate_engine( 

993 "wayback", 

994 egress_context, 

995 settings_snapshot=settings_snapshot or {}, 

996 ) 

997 if not decision.allowed: 

998 raise PolicyDeniedError(decision, target=AVAILABLE_API) 

999 _enforce_url_policy( 

1000 source_metadata["original_url"], egress_context 

1001 ) 

1002 url_guard(url) 

1003 _enforce_url_policy(url, egress_context) 

1004 

1005 # Egress check passed! Only count fetches that reach the network. 

1006 if max_fetches is not None: 

1007 budget_err, current_count = _check_and_record_fetch( 

1008 collector, max_fetches, subagent_id=subagent_id 

1009 ) 

1010 if budget_err is not None: 

1011 return None, url, budget_err, current_count 

1012 

1013 # Inspect the cache only for logging. ContentFetcher serves both 

1014 # hits and misses below, applying the same policy and max_length 

1015 # to both. A separate early return here would bypass changes to 

1016 # the caller's content limit (including summary prompt limits). 

1017 _hit = None 

1018 if _cache is not None and _is_fetch_cache_hit_allowed( 

1019 url, egress_context, _cache 

1020 ): 

1021 try: 

1022 _hit = _cache.get(url) 

1023 except Exception: # pragma: no cover - defensive 

1024 _hit = None 

1025 

1026 enable_js = _read_js_rendering_setting(settings_snapshot) 

1027 fetcher_options = { 

1028 "timeout": CONTENT_FETCH_TIMEOUT, 

1029 "enable_js_rendering": enable_js, 

1030 "egress_context": egress_context, 

1031 "content_cache": _cache, 

1032 } 

1033 if url_guard is not None: 

1034 fetcher_options["url_guard"] = url_guard 

1035 with ContentFetcher(**fetcher_options) as fetcher: 

1036 result = fetcher.fetch(url, max_length=effective_max_length) 

1037 if ( 

1038 source_metadata 

1039 and source_metadata.get("source_type") == "archive" 

1040 and availability is not None 

1041 and result.get("status") == "success" 

1042 and (result.get("content") or "").strip() 

1043 ): 

1044 availability.record_success("wayback") 

1045 

1046 if ( 

1047 _hit is not None 

1048 and isinstance(_hit.get("content"), str) 

1049 and result.get("status") == "success" 

1050 ): 

1051 logger.info( 

1052 f"[FETCH] mode={mode_label} source=cache " 

1053 f"url={redact_url_for_log(url)} — " 

1054 "served from shared research cache" 

1055 ) 

1056 

1057 # Store successes at this layer only when ContentFetcher did not 

1058 # already cache the full text (e.g. mocked fetchers in tests). 

1059 # Never write truncated display output back: ContentFetcher caches 

1060 # the full pre-truncation text, and a 10_001-char page sliced to 

1061 # 10_000 + marker is LONGER, so it would win under longest-wins 

1062 # and lose the tail for later callers with larger limits. 

1063 # Only successes: errors must retry. Grant-consistent: entries 

1064 # this caller's grant would not allow to read are not written 

1065 # either (mirrors the engine store gate). 

1066 if ( 

1067 _cache is not None 

1068 and isinstance(result, dict) 

1069 and result.get("status") == "success" 

1070 and isinstance(result.get("content"), str) 

1071 and result.get("content_is_complete") is not False 

1072 and _is_fetch_cache_hit_allowed(url, egress_context, _cache) 

1073 ): 

1074 _text = result.get("content") or "" 

1075 if "[... content truncated ...]" not in _text: 

1076 try: 

1077 if _cache.get(url) is None: 

1078 _cache.put( 

1079 url, 

1080 _text, 

1081 title=result.get("title"), 

1082 url_out=result.get("url") or url, 

1083 ) 

1084 except Exception: # allow: silent-exception 

1085 pass 

1086 

1087 return result, url, None, current_count 

1088 

1089 

1090def _make_full_fetch_tool( 

1091 collector: Any, 

1092 settings_snapshot: dict | None = None, 

1093 egress_context: Any = None, 

1094 library_resolver: Any = None, 

1095 max_fetches: int | None = None, 

1096 subagent_id: str | None = None, 

1097 content_cache: Any = None, 

1098 content_max_length: int | None = None, 

1099 record_fetch: bool = True, 

1100 source_metadata: dict | None = None, 

1101 url_guard: Any = None, 

1102 availability: Any = None, 

1103): 

1104 mode_label = "full" 

1105 limit = ( 

1106 max_fetches 

1107 if max_fetches is not None 

1108 else _read_max_fetches_setting(settings_snapshot) 

1109 ) 

1110 desc = ( 

1111 "Download and read the full text content from a URL. " 

1112 "Use when search snippets aren't detailed enough." 

1113 ) 

1114 if limit > 0: 

1115 desc += f" (Up to {limit} page fetches allowed per topic.)" 

1116 

1117 @tool("fetch_content", description=desc) 

1118 def fetch_content(url: str) -> str: 

1119 """Download and read the full text content from a URL. Use when search snippets aren't detailed enough.""" 

1120 from local_deep_research.security.egress.policy import ( 

1121 PolicyDeniedError, 

1122 ) 

1123 

1124 archive_metadata = source_metadata 

1125 try: 

1126 archive_metadata, archive_guard = _archive_fetch_context( 

1127 url, collector, source_metadata, url_guard 

1128 ) 

1129 result, url, err_msg, current_count = _fetch_raw_content( 

1130 url, 

1131 library_resolver, 

1132 collector, 

1133 egress_context, 

1134 settings_snapshot, 

1135 mode_label, 

1136 max_fetches=limit if record_fetch else None, 

1137 subagent_id=subagent_id, 

1138 content_cache=content_cache, 

1139 content_max_length=content_max_length, 

1140 url_guard=archive_guard, 

1141 source_metadata=archive_metadata, 

1142 availability=availability, 

1143 ) 

1144 if err_msg is not None: 

1145 return err_msg 

1146 

1147 if result.get("status") == "success": 

1148 title = result.get("title", "") 

1149 content = result.get("content", "") 

1150 if archive_metadata is not None and not (content or "").strip(): 1150 ↛ 1151line 1150 didn't jump to line 1151 because the condition on line 1150 was never true

1151 return f"NOT RELEVANT (no extractable content at {url})" 

1152 cite_idx = _register_in_collector( 

1153 collector, url, title, content, archive_metadata 

1154 ) 

1155 msg = _format_fetch_message( 

1156 cite_idx, title, url, content, archive_metadata 

1157 ) 

1158 if limit > 0 and current_count is not None: 

1159 msg += f"\n\n({current_count} of {limit} page fetches used)" 

1160 return msg 

1161 # result['error'] comes from ContentFetcher, which returns a 

1162 # raw str(exception) — scrub it (and the url) before this 

1163 # reaches the agent/LLM and user-visible output (#4633). 

1164 return sanitize_error_for_agent( 

1165 f"Failed to fetch {url}: {result.get('error', 'unknown error')}" 

1166 ) 

1167 except PolicyDeniedError as exc: 

1168 # An out-of-scope URL is a RECOVERABLE, per-call decision (the agent 

1169 # picked one bad URL among many). Return it as a tool message — like 

1170 # the transient-error path below — so the lead agent and pooled 

1171 # subagents handle it identically and the agent can adapt, instead 

1172 # of re-raising (which aborts a subagent and depends on each agent's 

1173 # tool-error layer). The URL was already NOT fetched; the policy 

1174 # already enforced — only the REPORTING changes, not security. 

1175 target_url = getattr(exc, "target", "") or url 

1176 return sanitize_error_for_agent( 

1177 f"Cannot fetch {target_url}: blocked by egress policy " 

1178 f"({_denial_reason(exc)}). {_EGRESS_DENIAL_HINT}" 

1179 ) 

1180 except Exception as exc: 

1181 archive_failure = _archive_failure_message( 

1182 exc, availability, archive_metadata 

1183 ) 

1184 if archive_failure is not None: 

1185 return archive_failure 

1186 # logger.opt(exception=False).error — explicit no-traceback form 

1187 # that satisfies both custom-checks (no bare logger.error in an 

1188 # except block) and the security contract (no logger.exception 

1189 # traceback, which can embed the raw URL). Message carries the mode 

1190 # + a REDACTED scheme://host only (no userinfo/path/query); the 

1191 # agent/user-facing return is scrubbed separately below. 

1192 target_url = getattr(exc, "target", "") or url 

1193 logger.opt(exception=False).error( 

1194 f"[FETCH] mode={mode_label} status=error " 

1195 f"url={redact_url_for_log(target_url)}" 

1196 ) 

1197 return sanitize_error_for_agent( 

1198 f"Error fetching {target_url}: {exc}" 

1199 ) 

1200 

1201 return fetch_content 

1202 

1203 

1204def _make_summary_fetch_tool( 

1205 collector: Any, 

1206 model: BaseChatModel, 

1207 overall_query: str | None, 

1208 settings_snapshot: dict | None = None, 

1209 egress_context: Any = None, 

1210 library_resolver: Any = None, 

1211 max_fetches: int | None = None, 

1212 subagent_id: str | None = None, 

1213 content_cache: Any = None, 

1214 content_max_length: int | None = None, 

1215 record_fetch: bool = True, 

1216 source_metadata: dict | None = None, 

1217 url_guard: Any = None, 

1218 availability: Any = None, 

1219): 

1220 """Build the summary-mode fetch tool. 

1221 

1222 overall_query=None → focus-only prompt (``summary_focus`` mode). 

1223 overall_query=str → focus + overall-query prompt (``summary_focus_query``). 

1224 """ 

1225 use_query = bool(overall_query) 

1226 template = SUMMARY_FOCUS_QUERY_PROMPT if use_query else SUMMARY_FOCUS_PROMPT 

1227 

1228 mode_label = "summary_focus_query" if use_query else "summary_focus" 

1229 limit = ( 

1230 max_fetches 

1231 if max_fetches is not None 

1232 else _read_max_fetches_setting(settings_snapshot) 

1233 ) 

1234 desc = ( 

1235 "Fetch a URL and return only the spans of text relevant to `focus`. " 

1236 "Pass the specific question or claim you want answered as `focus` — " 

1237 "the tool will quote relevant facts verbatim and discard unrelated content." 

1238 ) 

1239 if limit > 0: 

1240 desc += f" (Up to {limit} page fetches allowed per topic.)" 

1241 

1242 @tool("fetch_content", description=desc) 

1243 def fetch_content(url: str, focus: str) -> str: 

1244 """Fetch a URL and return only the spans of text relevant to ``focus``. 

1245 Pass the specific question or claim you want answered as ``focus`` — 

1246 the tool will quote relevant facts verbatim and discard unrelated content. 

1247 """ 

1248 from local_deep_research.security.egress.policy import ( 

1249 PolicyDeniedError, 

1250 ) 

1251 

1252 archive_metadata = source_metadata 

1253 try: 

1254 prompt_char_limit = None 

1255 invocation_kwargs = {} 

1256 if content_max_length is not None: 

1257 page_budget = content_max_length 

1258 else: 

1259 # Validated first, so an invalid budget fails before the 

1260 # window lookup and any fetch. 

1261 page_budget = _read_summary_input_max_length_setting( 

1262 settings_snapshot 

1263 ) 

1264 prompt_char_limit, invocation_kwargs = ( 

1265 _summary_invocation_budget(model, settings_snapshot) 

1266 ) 

1267 if prompt_char_limit is not None: 

1268 page_budget = min(page_budget, prompt_char_limit) 

1269 archive_metadata, archive_guard = _archive_fetch_context( 

1270 url, collector, source_metadata, url_guard 

1271 ) 

1272 result, url, err_msg, current_count = _fetch_raw_content( 

1273 url, 

1274 library_resolver, 

1275 collector, 

1276 egress_context, 

1277 settings_snapshot, 

1278 mode_label, 

1279 max_fetches=limit if record_fetch else None, 

1280 subagent_id=subagent_id, 

1281 content_cache=content_cache, 

1282 content_max_length=page_budget, 

1283 url_guard=archive_guard, 

1284 source_metadata=archive_metadata, 

1285 availability=availability, 

1286 ) 

1287 if err_msg is not None: 

1288 return err_msg 

1289 

1290 if result.get("status") != "success": 

1291 # result['error'] comes from ContentFetcher, which returns a 

1292 # raw str(exception) — scrub it (and the url) before this 

1293 # reaches the agent/LLM / user output (#4633). 

1294 return sanitize_error_for_agent( 

1295 f"Failed to fetch {url}: " 

1296 f"{result.get('error', 'unknown error')}" 

1297 ) 

1298 

1299 title = result.get("title") or "" 

1300 content = result.get("content") or "" 

1301 

1302 # Guard 1 — empty page content (paywalls, JS-only SPAs that 

1303 # static fetch can't render, deleted pages with HTTP 200). 

1304 # Skipping the LLM call here means we don't pay the round-trip 

1305 # to summarise nothing, AND we don't register an empty 

1306 # citation in the collector — `_register_in_collector` caches 

1307 # by URL, so an empty snippet would lock the URL in as 

1308 # "already fetched, nothing here" and the agent would never 

1309 # retry it under a different focus. 

1310 if not content.strip(): 

1311 logger.info( 

1312 f"[FETCH] mode={mode_label} status=empty_content " 

1313 f"url={redact_url_for_log(url)}" 

1314 ) 

1315 return f"NOT RELEVANT (no extractable content at {url})" 

1316 

1317 fmt_kwargs = { 

1318 "focus": focus, 

1319 "title": title, 

1320 "url": url, 

1321 "content": content, 

1322 } 

1323 if use_query: 

1324 fmt_kwargs["overall_query"] = overall_query 

1325 prompt = template.format(**fmt_kwargs) 

1326 if ( 

1327 prompt_char_limit is not None 

1328 and len(prompt) > prompt_char_limit 

1329 ): 

1330 # Cut the page tail so the whole prompt fits. At least one 

1331 # character is kept, so a window too small for the prompt's 

1332 # own text still overflows. 

1333 excess = len(prompt) - prompt_char_limit 

1334 content = content[: max(1, len(content) - excess)] 

1335 fmt_kwargs["content"] = content 

1336 prompt = template.format(**fmt_kwargs) 

1337 

1338 try: 

1339 summary_msg = invoke_llm_sync( 

1340 model, prompt, **invocation_kwargs 

1341 ) 

1342 summary = getattr( 

1343 summary_msg, "content", str(summary_msg) 

1344 ).strip() 

1345 except Exception as exc: 

1346 # Explicit no-traceback form (see full-fetch variant): a 

1347 # redacted scheme://host + mode only, no page content, focus, 

1348 # or credentials. Agent-facing return is scrubbed below. 

1349 logger.opt(exception=False).error( 

1350 f"[FETCH] mode={mode_label} status=summary_error " 

1351 f"url={redact_url_for_log(url)}" 

1352 ) 

1353 return sanitize_error_for_agent( 

1354 f"Error summarizing {url}: {exc}" 

1355 ) 

1356 

1357 # Guard 2 — empty LLM summary. The model decided nothing on 

1358 # the page answers the focus (or it returned a malformed/empty 

1359 # response). Treat as NOT RELEVANT and skip collector 

1360 # registration: the agent should be free to re-fetch the URL 

1361 # later with a different focus instead of seeing it as 

1362 # already-cached with an empty body. 

1363 if not summary: 

1364 logger.info( 

1365 f"[FETCH] mode={mode_label} status=empty_summary " 

1366 f"url={redact_url_for_log(url)} " 

1367 f"page_chars={len(content)}" 

1368 ) 

1369 return f"NOT RELEVANT (no spans matched focus at {url})" 

1370 

1371 # Bounded success event: mode, status, redacted origin, and 

1372 # page/summary character counts only. Raw focus, overall query, 

1373 # title, body, and summary text are deliberately NOT logged — 

1374 # they persist to DB/frontend sinks and, for private 

1375 # collection/library documents, are sensitive. 

1376 logger.info( 

1377 f"[FETCH] mode={mode_label} status=success " 

1378 f"url={redact_url_for_log(url)} " 

1379 f"page_chars={len(content)} summary_chars={len(summary)}" 

1380 ) 

1381 

1382 cite_idx = _register_in_collector( 

1383 collector, url, title, summary, archive_metadata 

1384 ) 

1385 msg = _format_fetch_message( 

1386 cite_idx, title, url, summary, archive_metadata 

1387 ) 

1388 if limit > 0 and current_count is not None: 

1389 msg += f"\n\n({current_count} of {limit} page fetches used)" 

1390 return msg 

1391 except PolicyDeniedError as exc: 

1392 # Recoverable per-URL denial — return a tool message so both the 

1393 # lead agent and pooled subagents handle it identically and the 

1394 # agent stays in-scope. The URL was already NOT fetched; only the 

1395 # reporting changes, not security. (See the full-fetch variant.) 

1396 target_url = getattr(exc, "target", "") or url 

1397 return sanitize_error_for_agent( 

1398 f"Cannot fetch {target_url}: blocked by egress policy " 

1399 f"({_denial_reason(exc)}). {_EGRESS_DENIAL_HINT}" 

1400 ) 

1401 except Exception as exc: 

1402 archive_failure = _archive_failure_message( 

1403 exc, availability, archive_metadata 

1404 ) 

1405 if archive_failure is not None: 

1406 return archive_failure 

1407 # logger.opt(exception=False).error — explicit no-traceback form 

1408 # that satisfies both custom-checks (no bare logger.error in an 

1409 # except block) and the security contract (no logger.exception 

1410 # traceback, which can embed the raw URL). Message carries the mode 

1411 # + a REDACTED scheme://host only (no userinfo/path/query); the 

1412 # agent/user-facing return is scrubbed separately below. 

1413 target_url = getattr(exc, "target", "") or url 

1414 logger.opt(exception=False).error( 

1415 f"[FETCH] mode={mode_label} status=error " 

1416 f"url={redact_url_for_log(target_url)}" 

1417 ) 

1418 return sanitize_error_for_agent( 

1419 f"Error fetching {target_url}: {exc}" 

1420 ) 

1421 

1422 return fetch_content 

1423 

1424 

1425def build_fetch_tool( 

1426 mode: str, 

1427 collector: Any, 

1428 *, 

1429 model: BaseChatModel | None = None, 

1430 overall_query: str = "", 

1431 settings_snapshot: dict | None = None, 

1432 egress_context: Any = None, 

1433 library_resolver: Any = None, 

1434 max_fetches: int | None = None, 

1435 subagent_id: str | None = None, 

1436 content_cache: Any = None, 

1437 content_max_length: int | None = None, 

1438 record_fetch: bool = True, 

1439 source_metadata: dict | None = None, 

1440 url_guard: Any = None, 

1441 availability: Any = None, 

1442): 

1443 """Build the agent-facing ``fetch_content`` tool for *mode*. 

1444 

1445 ``record_fetch=False`` is used by the archive wrapper after it reserves 

1446 the shared fetch slot before archive lookup. ``source_metadata`` carries 

1447 that capture's provenance into the citation collector. 

1448 

1449 Returns ``None`` when ``mode == 'disabled'``; the caller should not 

1450 register the tool with the agent in that case (and the system prompt 

1451 should also drop the corresponding instruction line so the agent 

1452 isn't told to use a tool that doesn't exist). 

1453 

1454 ``settings_snapshot`` is captured by the tool closure so the per-call 

1455 JS-rendering toggle can be read on a worker thread (where 

1456 ``threading.local`` context does not propagate). 

1457 

1458 ``content_max_length`` explicitly overrides the page-text budget in 

1459 any mode, without a context-window limit. Otherwise, ``full`` reads 

1460 ``search.fetch.content_max_length`` and summary modes read 

1461 ``search.fetch.summary_input_max_length``. Unless *model* was built 

1462 for a cloud provider with unrestricted context, summary modes then cut 

1463 the page text so the whole extraction prompt fits the model's context 

1464 window minus its response allowance, estimated at four characters per 

1465 token (see ``_summary_invocation_budget``). That budget limits only the 

1466 separate extraction prompt, not the tool's summary returned to the 

1467 research agent. 

1468 

1469 ``egress_context`` is captured by the closure so the per-call URL 

1470 can be policy-gated; when ``None``, no policy enforcement runs 

1471 (preserves legacy non-LangGraph callers). 

1472 

1473 ``library_resolver`` is captured by the closure so a fetch call can 

1474 short-circuit on ``/library/document/<uuid>[/pdf]`` URLs (a local DB 

1475 read, not a network fetch) and on bare ``[N]`` citation markers 

1476 (rewritten to the citation's URL via ``SearchResultsCollector``). 

1477 When ``None``, the tool falls through to the egress gate unchanged 

1478 (which, when ``egress_context`` is configured, rejects library / citation 

1479 URLs as ``unsupported_scheme``) — same as the pre-fix behaviour. 

1480 

1481 ``content_cache`` is the single shared cache for the whole research 

1482 run. When None, the cache bound to the current research (if any) is 

1483 used, so the lead agent, subagents, quick phase and subsections all 

1484 share previously fetched pages instead of re-fetching on the spot. 

1485 """ 

1486 if mode == "disabled": 

1487 return None 

1488 if mode == "full": 

1489 return _make_full_fetch_tool( 

1490 collector, 

1491 settings_snapshot=settings_snapshot, 

1492 egress_context=egress_context, 

1493 library_resolver=library_resolver, 

1494 max_fetches=max_fetches, 

1495 subagent_id=subagent_id, 

1496 content_cache=content_cache, 

1497 content_max_length=content_max_length, 

1498 record_fetch=record_fetch, 

1499 source_metadata=source_metadata, 

1500 url_guard=url_guard, 

1501 availability=availability, 

1502 ) 

1503 if mode == "summary_focus": 

1504 if model is None: 

1505 raise ValueError("summary_focus fetch mode requires a model") 

1506 return _make_summary_fetch_tool( 

1507 collector, 

1508 model, 

1509 overall_query=None, 

1510 settings_snapshot=settings_snapshot, 

1511 egress_context=egress_context, 

1512 library_resolver=library_resolver, 

1513 max_fetches=max_fetches, 

1514 subagent_id=subagent_id, 

1515 content_cache=content_cache, 

1516 content_max_length=content_max_length, 

1517 record_fetch=record_fetch, 

1518 source_metadata=source_metadata, 

1519 url_guard=url_guard, 

1520 availability=availability, 

1521 ) 

1522 if mode == "summary_focus_query": 

1523 if model is None: 

1524 raise ValueError("summary_focus_query fetch mode requires a model") 

1525 # Empty overall_query falls back to focus-only behaviour at format 

1526 # time; we keep the *_query mode label so logs stay diagnostic. 

1527 return _make_summary_fetch_tool( 

1528 collector, 

1529 model, 

1530 overall_query=overall_query or None, 

1531 settings_snapshot=settings_snapshot, 

1532 egress_context=egress_context, 

1533 library_resolver=library_resolver, 

1534 max_fetches=max_fetches, 

1535 subagent_id=subagent_id, 

1536 content_cache=content_cache, 

1537 content_max_length=content_max_length, 

1538 record_fetch=record_fetch, 

1539 source_metadata=source_metadata, 

1540 url_guard=url_guard, 

1541 availability=availability, 

1542 ) 

1543 raise ValueError( 

1544 f"Unknown fetch mode {mode!r}; expected one of {FETCH_MODES}" 

1545 ) 

1546 

1547 

1548__all__ = [ 

1549 "DEFAULT_MAX_FETCHES_PER_TOPIC", 

1550 "FETCH_MODES", 

1551 "build_fetch_tool", 

1552 "make_library_resolver", 

1553]