From 6abc1d77141bd3dfe83a4eb0862ea7f97f98b1e1 Mon Sep 17 00:00:00 2001 From: d-oit <6849456+d-oit@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:02:02 +0200 Subject: [PATCH 01/15] fix(owlwatch): remediate issues 620-622 and reconcile plans (Plan 111) --- .../scripts/providers_impl.py | 11 + .../do-web-doc-resolver/scripts/resolve.py | 328 +++++++++++------- .../tests/test_providers.py | 84 ++++- .../do-web-doc-resolver/tests/test_resolve.py | 117 +++++++ ...11-owlwatch-issues-pr624-625-2026-08-09.md | 60 ++++ worklog.md | 13 + 6 files changed, 476 insertions(+), 137 deletions(-) create mode 100644 plans/111-owlwatch-issues-pr624-625-2026-08-09.md diff --git a/.agents/skills/do-web-doc-resolver/scripts/providers_impl.py b/.agents/skills/do-web-doc-resolver/scripts/providers_impl.py index c4668af5..208979f6 100644 --- a/.agents/skills/do-web-doc-resolver/scripts/providers_impl.py +++ b/.agents/skills/do-web-doc-resolver/scripts/providers_impl.py @@ -13,6 +13,7 @@ _get_from_cache, _save_to_cache, get_session, + is_safe_url, ) logger = logging.getLogger(__name__) @@ -329,6 +330,10 @@ def resolve_with_mistral_websearch(query: str, max_chars: int = MAX_CHARS) -> Pr def resolve_with_docling(url: str, max_chars: int) -> ProviderResult: start = time.time() + if not is_safe_url(url): + duration = int((time.time() - start) * 1000) + meta = ProviderMeta(tool="docling", duration_ms=duration, error_type="ssrf_blocked") + return ProviderResult(ok=False, error="unsafe_url", meta=meta, url=url, source="docling") try: res = subprocess.run( ["docling", "--format", "markdown", url], capture_output=True, text=True, timeout=60 @@ -348,6 +353,12 @@ def resolve_with_docling(url: str, max_chars: int) -> ProviderResult: def resolve_with_ocr(url: str, max_chars: int) -> ProviderResult: start = time.time() + if not is_safe_url(url): + duration = int((time.time() - start) * 1000) + meta = ProviderMeta(tool="ocr", duration_ms=duration, error_type="ssrf_blocked") + return ProviderResult( + ok=False, error="unsafe_url", meta=meta, url=url, source="ocr-tesseract" + ) try: res = subprocess.run( ["tesseract", url, "stdout"], capture_output=True, text=True, timeout=30 diff --git a/.agents/skills/do-web-doc-resolver/scripts/resolve.py b/.agents/skills/do-web-doc-resolver/scripts/resolve.py index 54010d8d..8dee9df3 100644 --- a/.agents/skills/do-web-doc-resolver/scripts/resolve.py +++ b/.agents/skills/do-web-doc-resolver/scripts/resolve.py @@ -11,7 +11,7 @@ import os import time import uuid -from collections.abc import Generator +from collections.abc import Callable, Generator from typing import Any from . import ( @@ -160,6 +160,179 @@ def resolve_url( return {"source": "none", "url": url, "content": "Failed"} +_SPECIAL_DOCUMENT_PROVIDERS: tuple[tuple[tuple[str, ...], str], ...] = ( + ((".pdf", ".docx", ".pptx"), "docling"), + ((".png", ".jpg", ".jpeg"), "ocr"), +) + + +def _special_document_provider(name: str) -> Callable[[str, int], ProviderResult]: + """Resolve a special-document provider function by tool name. + + The lookup happens at call time so module-attribute patching in tests + (e.g. ``@patch("scripts.resolve.resolve_with_docling")``) keeps working. + """ + if name == "docling": + return resolve_with_docling + return resolve_with_ocr + + +def _resolve_special_document( + url: str, + max_chars: int, + start_time: float, + metrics: ResolveMetrics, + trace: ResolutionTrace | None, +) -> dict[str, Any] | None: + """Resolve document/image URLs via docling or OCR, returning a result dict or None. + + Falls through to the regular provider cascade when no special provider + matches the URL extension or the provider reports a failure. + """ + lower_url = url.lower() + for extensions, tool in _SPECIAL_DOCUMENT_PROVIDERS: + if not any(lower_url.endswith(ext) for ext in extensions): + continue + res = _special_document_provider(tool)(url, max_chars) + if not res.ok: + return None + result = ResolvedResult(source=res.source, content=res.content or "", url=res.url) + result.meta = res.meta + result.metrics = metrics + result_dict = result.to_dict() + if trace: + step = TraceStep( + tool=tool, + duration_ms=int((time.time() - start_time) * 1000), + success=True, + quality_score=res.meta.quality_score if res.meta else 0.0, + content_length=len(res.content or ""), + ) + trace.steps.append(step) + trace.total_latency_ms = int((time.time() - start_time) * 1000) + trace.final_source = tool + trace.final_score = res.meta.quality_score if res.meta else 0.0 + trace.success = True + result_dict["trace"] = trace.to_dict() + return result_dict + return None + + +def _build_probe_output( + res_or_content: Any, + p_name_done: str, + pt_done: ProviderType, + latency: int, + url: str, + max_chars: int, + metrics: ResolveMetrics, + trace: ResolutionTrace | None, + start_time: float, + domain: str, +) -> tuple[dict[str, Any] | None, bool]: + """Build the output dict for a completed provider result, or None if unusable. + + Returns ``(output_dict, accepted)``; ``accepted`` tells the caller to stop + processing further futures in the current completion batch. + """ + if not res_or_content: + _circuit_breakers.record_failure(p_name_done) + metrics.record_provider(pt_done, latency, False) + return None, False + if isinstance(res_or_content, ProviderResult): + if not res_or_content.ok: + _circuit_breakers.record_failure(p_name_done) + metrics.record_provider(pt_done, latency, False) + if trace: + step = TraceStep( + tool=p_name_done, + duration_ms=latency, + success=False, + error=res_or_content.error, + ) + trace.steps.append(step) + return None, False + content = res_or_content.content or "" + elif isinstance(res_or_content, ResolvedResult): + content = res_or_content.content + else: + content = str(res_or_content) + + q_score = quality.score_content(content) + if not (q_score.acceptable or pt_done == ProviderType.LLMS_TXT): + cache_negative.write_negative_cache(_get_cache(), url, p_name_done, "thin_content", 1800) + if domain: + _routing_memory.record(domain, p_name_done, False, latency, q_score.score) + return None, False + + _circuit_breakers.record_success(p_name_done) + metrics.record_provider(pt_done, latency, True) + if domain: + _routing_memory.record(domain, p_name_done, True, latency, q_score.score) + if trace: + trace.total_latency_ms = int((time.time() - start_time) * 1000) + trace.final_source = p_name_done + trace.final_score = q_score.score + trace.success = True + if pt_done == ProviderType.LLMS_TXT: + out: dict[str, Any] = { + "source": "llms.txt", + "url": url, + "content": compact_content(content, max_chars), + "metrics": metrics, + } + if trace: + out["trace"] = trace.to_dict() + return out, True + if isinstance(res_or_content, ResolvedResult): + res_or_content.metrics, res_or_content.score = metrics, q_score.score + out = res_or_content.to_dict() + if trace: + out["trace"] = trace.to_dict() + return out, True + return None, True + + +def _process_probe_result( + future: concurrent.futures.Future[Any], + active_futures: dict[concurrent.futures.Future[Any], tuple[str, ProviderType, float]], + budget: routing.ResolutionBudget, + url: str, + max_chars: int, + metrics: ResolveMetrics, + trace: ResolutionTrace | None, + start_time: float, + domain: str, +) -> tuple[dict[str, Any] | None, bool]: + """Process a single completed provider probe, recording budget and metrics.""" + p_name_done, pt_done, s_time = active_futures.pop(future) + latency = int((time.time() - s_time) * 1000) + budget.record_attempt(is_paid=pt_done.is_paid(), latency_ms=latency) + try: + res_or_content = future.result() + except Exception as e: + err_type = _detect_error_type(e) + if err_type not in (ErrorType.AUTH_ERROR, ErrorType.SSRF_BLOCKED): + _circuit_breakers.record_failure(p_name_done) + if trace: + step = TraceStep(tool=p_name_done, duration_ms=latency, success=False, error=str(e)) + trace.steps.append(step) + metrics.record_provider(pt_done, latency, False) + return None, False + return _build_probe_output( + res_or_content, + p_name_done, + pt_done, + latency, + url, + max_chars, + metrics, + trace, + start_time, + domain, + ) + + def resolve_url_stream( url: str, max_chars: int = MAX_CHARS, profile: Profile = Profile.BALANCED, trace: ResolutionTrace | None = None, @@ -175,52 +348,10 @@ def resolve_url_stream( ) start_time = time.time() - if any(url.lower().endswith(ext) for ext in [".pdf", ".docx", ".pptx"]): - res = resolve_with_docling(url, max_chars) - if res.ok: - result = ResolvedResult(source=res.source, content=res.content or "", url=res.url) - result.meta = res.meta - result.metrics = metrics - result_dict = result.to_dict() - if trace: - step = TraceStep( - tool="docling", - duration_ms=int((time.time() - start_time) * 1000), - success=True, - quality_score=res.meta.quality_score if res.meta else 0.0, - content_length=len(res.content or ""), - ) - trace.steps.append(step) - trace.total_latency_ms = int((time.time() - start_time) * 1000) - trace.final_source = "docling" - trace.final_score = res.meta.quality_score if res.meta else 0.0 - trace.success = True - result_dict["trace"] = trace.to_dict() - yield result_dict - return - if any(url.lower().endswith(ext) for ext in [".png", ".jpg", ".jpeg"]): - res = resolve_with_ocr(url, max_chars) - if res.ok: - result = ResolvedResult(source=res.source, content=res.content or "", url=res.url) - result.meta = res.meta - result.metrics = metrics - result_dict = result.to_dict() - if trace: - step = TraceStep( - tool="ocr", - duration_ms=int((time.time() - start_time) * 1000), - success=True, - quality_score=res.meta.quality_score if res.meta else 0.0, - content_length=len(res.content or ""), - ) - trace.steps.append(step) - trace.total_latency_ms = int((time.time() - start_time) * 1000) - trace.final_source = "ocr" - trace.final_score = res.meta.quality_score if res.meta else 0.0 - trace.success = True - result_dict["trace"] = trace.to_dict() - yield result_dict - return + special = _resolve_special_document(url, max_chars, start_time, metrics, trace) + if special is not None: + yield special + return provider_names = routing.plan_provider_order( target=url, is_url=True, routing_memory=_routing_memory @@ -279,98 +410,25 @@ def resolve_url_stream( return_when=concurrent.futures.FIRST_COMPLETED, ) - found_acceptable = False for f in list(done): if f not in active_futures: continue - p_name_done, pt_done, s_time = active_futures.pop(f) - latency = int((time.time() - s_time) * 1000) - budget.record_attempt(is_paid=pt_done.is_paid(), latency_ms=latency) - try: - res_or_content = f.result() - except Exception as e: - err_type = _detect_error_type(e) - if err_type not in (ErrorType.AUTH_ERROR, ErrorType.SSRF_BLOCKED): - _circuit_breakers.record_failure(p_name_done) - if trace: - step = TraceStep( - tool=p_name_done, - duration_ms=latency, - success=False, - error=str(e), - ) - trace.steps.append(step) - metrics.record_provider(pt_done, latency, False) - continue - if res_or_content: - if isinstance(res_or_content, ProviderResult): - if res_or_content.ok: - content = res_or_content.content or "" - else: - _circuit_breakers.record_failure(p_name_done) - metrics.record_provider(pt_done, latency, False) - if trace: - step = TraceStep( - tool=p_name_done, - duration_ms=latency, - success=False, - error=res_or_content.error, - ) - trace.steps.append(step) - continue - elif isinstance(res_or_content, ResolvedResult): - content = res_or_content.content - else: - content = str(res_or_content) - - q_score = quality.score_content(content) - if q_score.acceptable or pt_done == ProviderType.LLMS_TXT: - _circuit_breakers.record_success(p_name_done) - metrics.record_provider(pt_done, latency, True) - if domain: - _routing_memory.record( - domain, p_name_done, True, latency, q_score.score - ) - - if trace: - trace.total_latency_ms = int((time.time() - start_time) * 1000) - trace.final_source = p_name_done - trace.final_score = q_score.score - trace.success = True - if pt_done == ProviderType.LLMS_TXT: - out = { - "source": "llms.txt", - "url": url, - "content": compact_content(content, max_chars), - "metrics": metrics, - } - if trace: - out["trace"] = trace.to_dict() - yield out - elif isinstance(res_or_content, ResolvedResult): - res_or_content.metrics, res_or_content.score = ( - metrics, - q_score.score, - ) - out = res_or_content.to_dict() - if trace: - out["trace"] = trace.to_dict() - yield out - break - else: - cache_negative.write_negative_cache( - cache, url, p_name_done, "thin_content", 1800 - ) - if domain: - _routing_memory.record( - domain, p_name_done, False, latency, q_score.score - ) - else: - _circuit_breakers.record_failure(p_name_done) - metrics.record_provider(pt_done, latency, False) + out, accepted = _process_probe_result( + f, + active_futures, + budget, + url, + max_chars, + metrics, + trace, + start_time, + domain, + ) + if accepted: + if out is not None: + yield out + break - if found_acceptable: - return if done: break if not active_futures: diff --git a/.agents/skills/do-web-doc-resolver/tests/test_providers.py b/.agents/skills/do-web-doc-resolver/tests/test_providers.py index d6ac65b7..9339d60f 100644 --- a/.agents/skills/do-web-doc-resolver/tests/test_providers.py +++ b/.agents/skills/do-web-doc-resolver/tests/test_providers.py @@ -54,8 +54,11 @@ def test_rate_limit_clears_after_expiry(self): set_rate_limit("test_provider", cooldown=1) assert "test_provider" in _rate_limits - time.sleep(1.1) - is_rate_limited("test_provider") # This should clear expired entry + # Poll until expiry instead of a fixed sleep to avoid timing flakes. + deadline = time.time() + 3 + while "test_provider" in _rate_limits and time.time() < deadline: + is_rate_limited("test_provider") # Clears the expired entry + time.sleep(0.05) assert "test_provider" not in _rate_limits @@ -250,6 +253,83 @@ def test_short_content_not_truncated(self): assert result.content == short_content +class TestSubprocessProviderSafety: + """Tests for subprocess provider URL validation (issue #622).""" + + @patch("scripts.providers_impl.subprocess.run") + def test_docling_rejects_unsafe_scheme(self, mock_run): + """resolve_with_docling should reject non-http(s) URLs without invoking subprocess.""" + from scripts.providers_impl import resolve_with_docling + + result = resolve_with_docling("file:///etc/passwd", 1000) + assert result.ok is False + assert result.error == "unsafe_url" + assert result.meta.error_type == "ssrf_blocked" + mock_run.assert_not_called() + + @patch("scripts.providers_impl.subprocess.run") + def test_docling_rejects_private_ip(self, mock_run): + """resolve_with_docling should reject SSRF-prone private IP URLs.""" + from scripts.providers_impl import resolve_with_docling + + result = resolve_with_docling("http://127.0.0.1:8080/report.pdf", 1000) + assert result.ok is False + assert result.error == "unsafe_url" + mock_run.assert_not_called() + + @patch("scripts.providers_impl.subprocess.run") + def test_ocr_rejects_unsafe_scheme(self, mock_run): + """resolve_with_ocr should reject non-http(s) URLs without invoking subprocess.""" + from scripts.providers_impl import resolve_with_ocr + + result = resolve_with_ocr("data:image/png;base64,AAAA", 1000) + assert result.ok is False + assert result.error == "unsafe_url" + mock_run.assert_not_called() + + @patch("scripts.providers_impl.subprocess.run") + def test_docling_passes_safe_url(self, mock_run): + """resolve_with_docling should invoke subprocess for safe http(s) URLs.""" + from unittest.mock import MagicMock + + from scripts.providers_impl import resolve_with_docling + + completed = MagicMock() + completed.returncode = 0 + completed.stdout = "extracted markdown" + mock_run.return_value = completed + + result = resolve_with_docling("https://8.8.8.8/doc.pdf", 1000) + assert result.ok is True + mock_run.assert_called_once_with( + ["docling", "--format", "markdown", "https://8.8.8.8/doc.pdf"], + capture_output=True, + text=True, + timeout=60, + ) + + @patch("scripts.providers_impl.subprocess.run") + def test_ocr_passes_safe_url(self, mock_run): + """resolve_with_ocr should invoke subprocess for safe http(s) URLs.""" + from unittest.mock import MagicMock + + from scripts.providers_impl import resolve_with_ocr + + completed = MagicMock() + completed.returncode = 0 + completed.stdout = "recognized text" + mock_run.return_value = completed + + result = resolve_with_ocr("https://8.8.8.8/photo.png", 1000) + assert result.ok is True + mock_run.assert_called_once_with( + ["tesseract", "https://8.8.8.8/photo.png", "stdout"], + capture_output=True, + text=True, + timeout=30, + ) + + class TestMinContentThreshold: """Tests for minimum content threshold.""" diff --git a/.agents/skills/do-web-doc-resolver/tests/test_resolve.py b/.agents/skills/do-web-doc-resolver/tests/test_resolve.py index c797a5a4..19195163 100644 --- a/.agents/skills/do-web-doc-resolver/tests/test_resolve.py +++ b/.agents/skills/do-web-doc-resolver/tests/test_resolve.py @@ -2,6 +2,8 @@ Tests for main resolve module. """ +from unittest.mock import patch + import pytest from scripts.resolve import MAX_CHARS, MIN_CHARS, is_url, resolve @@ -179,6 +181,121 @@ def test_resolve_none_input(self, max_chars): assert result.get("source") == "none" +class TestResolveUrlStreamCascade: + """Mock-based tests for the resolve_url_stream provider cascade.""" + + @pytest.fixture(autouse=True) + def _isolate_cache(self): + """Disable the persistent diskcache so tests stay hermetic.""" + with patch("scripts.resolve._get_cache", return_value=None): + yield + + @staticmethod + def _make_quality(acceptable: bool = True): + """Build a QualityScore for the given acceptance flag.""" + from scripts.quality import QualityScore + + return QualityScore( + score=0.9 if acceptable else 0.3, + too_short=not acceptable, + missing_links=False, + duplicate_heavy=False, + noisy=False, + acceptable=acceptable, + ) + + @patch("scripts.resolve.routing.plan_provider_order", return_value=["direct_fetch"]) + @patch("scripts.resolve.fetch_url_content") + @patch("scripts.resolve.quality.score_content") + def test_acceptable_resolved_result_yields_content(self, mock_score, mock_fetch, mock_plan): + """An acceptable ResolvedResult should be yielded as the first output.""" + from scripts.models import Profile, ResolvedResult + from scripts.resolve import resolve_url_stream + + mock_score.return_value = self._make_quality(acceptable=True) + mock_fetch.return_value = ResolvedResult( + source="direct_fetch", content="B" * 600, url="https://example.com" + ) + results = list(resolve_url_stream("https://example.com", profile=Profile.FAST)) + assert results + first = results[0] + assert first["source"] == "direct_fetch" + assert first["url"] == "https://example.com" + assert len(first["content"]) >= 600 + + @patch("scripts.resolve.routing.plan_provider_order", return_value=["llms_txt"]) + @patch("scripts.resolve.fetch_llms_txt") + def test_llms_txt_yields_compacted_output(self, mock_llms, mock_plan): + """An llms.txt hit should yield compacted content regardless of quality.""" + from scripts.models import Profile + from scripts.resolve import resolve_url_stream + + mock_llms.return_value = ("line1\n" + "line2\n") * 300 + results = list(resolve_url_stream("https://example.com", profile=Profile.FAST)) + assert results + first = results[0] + assert first["source"] == "llms.txt" + assert "line1" in first["content"] + + @patch("scripts.resolve.routing.plan_provider_order", return_value=["jina"]) + @patch("scripts.resolve.resolve_with_jina") + @patch("scripts.resolve.quality.score_content") + def test_thin_content_falls_through_to_failure(self, mock_score, mock_jina, mock_plan): + """Thin provider content should not be yielded; final result is 'none'.""" + from scripts.models import Profile, ProviderMeta, ProviderResult + from scripts.resolve import resolve_url_stream + + mock_score.return_value = self._make_quality(acceptable=False) + mock_jina.return_value = ProviderResult( + ok=True, + content="short", + meta=ProviderMeta(tool="jina", duration_ms=5), + url="https://example.com", + source="jina", + ) + results = list(resolve_url_stream("https://example.com", profile=Profile.FAST)) + assert results + # ProviderResult with acceptable=False is rejected; only the failure dict is emitted. + assert all(r["source"] != "jina" for r in results) + assert results[-1]["source"] == "none" + + @patch("scripts.resolve.resolve_with_docling") + def test_special_document_uses_docling(self, mock_docling): + """A PDF URL should be resolved via docling without a provider cascade.""" + from scripts.models import Profile, ProviderMeta, ProviderResult + from scripts.resolve import resolve_url_stream + + mock_docling.return_value = ProviderResult( + ok=True, + content="extracted pdf text", + meta=ProviderMeta(tool="docling", duration_ms=10), + url="https://8.8.8.8/report.pdf", + source="docling", + ) + results = list(resolve_url_stream("https://8.8.8.8/report.pdf", profile=Profile.FAST)) + assert results + assert results[0]["source"] == "docling" + assert results[0]["content"] == "extracted pdf text" + + @patch("scripts.resolve.resolve_with_docling") + @patch("scripts.resolve.routing.plan_provider_order", return_value=[]) + def test_special_document_failure_falls_through(self, mock_plan, mock_docling): + """A failed docling attempt should fall through to the regular failure result.""" + from scripts.models import Profile, ProviderMeta, ProviderResult + from scripts.resolve import resolve_url_stream + + mock_docling.return_value = ProviderResult( + ok=False, + error="exit_code_1", + meta=ProviderMeta(tool="docling", duration_ms=10, error_type="unknown"), + url="https://8.8.8.8/report.pdf", + source="docling", + ) + results = list(resolve_url_stream("https://8.8.8.8/report.pdf", profile=Profile.FAST)) + assert results + assert results[-1]["source"] == "none" + + class TestResolveQuality: """Tests for content quality in resolve function.""" diff --git a/plans/111-owlwatch-issues-pr624-625-2026-08-09.md b/plans/111-owlwatch-issues-pr624-625-2026-08-09.md new file mode 100644 index 00000000..bfea7cc6 --- /dev/null +++ b/plans/111-owlwatch-issues-pr624-625-2026-08-09.md @@ -0,0 +1,60 @@ +# Plan 111 — OwlWatch Issues + PR #624/#625 Remediation (2026-08-09) + +**Status**: COMPLETE (2026-08-09 — PR #624 fixes pushed, PR #625 unblocked, issues #621/#622 fixed, #620 documented, Plan 111 PR submitted) +**Method**: GOAP orchestrator with parallel agent swarm +**Goal**: Address all open PR comments (including bot comments), failing CI, open issues, and open plans/ tasks; close or document no-impact issues; create a new PR for the issue work. + +## Inventory (verified 2026-08-09) + +| Item | State | Action | +|------|-------|--------| +| PR #624 (OKF bundle) | DeepSource: JavaScript FAILING (docs coverage 0.3% vs 71.8% baseline) + 14 inline comments + OwlWatch findings + `@jules address feedback` | Fixed at source, pushed `dfff869` | +| PR #625 (dompurify 3.4.13 dependabot) | All checks green but BLOCKED; unresolved OwlWatch thread (missing pnpm override) | Added override, pushed `2c9a400`, replied to thread, re-armed auto-merge | +| Issue #620 (eslint outdated 9→10) | OPEN, severity medium | Attempted; **blocked upstream** (see below); documented & closed | +| Issue #621 (long function `resolve_url_stream` 230 LOC/54 ccn) | OPEN, severity medium | Refactored into 4 helpers; 115 LOC/22 ccn | +| Issue #622 (subprocess S603 untrusted URL in docling/OCR) | OPEN, severity low | URL safety validation added + 5 tests | +| Issue #623 (OKF feature tracker) | OPEN | Close when PR #624 merges (no separate code impact) | + +## Execution Log + +### Phase 1 — PR #624 (OKF bundle) remediation +DeepSource blocked on **Documentation Coverage metric (0.3% vs 71.8% baseline)** plus inline issues (JS-0067 arrows, JS-R1005 complexity on `bundle.ts`/`import.ts`/`use-export-handlers.ts`, async-without-await, non-null assertion). Fixes on `feat/okf-bundle-export-import-17257068464195044926`: +- `bundle.ts` — extracted `buildTrustSection`/`buildMetadataSection`/`buildEntityFile` helpers (complexity ↓, const arrows) +- `import.ts` — extracted `parseFrontmatterWithYaml`/`parseEntityYaml` helpers, removed non-null assertion, trust tier now derived from `verifyBundle` +- `trust.ts`/`trust.test.ts`/`types.ts` — TSDoc on all exported symbols (doc-coverage), const arrows +- `use-export-handlers.ts` — extracted `importOkfZip` helper (async fix), `handleExport` switch → dispatch map (complexity 8→6) +- `import.test.ts` — new tests for verification-tier derivation + YAML frontmatter parsing +- **Validation**: lint ✓ typecheck ✓ build ✓ 34 OKF tests ✓; pushed as `dfff869`; all PR #624 checks re-ran (DeepSource re-analyzing on push). + +### Phase 2 — PR #625 (dompurify) unblock +OwlWatch MEDIUM: "Missing or outdated pnpm override for updated dompurify dependency". Verified: `dompurify ^3.4.13` is a direct dep; `jspdf@4.2.1` carries it as an **optional dependency pinned to 3.4.12** — exactly what an override consolidates. Fix on `dependabot/npm_and_yarn/npm_and_yarn-37951cc692`: +- Added `"dompurify": "^3.4.13"` to `pnpm.overrides`; `pnpm install` resolved all lockfile entries to 3.4.13 +- Pushed `2c9a400`, replied to the OwlWatch thread with the resolution, re-armed `gh pr merge 625 --auto --squash`. + +### Phase 3 — Issue #622 (subprocess S603) +`providers_impl.py` `resolve_with_docling`/`resolve_with_ocr` passed user URLs to `subprocess.run`. Fix: reject non-http(s)/private-IP URLs via existing `is_safe_url` (SSRF guard) before invoking the subprocess; returns `error="unsafe_url"` / `meta.error_type="ssrf_blocked"`. Added 5 tests (unsafe scheme, private IP, safe-path subprocess invocation with exact argv). Ruff clean on changed lines; full suite passes. + +### Phase 4 — Issue #621 (long function) +`resolve.py` `resolve_url_stream` was 230 LOC / ccn 54 / nloc 214. Extracted: +- `_special_document_provider(name)` — call-time provider lookup (keeps `@patch` mockability) +- `_resolve_special_document(...)` — docling/OCR extension dispatch + trace emission +- `_build_probe_output(...)` — quality gate, circuit breaker/metrics/trace recording, output building +- `_process_probe_result(...)` — future completion handling (budget + exceptions) + +Result: `resolve_url_stream` 115 LOC / ccn 22 / nloc 101 (down from 230/54/214) — under the proven OwlWatch threshold (un-flagged `resolve_query_stream` is 159 LOC). Added 5 mock-based cascade tests (ResolvedResult yield, llms.txt compaction, thin-content fall-through, docling success/failure) with hermetic diskcache isolation via `_get_cache` patch (a stale persisted negative-cache entry from real-network runs was blocking the mock test). Also hardened the flaky `test_rate_limit_clears_after_expiry` (poll-until-expiry instead of fixed 1.1 s sleep). **187 Python tests pass ×2, ruff clean.** + +### Phase 5 — Issue #620 (eslint 9→10) +Attempted `eslint@10.8.1`. **Blocked upstream**: latest `eslint-plugin-react@7.37.5` (peer `eslint ^9.7` max) and `eslint-plugin-jsx-a11y@6.10.2` (peer `^9` max) do not support ESLint 10; react's plugin crashes with `contextOrFilename.getFilename is not a function`. Only `eslint-plugin-react-hooks@7.1.1` supports 10. Reverted to `^9`; lint green. **Resolution**: close #620 as `won't-fix (upstream)` with this evidence; revisit when plugins ship ESLint 10 peer ranges. + +### Phase 6 — Plan 111 / worklog +- This plan documents all changes and the reconciliation. +- `worklog.md` updated with a session entry. + +## Success Criteria +- [x] PR #624: all DeepSource/OwlWatch/maintainer review comments addressed at source; pushed; CI re-running +- [x] PR #625: OwlWatch override comment resolved (code fix + thread reply); auto-merge re-armed +- [x] Issue #621: `resolve_url_stream` refactored; lizard metrics halved; tests added; suite green +- [x] Issue #622: subprocess inputs SSRF-validated; tests added; suite green +- [x] Issue #620: upgrade attempted, blocked upstream, documented with evidence, closed +- [x] Issue #623: tracked to PR #624 merge (no independent code impact) +- [x] New PR created for the issue/plans work diff --git a/worklog.md b/worklog.md index 04c85b78..585acfff 100644 --- a/worklog.md +++ b/worklog.md @@ -1,5 +1,18 @@ # Worklog — DO Knowledge Studio Redesign +--- +Task ID: owlwatch-111 +Agent: Buffy (GOAP swarm) +Task: Address open PR comments/bot comments, failing CI, open issues, and open plans/ tasks (Plan 111). + +Work Log (2026-08-09): +- PR #624 (OKF bundle): fixed the DeepSource blocker (Documentation Coverage 0.3% vs 71.8% baseline) by adding TSDoc to all exported symbols in `src/lib/okf/*`, plus all inline findings (const arrows, complexity splits in `bundle.ts`/`import.ts`/`use-export-handlers.ts`, async-without-await, non-null assertion). lint/typecheck/build + 34 OKF tests green; pushed `dfff869`. +- PR #625 (dompurify 3.4.13): added pnpm override pinning the jspdf optional transitive dep to 3.4.13; pushed `2c9a400`; replied to the OwlWatch thread; re-armed auto-merge to unblock. +- Issue #622: SSRF validation (`is_safe_url`) before `subprocess.run` in docling/OCR providers + 5 tests. +- Issue #621: `resolve_url_stream` refactored 230→115 LOC / ccn 54→22 via 4 helpers; 5 mock-based cascade tests; hardened a flaky rate-limit test; 187 Python tests pass ×2; ruff clean. +- Issue #620 (eslint 10): blocked upstream — `eslint-plugin-react@7.37.5` and `eslint-plugin-jsx-a11y@6.10.2` peers cap at eslint 9 and crash on 10; reverted to ^9, documented in Plan 111. +- Plan 111 created; see `plans/111-owlwatch-issues-pr624-625-2026-08-09.md`. + --- Task ID: redesign-1 Agent: main (Super Z) From fa6fbed2e090fda4a05f2f16bc8084ad08036be0 Mon Sep 17 00:00:00 2001 From: d-oit <6849456+d-oit@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:22:49 +0200 Subject: [PATCH 02/15] fix(owlwatch): add docstrings and reduce complexity for DeepSource Python gate --- .../scripts/providers_impl.py | 20 ++ .../do-web-doc-resolver/scripts/resolve.py | 229 +++++++++++++----- .../do-web-doc-resolver/tests/test_resolve.py | 13 +- 3 files changed, 193 insertions(+), 69 deletions(-) diff --git a/.agents/skills/do-web-doc-resolver/scripts/providers_impl.py b/.agents/skills/do-web-doc-resolver/scripts/providers_impl.py index 208979f6..86f4f8c0 100644 --- a/.agents/skills/do-web-doc-resolver/scripts/providers_impl.py +++ b/.agents/skills/do-web-doc-resolver/scripts/providers_impl.py @@ -29,6 +29,7 @@ def _is_rate_limited(provider: str) -> bool: + """Return True if the provider is inside its cooldown window.""" if provider in _rate_limits: if time.time() < _rate_limits[provider]: return True @@ -37,6 +38,7 @@ def _is_rate_limited(provider: str) -> bool: def _set_rate_limit(provider: str, cooldown: int = 60): + """Record a rate-limit cooldown for the provider (seconds).""" _rate_limits[provider] = time.time() + cooldown @@ -46,6 +48,7 @@ def _set_rate_limit(provider: str, cooldown: int = 60): def resolve_with_jina(url: str, max_chars: int = MAX_CHARS) -> ProviderResult: + """Resolve a URL via the Jina Reader (r.jina.ai) and return a ProviderResult.""" start = time.time() cached = _get_from_cache(url, "jina") if cached: @@ -84,6 +87,7 @@ def resolve_with_jina(url: str, max_chars: int = MAX_CHARS) -> ProviderResult: def resolve_with_exa_mcp(query: str, max_chars: int = MAX_CHARS) -> ProviderResult: + """Resolve a query via the Exa MCP web-search endpoint.""" start = time.time() cached = _get_from_cache(query, "exa_mcp") if cached: @@ -126,6 +130,7 @@ def resolve_with_exa_mcp(query: str, max_chars: int = MAX_CHARS) -> ProviderResu def resolve_with_exa(query: str, max_chars: int = MAX_CHARS) -> ProviderResult: + """Resolve a query via the Exa SDK, requiring EXA_API_KEY.""" start = time.time() cached = _get_from_cache(query, "exa") if cached: @@ -167,6 +172,7 @@ def resolve_with_exa(query: str, max_chars: int = MAX_CHARS) -> ProviderResult: def resolve_with_tavily(query: str, max_chars: int = MAX_CHARS) -> ProviderResult: + """Resolve a query via the Tavily SDK, requiring TAVILY_API_KEY.""" start = time.time() cached = _get_from_cache(query, "tavily") if cached: @@ -200,6 +206,7 @@ def resolve_with_tavily(query: str, max_chars: int = MAX_CHARS) -> ProviderResul def resolve_with_duckduckgo(query: str, max_chars: int = MAX_CHARS) -> ProviderResult: + """Resolve a query via the free DuckDuckGo text search (ddgs).""" start = time.time() cached = _get_from_cache(query, "duckduckgo") if cached: @@ -233,6 +240,7 @@ def resolve_with_duckduckgo(query: str, max_chars: int = MAX_CHARS) -> ProviderR def resolve_with_firecrawl(url: str, max_chars: int = MAX_CHARS) -> ProviderResult: + """Scrape a URL to markdown via Firecrawl, requiring FIRECRAWL_API_KEY.""" start = time.time() cached = _get_from_cache(url, "firecrawl") if cached: @@ -263,6 +271,7 @@ def resolve_with_firecrawl(url: str, max_chars: int = MAX_CHARS) -> ProviderResu def resolve_with_mistral_browser(url: str, max_chars: int = MAX_CHARS) -> ProviderResult: + """Extract a URL's content via the Mistral browser tool.""" start = time.time() cached = _get_from_cache(url, "mistral_browser") if cached: @@ -296,6 +305,7 @@ def resolve_with_mistral_browser(url: str, max_chars: int = MAX_CHARS) -> Provid def resolve_with_mistral_websearch(query: str, max_chars: int = MAX_CHARS) -> ProviderResult: + """Answer a query via Mistral web search, requiring MISTRAL_API_KEY.""" start = time.time() cached = _get_from_cache(query, "mistral_websearch") if cached: @@ -329,6 +339,11 @@ def resolve_with_mistral_websearch(query: str, max_chars: int = MAX_CHARS) -> Pr def resolve_with_docling(url: str, max_chars: int) -> ProviderResult: + """Convert a document URL (pdf/docx/pptx) to markdown via the docling CLI. + + The URL is validated with ``is_safe_url`` before being passed to + subprocess to prevent SSRF / command injection via untrusted input. + """ start = time.time() if not is_safe_url(url): duration = int((time.time() - start) * 1000) @@ -352,6 +367,11 @@ def resolve_with_docling(url: str, max_chars: int) -> ProviderResult: def resolve_with_ocr(url: str, max_chars: int) -> ProviderResult: + """Extract text from an image URL (png/jpg/jpeg) via the tesseract CLI. + + The URL is validated with ``is_safe_url`` before being passed to + subprocess to prevent SSRF / command injection via untrusted input. + """ start = time.time() if not is_safe_url(url): duration = int((time.time() - start) * 1000) diff --git a/.agents/skills/do-web-doc-resolver/scripts/resolve.py b/.agents/skills/do-web-doc-resolver/scripts/resolve.py index 8dee9df3..8909ef40 100644 --- a/.agents/skills/do-web-doc-resolver/scripts/resolve.py +++ b/.agents/skills/do-web-doc-resolver/scripts/resolve.py @@ -115,6 +115,11 @@ def synthesize_results(query: str, results: list[ResolvedResult], api_key: str, model: str) -> str: + """Synthesize resolved results into a single cited markdown answer. + + Uses LLM synthesis when the results are rich enough; otherwise falls + back to a deterministic merge. + """ if not results: return "No results to synthesize." if not synthesis.should_call_llm_synthesis(results): @@ -154,6 +159,7 @@ def synthesize_results(query: str, results: list[ResolvedResult], api_key: str, def resolve_url( url: str, max_chars: int = MAX_CHARS, profile: Profile = Profile.BALANCED ) -> dict[str, Any]: + """Resolve a URL to a single result dict (first non-partial output).""" for result in resolve_url_stream(url, max_chars, profile): if result.get("source") != "partial": return result @@ -218,6 +224,44 @@ def _resolve_special_document( return None +def _record_probe_rejection( + p_name_done: str, + pt_done: ProviderType, + latency: int, + metrics: ResolveMetrics, + trace: ResolutionTrace | None, + error: str | None = None, +) -> None: + """Record a failed probe across the circuit breaker, metrics, and trace.""" + _circuit_breakers.record_failure(p_name_done) + metrics.record_provider(pt_done, latency, False) + if error and trace: + step = TraceStep(tool=p_name_done, duration_ms=latency, success=False, error=error) + trace.steps.append(step) + + +def _record_probe_success( + p_name_done: str, + pt_done: ProviderType, + latency: int, + metrics: ResolveMetrics, + trace: ResolutionTrace | None, + start_time: float, + domain: str, + q_score: Any, +) -> None: + """Record a successful probe across the circuit breaker, metrics, memory, and trace.""" + _circuit_breakers.record_success(p_name_done) + metrics.record_provider(pt_done, latency, True) + if domain: + _routing_memory.record(domain, p_name_done, True, latency, q_score.score) + if trace: + trace.total_latency_ms = int((time.time() - start_time) * 1000) + trace.final_source = p_name_done + trace.final_score = q_score.score + trace.success = True + + def _build_probe_output( res_or_content: Any, p_name_done: str, @@ -236,21 +280,13 @@ def _build_probe_output( processing further futures in the current completion batch. """ if not res_or_content: - _circuit_breakers.record_failure(p_name_done) - metrics.record_provider(pt_done, latency, False) + _record_probe_rejection(p_name_done, pt_done, latency, metrics, trace) return None, False if isinstance(res_or_content, ProviderResult): if not res_or_content.ok: - _circuit_breakers.record_failure(p_name_done) - metrics.record_provider(pt_done, latency, False) - if trace: - step = TraceStep( - tool=p_name_done, - duration_ms=latency, - success=False, - error=res_or_content.error, - ) - trace.steps.append(step) + _record_probe_rejection( + p_name_done, pt_done, latency, metrics, trace, res_or_content.error + ) return None, False content = res_or_content.content or "" elif isinstance(res_or_content, ResolvedResult): @@ -265,15 +301,9 @@ def _build_probe_output( _routing_memory.record(domain, p_name_done, False, latency, q_score.score) return None, False - _circuit_breakers.record_success(p_name_done) - metrics.record_provider(pt_done, latency, True) - if domain: - _routing_memory.record(domain, p_name_done, True, latency, q_score.score) - if trace: - trace.total_latency_ms = int((time.time() - start_time) * 1000) - trace.final_source = p_name_done - trace.final_score = q_score.score - trace.success = True + _record_probe_success( + p_name_done, pt_done, latency, metrics, trace, start_time, domain, q_score + ) if pt_done == ProviderType.LLMS_TXT: out: dict[str, Any] = { "source": "llms.txt", @@ -333,10 +363,94 @@ def _process_probe_result( ) +def _launch_url_probe( + p_name: str, + pt: ProviderType, + func: Callable[[], Any], + budget: routing.ResolutionBudget, + cache: Any, + url: str, + executor: concurrent.futures.ThreadPoolExecutor, +) -> tuple[concurrent.futures.Future[Any] | None, bool]: + """Submit a provider probe when budget/cache/circuit state allow. + + Returns ``(future, stop)``: ``future`` is None when the probe was skipped + and the cascade should continue with the next provider; ``stop`` signals + that the whole cascade should halt. + """ + if not budget.can_try(is_paid=pt.is_paid()): + return None, budget.stop_reason not in ("paid_disabled", "max_paid_attempts") + if cache_negative.should_skip_from_negative_cache(cache, url, p_name): + return None, False + if _circuit_breakers.is_open(p_name): + return None, False + return executor.submit(func), False + + +def _drain_completed_probes( + active_futures: dict[concurrent.futures.Future[Any], tuple[str, ProviderType, float]], + i: int, + eligible: list[str], + p_name: str, + threshold: float, + start_time_probe: float, + budget: routing.ResolutionBudget, + url: str, + max_chars: int, + metrics: ResolveMetrics, + trace: ResolutionTrace | None, + start_time: float, + domain: str, +) -> dict[str, Any] | None: + """Process completed probe futures until the batch yields an acceptable result. + + Returns the output dict to yield, or None when the batch is exhausted + (the caller then proceeds to the next eligible provider). Hedging breaks + out of the wait loop so the next provider can be launched early. + """ + while active_futures: + elapsed = time.time() - start_time_probe + if i < len(eligible) - 1 and elapsed >= threshold: + logger.info(f"Hedging threshold reached for {p_name} ({threshold}s)") + break + done, _ = concurrent.futures.wait( + active_futures.keys(), + timeout=0.01, + return_when=concurrent.futures.FIRST_COMPLETED, + ) + for f in list(done): + if f not in active_futures: + continue + out, accepted = _process_probe_result( + f, + active_futures, + budget, + url, + max_chars, + metrics, + trace, + start_time, + domain, + ) + if accepted: + return out + if done: + break + if not active_futures: + break + return None + + def resolve_url_stream( url: str, max_chars: int = MAX_CHARS, profile: Profile = Profile.BALANCED, trace: ResolutionTrace | None = None, ) -> Generator[dict[str, Any], None, None]: + """Resolve a URL through the provider cascade, yielding results as found. + + Special document/image URLs are handled first (docling/OCR), then the + eligible web providers are probed with hedging until an acceptable result + is produced or the budget is exhausted. + """ logger.info(f"Resolving URL: {url}") metrics = ResolveMetrics() budget_data = routing.PROFILE_BUDGETS.get(profile.value, routing.PROFILE_BUDGETS["balanced"]) @@ -380,59 +494,34 @@ def resolve_url_stream( try: for i, p_name in enumerate(eligible): pt, func = cascade_map[p_name] - if not budget.can_try(is_paid=pt.is_paid()): - if budget.stop_reason in ("paid_disabled", "max_paid_attempts"): - continue + future, stop = _launch_url_probe(p_name, pt, func, budget, cache, url, executor) + if stop: break - if cache_negative.should_skip_from_negative_cache(cache, url, p_name): - continue - if _circuit_breakers.is_open(p_name): + if future is None: continue logger.info(f"Starting probe: {p_name}") start_time_probe = time.time() - future = executor.submit(func) active_futures[future] = (p_name, pt, start_time_probe) threshold = _routing_memory.get_p75_latency(domain or "any", p_name) / 1000.0 - while active_futures: - elapsed = time.time() - start_time_probe - - # If we've hit the threshold, start the next provider (hedging) - if i < len(eligible) - 1 and elapsed >= threshold: - logger.info(f"Hedging threshold reached for {p_name} ({threshold}s)") - break - - # Wait for any task to complete - done, _ = concurrent.futures.wait( - active_futures.keys(), - timeout=0.01, - return_when=concurrent.futures.FIRST_COMPLETED, - ) - - for f in list(done): - if f not in active_futures: - continue - out, accepted = _process_probe_result( - f, - active_futures, - budget, - url, - max_chars, - metrics, - trace, - start_time, - domain, - ) - if accepted: - if out is not None: - yield out - break - - if done: - break - if not active_futures: - break + out = _drain_completed_probes( + active_futures, + i, + eligible, + p_name, + threshold, + start_time_probe, + budget, + url, + max_chars, + metrics, + trace, + start_time, + domain, + ) + if out is not None: + yield out finally: executor.shutdown(wait=False, cancel_futures=True) @@ -456,6 +545,7 @@ def resolve_query( skip_providers: set[str] | None = None, profile: Profile = Profile.BALANCED, ) -> dict[str, Any]: + """Resolve a search query to a single result dict (first non-partial output).""" for result in resolve_query_stream(query, max_chars, skip_providers, profile): if result.get("source") != "partial": return result @@ -469,6 +559,7 @@ def resolve_query_stream( profile: Profile = Profile.BALANCED, trace: ResolutionTrace | None = None, ) -> Generator[dict[str, Any], None, None]: + """Resolve a search query through the provider cascade, yielding results as found.""" skip = skip_providers or set() metrics = ResolveMetrics() budget_data = routing.PROFILE_BUDGETS.get(profile.value, routing.PROFILE_BUDGETS["balanced"]) @@ -629,6 +720,7 @@ def resolve( skip_providers: set[str] | None = None, profile: Profile = Profile.BALANCED, ) -> dict[str, Any]: + """Resolve either a URL or a query based on the input shape.""" if is_url(input_str): return resolve_url(input_str, max_chars, profile=profile) return resolve_query(input_str, max_chars, skip_providers, profile=profile) @@ -637,6 +729,7 @@ def resolve( def resolve_direct( input_str: str, provider: ProviderType, max_chars: int = MAX_CHARS ) -> dict[str, Any]: + """Resolve input with a single named provider, bypassing the cascade.""" funcs = { ProviderType.JINA: resolve_with_jina, ProviderType.EXA_MCP: resolve_with_exa_mcp, @@ -667,6 +760,7 @@ def resolve_direct( def resolve_with_order( input_str: str, providers_order: list[ProviderType], max_chars: int = MAX_CHARS ) -> dict[str, Any]: + """Resolve input trying providers sequentially until one succeeds.""" for pt in providers_order: res = resolve_direct(input_str, pt, max_chars) if res.get("source") != "none": @@ -677,16 +771,19 @@ def resolve_with_order( def resolve_url_with_order( url: str, order: list[ProviderType], max_chars: int = MAX_CHARS ) -> dict[str, Any]: + """Resolve a URL with an explicit provider order.""" return resolve_with_order(url, order, max_chars) def resolve_query_with_order( query: str, order: list[ProviderType], max_chars: int = MAX_CHARS ) -> dict[str, Any]: + """Resolve a query with an explicit provider order.""" return resolve_with_order(query, order, max_chars) def main(): + """CLI entry point: resolve a URL or query with optional tracing.""" parser = argparse.ArgumentParser(description="Web Doc Resolver") parser.add_argument("input", nargs="?", help="URL or query") parser.add_argument("--max-chars", type=int, default=MAX_CHARS) diff --git a/.agents/skills/do-web-doc-resolver/tests/test_resolve.py b/.agents/skills/do-web-doc-resolver/tests/test_resolve.py index 19195163..c23f7799 100644 --- a/.agents/skills/do-web-doc-resolver/tests/test_resolve.py +++ b/.agents/skills/do-web-doc-resolver/tests/test_resolve.py @@ -185,9 +185,16 @@ class TestResolveUrlStreamCascade: """Mock-based tests for the resolve_url_stream provider cascade.""" @pytest.fixture(autouse=True) - def _isolate_cache(self): - """Disable the persistent diskcache so tests stay hermetic.""" - with patch("scripts.resolve._get_cache", return_value=None): + def _isolate_state(self): + """Isolate persistent cache, circuit breakers, and routing memory.""" + from scripts.circuit_breaker import CircuitBreakerRegistry + from scripts.routing_memory import RoutingMemory + + with ( + patch("scripts.resolve._get_cache", return_value=None), + patch("scripts.resolve._circuit_breakers", CircuitBreakerRegistry()), + patch("scripts.resolve._routing_memory", RoutingMemory()), + ): yield @staticmethod From 8805033b8cc81d1f41b30127eea4c3c90120bfb5 Mon Sep 17 00:00:00 2001 From: d-oit <6849456+d-oit@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:27:04 +0200 Subject: [PATCH 03/15] docs(owlwatch): add Args/Returns sections to raise DeepSource Python doc coverage --- .../scripts/providers_impl.py | 110 ++++++++- .../do-web-doc-resolver/scripts/resolve.py | 214 +++++++++++++++++- 2 files changed, 303 insertions(+), 21 deletions(-) diff --git a/.agents/skills/do-web-doc-resolver/scripts/providers_impl.py b/.agents/skills/do-web-doc-resolver/scripts/providers_impl.py index 86f4f8c0..95ad84a0 100644 --- a/.agents/skills/do-web-doc-resolver/scripts/providers_impl.py +++ b/.agents/skills/do-web-doc-resolver/scripts/providers_impl.py @@ -29,7 +29,14 @@ def _is_rate_limited(provider: str) -> bool: - """Return True if the provider is inside its cooldown window.""" + """Return True if the provider is inside its cooldown window. + + Args: + provider: Provider name key in the cooldown registry. + + Returns: + True when a cooldown is active; the expired entry is removed otherwise. + """ if provider in _rate_limits: if time.time() < _rate_limits[provider]: return True @@ -38,7 +45,12 @@ def _is_rate_limited(provider: str) -> bool: def _set_rate_limit(provider: str, cooldown: int = 60): - """Record a rate-limit cooldown for the provider (seconds).""" + """Record a rate-limit cooldown for the provider (seconds). + + Args: + provider: Provider name to throttle. + cooldown: Cooldown duration in seconds. + """ _rate_limits[provider] = time.time() + cooldown @@ -48,7 +60,15 @@ def _set_rate_limit(provider: str, cooldown: int = 60): def resolve_with_jina(url: str, max_chars: int = MAX_CHARS) -> ProviderResult: - """Resolve a URL via the Jina Reader (r.jina.ai) and return a ProviderResult.""" + """Resolve a URL via the Jina Reader (r.jina.ai) and return a ProviderResult. + + Args: + url: The target URL to read. + max_chars: Maximum content length to retain. + + Returns: + A ProviderResult with the markdown content or an error meta. + """ start = time.time() cached = _get_from_cache(url, "jina") if cached: @@ -87,7 +107,15 @@ def resolve_with_jina(url: str, max_chars: int = MAX_CHARS) -> ProviderResult: def resolve_with_exa_mcp(query: str, max_chars: int = MAX_CHARS) -> ProviderResult: - """Resolve a query via the Exa MCP web-search endpoint.""" + """Resolve a query via the Exa MCP web-search endpoint. + + Args: + query: The search query. + max_chars: Maximum content length to retain. + + Returns: + A ProviderResult with the search results or an error meta. + """ start = time.time() cached = _get_from_cache(query, "exa_mcp") if cached: @@ -130,7 +158,15 @@ def resolve_with_exa_mcp(query: str, max_chars: int = MAX_CHARS) -> ProviderResu def resolve_with_exa(query: str, max_chars: int = MAX_CHARS) -> ProviderResult: - """Resolve a query via the Exa SDK, requiring EXA_API_KEY.""" + """Resolve a query via the Exa SDK, requiring EXA_API_KEY. + + Args: + query: The search query. + max_chars: Maximum content length to retain. + + Returns: + A ProviderResult with the search results or an error meta. + """ start = time.time() cached = _get_from_cache(query, "exa") if cached: @@ -172,7 +208,15 @@ def resolve_with_exa(query: str, max_chars: int = MAX_CHARS) -> ProviderResult: def resolve_with_tavily(query: str, max_chars: int = MAX_CHARS) -> ProviderResult: - """Resolve a query via the Tavily SDK, requiring TAVILY_API_KEY.""" + """Resolve a query via the Tavily SDK, requiring TAVILY_API_KEY. + + Args: + query: The search query. + max_chars: Maximum content length to retain. + + Returns: + A ProviderResult with the search results or an error meta. + """ start = time.time() cached = _get_from_cache(query, "tavily") if cached: @@ -206,7 +250,15 @@ def resolve_with_tavily(query: str, max_chars: int = MAX_CHARS) -> ProviderResul def resolve_with_duckduckgo(query: str, max_chars: int = MAX_CHARS) -> ProviderResult: - """Resolve a query via the free DuckDuckGo text search (ddgs).""" + """Resolve a query via the free DuckDuckGo text search (ddgs). + + Args: + query: The search query. + max_chars: Maximum content length to retain. + + Returns: + A ProviderResult with the search results or an error meta. + """ start = time.time() cached = _get_from_cache(query, "duckduckgo") if cached: @@ -240,7 +292,15 @@ def resolve_with_duckduckgo(query: str, max_chars: int = MAX_CHARS) -> ProviderR def resolve_with_firecrawl(url: str, max_chars: int = MAX_CHARS) -> ProviderResult: - """Scrape a URL to markdown via Firecrawl, requiring FIRECRAWL_API_KEY.""" + """Scrape a URL to markdown via Firecrawl, requiring FIRECRAWL_API_KEY. + + Args: + url: The target URL to scrape. + max_chars: Maximum content length to retain. + + Returns: + A ProviderResult with the markdown content or an error meta. + """ start = time.time() cached = _get_from_cache(url, "firecrawl") if cached: @@ -271,7 +331,15 @@ def resolve_with_firecrawl(url: str, max_chars: int = MAX_CHARS) -> ProviderResu def resolve_with_mistral_browser(url: str, max_chars: int = MAX_CHARS) -> ProviderResult: - """Extract a URL's content via the Mistral browser tool.""" + """Extract a URL's content via the Mistral browser tool. + + Args: + url: The target URL to extract. + max_chars: Maximum content length to retain. + + Returns: + A ProviderResult with the extracted content or an error meta. + """ start = time.time() cached = _get_from_cache(url, "mistral_browser") if cached: @@ -305,7 +373,15 @@ def resolve_with_mistral_browser(url: str, max_chars: int = MAX_CHARS) -> Provid def resolve_with_mistral_websearch(query: str, max_chars: int = MAX_CHARS) -> ProviderResult: - """Answer a query via Mistral web search, requiring MISTRAL_API_KEY.""" + """Answer a query via Mistral web search, requiring MISTRAL_API_KEY. + + Args: + query: The search query. + max_chars: Maximum content length to retain. + + Returns: + A ProviderResult with the answer content or an error meta. + """ start = time.time() cached = _get_from_cache(query, "mistral_websearch") if cached: @@ -343,6 +419,13 @@ def resolve_with_docling(url: str, max_chars: int) -> ProviderResult: The URL is validated with ``is_safe_url`` before being passed to subprocess to prevent SSRF / command injection via untrusted input. + + Args: + url: The document URL to convert. + max_chars: Maximum content length to retain. + + Returns: + A ProviderResult with the extracted markdown or an error meta. """ start = time.time() if not is_safe_url(url): @@ -371,6 +454,13 @@ def resolve_with_ocr(url: str, max_chars: int) -> ProviderResult: The URL is validated with ``is_safe_url`` before being passed to subprocess to prevent SSRF / command injection via untrusted input. + + Args: + url: The image URL to OCR. + max_chars: Maximum content length to retain. + + Returns: + A ProviderResult with the recognized text or an error meta. """ start = time.time() if not is_safe_url(url): diff --git a/.agents/skills/do-web-doc-resolver/scripts/resolve.py b/.agents/skills/do-web-doc-resolver/scripts/resolve.py index 8909ef40..8841e915 100644 --- a/.agents/skills/do-web-doc-resolver/scripts/resolve.py +++ b/.agents/skills/do-web-doc-resolver/scripts/resolve.py @@ -119,6 +119,15 @@ def synthesize_results(query: str, results: list[ResolvedResult], api_key: str, Uses LLM synthesis when the results are rich enough; otherwise falls back to a deterministic merge. + + Args: + query: The original search query. + results: Resolved results to synthesize. + api_key: Mistral API key for LLM synthesis. + model: Model name to use for synthesis. + + Returns: + The synthesized markdown answer. """ if not results: return "No results to synthesize." @@ -159,7 +168,16 @@ def synthesize_results(query: str, results: list[ResolvedResult], api_key: str, def resolve_url( url: str, max_chars: int = MAX_CHARS, profile: Profile = Profile.BALANCED ) -> dict[str, Any]: - """Resolve a URL to a single result dict (first non-partial output).""" + """Resolve a URL to a single result dict (first non-partial output). + + Args: + url: The URL to resolve. + max_chars: Maximum content length to retain. + profile: Resolution profile controlling the budget. + + Returns: + The first non-partial result dict, or a "none" failure dict. + """ for result in resolve_url_stream(url, max_chars, profile): if result.get("source") != "partial": return result @@ -177,6 +195,12 @@ def _special_document_provider(name: str) -> Callable[[str, int], ProviderResult The lookup happens at call time so module-attribute patching in tests (e.g. ``@patch("scripts.resolve.resolve_with_docling")``) keeps working. + + Args: + name: Tool name (``docling`` or ``ocr``). + + Returns: + The provider function for the tool name (ocr as fallback). """ if name == "docling": return resolve_with_docling @@ -194,6 +218,16 @@ def _resolve_special_document( Falls through to the regular provider cascade when no special provider matches the URL extension or the provider reports a failure. + + Args: + url: The URL being resolved. + max_chars: Maximum content length to retain. + start_time: Epoch seconds when the resolution started (for traces). + metrics: Metrics accumulator for the resolution. + trace: Optional trace to populate on success. + + Returns: + A result dict to yield, or None to continue with the cascade. """ lower_url = url.lower() for extensions, tool in _SPECIAL_DOCUMENT_PROVIDERS: @@ -232,7 +266,16 @@ def _record_probe_rejection( trace: ResolutionTrace | None, error: str | None = None, ) -> None: - """Record a failed probe across the circuit breaker, metrics, and trace.""" + """Record a failed probe across the circuit breaker, metrics, and trace. + + Args: + p_name_done: Provider name that failed. + pt_done: Provider type that failed. + latency: Probe latency in milliseconds. + metrics: Metrics accumulator. + trace: Optional trace to append a failure step to. + error: Optional error message recorded in the trace. + """ _circuit_breakers.record_failure(p_name_done) metrics.record_provider(pt_done, latency, False) if error and trace: @@ -250,7 +293,18 @@ def _record_probe_success( domain: str, q_score: Any, ) -> None: - """Record a successful probe across the circuit breaker, metrics, memory, and trace.""" + """Record a successful probe across the circuit breaker, metrics, memory, and trace. + + Args: + p_name_done: Provider name that succeeded. + pt_done: Provider type that succeeded. + latency: Probe latency in milliseconds. + metrics: Metrics accumulator. + trace: Optional trace to mark successful. + start_time: Epoch seconds when the resolution started. + domain: Extracted domain used for routing-memory keys. + q_score: Quality score of the accepted content. + """ _circuit_breakers.record_success(p_name_done) metrics.record_provider(pt_done, latency, True) if domain: @@ -278,6 +332,22 @@ def _build_probe_output( Returns ``(output_dict, accepted)``; ``accepted`` tells the caller to stop processing further futures in the current completion batch. + + Args: + res_or_content: The provider's result object or raw content. + p_name_done: Provider name that completed. + pt_done: Provider type that completed. + latency: Probe latency in milliseconds. + url: The URL being resolved. + max_chars: Maximum content length to retain. + metrics: Metrics accumulator. + trace: Optional trace to update on success. + start_time: Epoch seconds when the resolution started. + domain: Extracted domain used for routing-memory keys. + + Returns: + (output dict to yield, accepted flag); accepted means the caller + should stop processing the current completion batch. """ if not res_or_content: _record_probe_rejection(p_name_done, pt_done, latency, metrics, trace) @@ -334,7 +404,23 @@ def _process_probe_result( start_time: float, domain: str, ) -> tuple[dict[str, Any] | None, bool]: - """Process a single completed provider probe, recording budget and metrics.""" + """Process a single completed provider probe, recording budget and metrics. + + Args: + future: The completed provider future. + active_futures: Map of in-flight futures to probe metadata. + budget: Resolution budget tracker. + url: The URL being resolved. + max_chars: Maximum content length to retain. + metrics: Metrics accumulator. + trace: Optional trace to update. + start_time: Epoch seconds when the resolution started. + domain: Extracted domain used for routing-memory keys. + + Returns: + (output dict to yield, accepted flag); accepted means the caller + should stop processing the current completion batch. + """ p_name_done, pt_done, s_time = active_futures.pop(future) latency = int((time.time() - s_time) * 1000) budget.record_attempt(is_paid=pt_done.is_paid(), latency_ms=latency) @@ -377,6 +463,18 @@ def _launch_url_probe( Returns ``(future, stop)``: ``future`` is None when the probe was skipped and the cascade should continue with the next provider; ``stop`` signals that the whole cascade should halt. + + Args: + p_name: Provider name to probe. + pt: Provider type to probe. + func: Zero-argument callable that performs the probe. + budget: Resolution budget tracker. + cache: Cache handle for negative-cache lookups. + url: The URL being resolved. + executor: Thread pool used to launch the probe. + + Returns: + (future or None, stop flag) as described above. """ if not budget.can_try(is_paid=pt.is_paid()): return None, budget.stop_reason not in ("paid_disabled", "max_paid_attempts") @@ -407,6 +505,24 @@ def _drain_completed_probes( Returns the output dict to yield, or None when the batch is exhausted (the caller then proceeds to the next eligible provider). Hedging breaks out of the wait loop so the next provider can be launched early. + + Args: + active_futures: Map of in-flight futures to probe metadata. + i: Index of the current provider in the eligible list. + eligible: List of eligible provider names. + p_name: Current provider name (for hedge logging). + threshold: Hedging latency threshold in seconds. + start_time_probe: Epoch seconds when this probe was launched. + budget: Resolution budget tracker. + url: The URL being resolved. + max_chars: Maximum content length to retain. + metrics: Metrics accumulator. + trace: Optional trace to update. + start_time: Epoch seconds when the resolution started. + domain: Extracted domain used for routing-memory keys. + + Returns: + The output dict to yield, or None when the batch is exhausted. """ while active_futures: elapsed = time.time() - start_time_probe @@ -450,6 +566,15 @@ def resolve_url_stream( Special document/image URLs are handled first (docling/OCR), then the eligible web providers are probed with hedging until an acceptable result is produced or the budget is exhausted. + + Args: + url: The URL to resolve. + max_chars: Maximum content length to retain. + profile: Resolution profile controlling the budget. + trace: Optional trace to populate during resolution. + + Yields: + Result dicts; the final dict reports "none" when nothing succeeded. """ logger.info(f"Resolving URL: {url}") metrics = ResolveMetrics() @@ -545,7 +670,17 @@ def resolve_query( skip_providers: set[str] | None = None, profile: Profile = Profile.BALANCED, ) -> dict[str, Any]: - """Resolve a search query to a single result dict (first non-partial output).""" + """Resolve a search query to a single result dict (first non-partial output). + + Args: + query: The search query. + max_chars: Maximum content length to retain. + skip_providers: Optional set of provider names to skip. + profile: Resolution profile controlling the budget. + + Returns: + The first non-partial result dict, or a "none" failure dict. + """ for result in resolve_query_stream(query, max_chars, skip_providers, profile): if result.get("source") != "partial": return result @@ -559,7 +694,18 @@ def resolve_query_stream( profile: Profile = Profile.BALANCED, trace: ResolutionTrace | None = None, ) -> Generator[dict[str, Any], None, None]: - """Resolve a search query through the provider cascade, yielding results as found.""" + """Resolve a search query through the provider cascade, yielding results as found. + + Args: + query: The search query. + max_chars: Maximum content length to retain. + skip_providers: Optional set of provider names to skip. + profile: Resolution profile controlling the budget. + trace: Optional trace to populate during resolution. + + Yields: + Result dicts; the final dict reports "none" when nothing succeeded. + """ skip = skip_providers or set() metrics = ResolveMetrics() budget_data = routing.PROFILE_BUDGETS.get(profile.value, routing.PROFILE_BUDGETS["balanced"]) @@ -720,7 +866,17 @@ def resolve( skip_providers: set[str] | None = None, profile: Profile = Profile.BALANCED, ) -> dict[str, Any]: - """Resolve either a URL or a query based on the input shape.""" + """Resolve either a URL or a query based on the input shape. + + Args: + input_str: A URL or a search query. + max_chars: Maximum content length to retain. + skip_providers: Optional set of provider names to skip. + profile: Resolution profile controlling the budget. + + Returns: + The resolution result dict. + """ if is_url(input_str): return resolve_url(input_str, max_chars, profile=profile) return resolve_query(input_str, max_chars, skip_providers, profile=profile) @@ -729,7 +885,16 @@ def resolve( def resolve_direct( input_str: str, provider: ProviderType, max_chars: int = MAX_CHARS ) -> dict[str, Any]: - """Resolve input with a single named provider, bypassing the cascade.""" + """Resolve input with a single named provider, bypassing the cascade. + + Args: + input_str: A URL or a search query. + provider: The provider to use. + max_chars: Maximum content length to retain. + + Returns: + The resolution result dict. + """ funcs = { ProviderType.JINA: resolve_with_jina, ProviderType.EXA_MCP: resolve_with_exa_mcp, @@ -760,7 +925,16 @@ def resolve_direct( def resolve_with_order( input_str: str, providers_order: list[ProviderType], max_chars: int = MAX_CHARS ) -> dict[str, Any]: - """Resolve input trying providers sequentially until one succeeds.""" + """Resolve input trying providers sequentially until one succeeds. + + Args: + input_str: A URL or a search query. + providers_order: Providers to try in order. + max_chars: Maximum content length to retain. + + Returns: + The first successful result dict, or a "none" failure dict. + """ for pt in providers_order: res = resolve_direct(input_str, pt, max_chars) if res.get("source") != "none": @@ -771,14 +945,32 @@ def resolve_with_order( def resolve_url_with_order( url: str, order: list[ProviderType], max_chars: int = MAX_CHARS ) -> dict[str, Any]: - """Resolve a URL with an explicit provider order.""" + """Resolve a URL with an explicit provider order. + + Args: + url: The URL to resolve. + order: Providers to try in order. + max_chars: Maximum content length to retain. + + Returns: + The first successful result dict, or a "none" failure dict. + """ return resolve_with_order(url, order, max_chars) def resolve_query_with_order( query: str, order: list[ProviderType], max_chars: int = MAX_CHARS ) -> dict[str, Any]: - """Resolve a query with an explicit provider order.""" + """Resolve a query with an explicit provider order. + + Args: + query: The search query. + order: Providers to try in order. + max_chars: Maximum content length to retain. + + Returns: + The first successful result dict, or a "none" failure dict. + """ return resolve_with_order(query, order, max_chars) From a9b0812b6b5a422682f23baf7935be085334af88 Mon Sep 17 00:00:00 2001 From: d-oit <6849456+d-oit@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:49:02 +0200 Subject: [PATCH 04/15] fix(owlwatch): lazy log formatting and document test fixture params --- .../do-web-doc-resolver/scripts/resolve.py | 8 +- .../tests/test_providers.py | 42 +++++-- .../do-web-doc-resolver/tests/test_resolve.py | 115 +++++++++++++++--- 3 files changed, 137 insertions(+), 28 deletions(-) diff --git a/.agents/skills/do-web-doc-resolver/scripts/resolve.py b/.agents/skills/do-web-doc-resolver/scripts/resolve.py index 8841e915..9a5a8a19 100644 --- a/.agents/skills/do-web-doc-resolver/scripts/resolve.py +++ b/.agents/skills/do-web-doc-resolver/scripts/resolve.py @@ -527,7 +527,7 @@ def _drain_completed_probes( while active_futures: elapsed = time.time() - start_time_probe if i < len(eligible) - 1 and elapsed >= threshold: - logger.info(f"Hedging threshold reached for {p_name} ({threshold}s)") + logger.info("Hedging threshold reached for %s (%ss)", p_name, threshold) break done, _ = concurrent.futures.wait( active_futures.keys(), @@ -576,7 +576,7 @@ def resolve_url_stream( Yields: Result dicts; the final dict reports "none" when nothing succeeded. """ - logger.info(f"Resolving URL: {url}") + logger.info("Resolving URL: %s", url) metrics = ResolveMetrics() budget_data = routing.PROFILE_BUDGETS.get(profile.value, routing.PROFILE_BUDGETS["balanced"]) budget = routing.ResolutionBudget( @@ -625,7 +625,7 @@ def resolve_url_stream( if future is None: continue - logger.info(f"Starting probe: {p_name}") + logger.info("Starting probe: %s", p_name) start_time_probe = time.time() active_futures[future] = (p_name, pt, start_time_probe) threshold = _routing_memory.get_p75_latency(domain or "any", p_name) / 1000.0 @@ -741,7 +741,7 @@ def resolve_query_stream( continue if _circuit_breakers.is_open(p_name): continue - logger.info(f"Starting probe: {p_name}") + logger.info("Starting probe: %s", p_name) start_time_probe = time.time() future = executor.submit(func, query, max_chars) active_futures[future] = (p_name, pt, start_time_probe) diff --git a/.agents/skills/do-web-doc-resolver/tests/test_providers.py b/.agents/skills/do-web-doc-resolver/tests/test_providers.py index 9339d60f..f8d1e19e 100644 --- a/.agents/skills/do-web-doc-resolver/tests/test_providers.py +++ b/.agents/skills/do-web-doc-resolver/tests/test_providers.py @@ -126,14 +126,22 @@ def setup_method(self): @patch("scripts.providers_impl._is_rate_limited") def test_rate_limited_returns_none(self, mock_rate_limited): - """Rate limited jina should return None.""" + """ + Rate limited jina should return None. + Args: + mock_rate_limited: Injected pytest fixture. + """ mock_rate_limited.return_value = True # This test demonstrates the rate limit check behavior assert is_rate_limited("jina") is False # Not rate limited by default @patch("scripts.providers_impl._get_from_cache") def test_cache_hit_returns_cached(self, mock_cache): - """Cached result should be returned immediately.""" + """ + Cached result should be returned immediately. + Args: + mock_cache: Injected pytest fixture. + """ mock_cache.return_value = { "source": "jina", @@ -258,7 +266,11 @@ class TestSubprocessProviderSafety: @patch("scripts.providers_impl.subprocess.run") def test_docling_rejects_unsafe_scheme(self, mock_run): - """resolve_with_docling should reject non-http(s) URLs without invoking subprocess.""" + """ + resolve_with_docling should reject non-http(s) URLs without invoking subprocess. + Args: + mock_run: Injected pytest fixture. + """ from scripts.providers_impl import resolve_with_docling result = resolve_with_docling("file:///etc/passwd", 1000) @@ -269,7 +281,11 @@ def test_docling_rejects_unsafe_scheme(self, mock_run): @patch("scripts.providers_impl.subprocess.run") def test_docling_rejects_private_ip(self, mock_run): - """resolve_with_docling should reject SSRF-prone private IP URLs.""" + """ + resolve_with_docling should reject SSRF-prone private IP URLs. + Args: + mock_run: Injected pytest fixture. + """ from scripts.providers_impl import resolve_with_docling result = resolve_with_docling("http://127.0.0.1:8080/report.pdf", 1000) @@ -279,7 +295,11 @@ def test_docling_rejects_private_ip(self, mock_run): @patch("scripts.providers_impl.subprocess.run") def test_ocr_rejects_unsafe_scheme(self, mock_run): - """resolve_with_ocr should reject non-http(s) URLs without invoking subprocess.""" + """ + resolve_with_ocr should reject non-http(s) URLs without invoking subprocess. + Args: + mock_run: Injected pytest fixture. + """ from scripts.providers_impl import resolve_with_ocr result = resolve_with_ocr("data:image/png;base64,AAAA", 1000) @@ -289,7 +309,11 @@ def test_ocr_rejects_unsafe_scheme(self, mock_run): @patch("scripts.providers_impl.subprocess.run") def test_docling_passes_safe_url(self, mock_run): - """resolve_with_docling should invoke subprocess for safe http(s) URLs.""" + """ + resolve_with_docling should invoke subprocess for safe http(s) URLs. + Args: + mock_run: Injected pytest fixture. + """ from unittest.mock import MagicMock from scripts.providers_impl import resolve_with_docling @@ -310,7 +334,11 @@ def test_docling_passes_safe_url(self, mock_run): @patch("scripts.providers_impl.subprocess.run") def test_ocr_passes_safe_url(self, mock_run): - """resolve_with_ocr should invoke subprocess for safe http(s) URLs.""" + """ + resolve_with_ocr should invoke subprocess for safe http(s) URLs. + Args: + mock_run: Injected pytest fixture. + """ from unittest.mock import MagicMock from scripts.providers_impl import resolve_with_ocr diff --git a/.agents/skills/do-web-doc-resolver/tests/test_resolve.py b/.agents/skills/do-web-doc-resolver/tests/test_resolve.py index c23f7799..3dbbbfb0 100644 --- a/.agents/skills/do-web-doc-resolver/tests/test_resolve.py +++ b/.agents/skills/do-web-doc-resolver/tests/test_resolve.py @@ -115,7 +115,12 @@ class TestResolve: @pytest.mark.live def test_resolve_url_returns_dict(self, sample_url, max_chars): - """Resolving a URL should return a dict.""" + """ + Resolving a URL should return a dict. + Args: + sample_url: Injected pytest fixture. + max_chars: Injected pytest fixture. + """ result = resolve(sample_url, max_chars=max_chars) assert isinstance(result, dict) assert "source" in result @@ -123,7 +128,12 @@ def test_resolve_url_returns_dict(self, sample_url, max_chars): @pytest.mark.live def test_resolve_query_returns_dict(self, sample_query, max_chars): - """Resolving a query should return a dict.""" + """ + Resolving a query should return a dict. + Args: + sample_query: Injected pytest fixture. + max_chars: Injected pytest fixture. + """ result = resolve(sample_query, max_chars=max_chars) assert isinstance(result, dict) assert "source" in result @@ -131,21 +141,35 @@ def test_resolve_query_returns_dict(self, sample_query, max_chars): @pytest.mark.live def test_resolve_url_content_not_empty(self, sample_url, max_chars): - """Resolved content should not be empty.""" + """ + Resolved content should not be empty. + Args: + sample_url: Injected pytest fixture. + max_chars: Injected pytest fixture. + """ result = resolve(sample_url, max_chars=max_chars) assert result.get("content") assert len(result["content"]) > 0 @pytest.mark.live def test_resolve_query_content_not_empty(self, sample_query, max_chars): - """Resolved query content should not be empty.""" + """ + Resolved query content should not be empty. + Args: + sample_query: Injected pytest fixture. + max_chars: Injected pytest fixture. + """ result = resolve(sample_query, max_chars=max_chars) assert result.get("content") assert len(result["content"]) > 0 @pytest.mark.live def test_resolve_respects_max_chars(self, sample_url): - """Resolved content should respect max_chars limit.""" + """ + Resolved content should respect max_chars limit. + Args: + sample_url: Injected pytest fixture. + """ small_max = 500 result = resolve(sample_url, max_chars=small_max) if result and "content" in result: @@ -153,7 +177,12 @@ def test_resolve_respects_max_chars(self, sample_url): @pytest.mark.live def test_resolve_includes_source(self, sample_url, max_chars): - """Resolved result should include source provider.""" + """ + Resolved result should include source provider. + Args: + sample_url: Injected pytest fixture. + max_chars: Injected pytest fixture. + """ result = resolve(sample_url, max_chars=max_chars) assert result.get("source") assert isinstance(result["source"], str) @@ -163,19 +192,31 @@ class TestResolveEdgeCases: """Tests for edge cases in resolve function.""" def test_resolve_empty_input(self, max_chars): - """Empty input should return a failure dict.""" + """ + Empty input should return a failure dict. + Args: + max_chars: Injected pytest fixture. + """ result = resolve("", max_chars=max_chars) assert isinstance(result, dict) assert result.get("source") == "none" def test_resolve_whitespace_input(self, max_chars): - """Whitespace-only input should return a failure dict.""" + """ + Whitespace-only input should return a failure dict. + Args: + max_chars: Injected pytest fixture. + """ result = resolve(" ", max_chars=max_chars) assert isinstance(result, dict) assert result.get("source") == "none" def test_resolve_none_input(self, max_chars): - """None input should return a failure dict.""" + """ + None input should return a failure dict. + Args: + max_chars: Injected pytest fixture. + """ result = resolve(None, max_chars=max_chars) assert isinstance(result, dict) assert result.get("source") == "none" @@ -199,7 +240,11 @@ def _isolate_state(self): @staticmethod def _make_quality(acceptable: bool = True): - """Build a QualityScore for the given acceptance flag.""" + """ + Build a QualityScore for the given acceptance flag. + Args: + acceptable: Injected pytest fixture. + """ from scripts.quality import QualityScore return QualityScore( @@ -215,7 +260,13 @@ def _make_quality(acceptable: bool = True): @patch("scripts.resolve.fetch_url_content") @patch("scripts.resolve.quality.score_content") def test_acceptable_resolved_result_yields_content(self, mock_score, mock_fetch, mock_plan): - """An acceptable ResolvedResult should be yielded as the first output.""" + """ + An acceptable ResolvedResult should be yielded as the first output. + Args: + mock_score: Injected pytest fixture. + mock_fetch: Injected pytest fixture. + mock_plan: Injected pytest fixture. + """ from scripts.models import Profile, ResolvedResult from scripts.resolve import resolve_url_stream @@ -233,7 +284,12 @@ def test_acceptable_resolved_result_yields_content(self, mock_score, mock_fetch, @patch("scripts.resolve.routing.plan_provider_order", return_value=["llms_txt"]) @patch("scripts.resolve.fetch_llms_txt") def test_llms_txt_yields_compacted_output(self, mock_llms, mock_plan): - """An llms.txt hit should yield compacted content regardless of quality.""" + """ + An llms.txt hit should yield compacted content regardless of quality. + Args: + mock_llms: Injected pytest fixture. + mock_plan: Injected pytest fixture. + """ from scripts.models import Profile from scripts.resolve import resolve_url_stream @@ -248,7 +304,13 @@ def test_llms_txt_yields_compacted_output(self, mock_llms, mock_plan): @patch("scripts.resolve.resolve_with_jina") @patch("scripts.resolve.quality.score_content") def test_thin_content_falls_through_to_failure(self, mock_score, mock_jina, mock_plan): - """Thin provider content should not be yielded; final result is 'none'.""" + """ + Thin provider content should not be yielded; final result is 'none'. + Args: + mock_score: Injected pytest fixture. + mock_jina: Injected pytest fixture. + mock_plan: Injected pytest fixture. + """ from scripts.models import Profile, ProviderMeta, ProviderResult from scripts.resolve import resolve_url_stream @@ -268,7 +330,11 @@ def test_thin_content_falls_through_to_failure(self, mock_score, mock_jina, mock @patch("scripts.resolve.resolve_with_docling") def test_special_document_uses_docling(self, mock_docling): - """A PDF URL should be resolved via docling without a provider cascade.""" + """ + A PDF URL should be resolved via docling without a provider cascade. + Args: + mock_docling: Injected pytest fixture. + """ from scripts.models import Profile, ProviderMeta, ProviderResult from scripts.resolve import resolve_url_stream @@ -287,7 +353,12 @@ def test_special_document_uses_docling(self, mock_docling): @patch("scripts.resolve.resolve_with_docling") @patch("scripts.resolve.routing.plan_provider_order", return_value=[]) def test_special_document_failure_falls_through(self, mock_plan, mock_docling): - """A failed docling attempt should fall through to the regular failure result.""" + """ + A failed docling attempt should fall through to the regular failure result. + Args: + mock_plan: Injected pytest fixture. + mock_docling: Injected pytest fixture. + """ from scripts.models import Profile, ProviderMeta, ProviderResult from scripts.resolve import resolve_url_stream @@ -308,7 +379,12 @@ class TestResolveQuality: @pytest.mark.live def test_resolved_content_above_min_chars(self, sample_url, max_chars): - """Resolved content should typically be above MIN_CHARS.""" + """ + Resolved content should typically be above MIN_CHARS. + Args: + sample_url: Injected pytest fixture. + max_chars: Injected pytest fixture. + """ result = resolve(sample_url, max_chars=max_chars) if result and "content" in result: # Most successful resolutions should exceed MIN_CHARS @@ -316,7 +392,12 @@ def test_resolved_content_above_min_chars(self, sample_url, max_chars): @pytest.mark.live def test_resolved_content_has_structure(self, sample_query, max_chars): - """Resolved content should have some markdown structure.""" + """ + Resolved content should have some markdown structure. + Args: + sample_query: Injected pytest fixture. + max_chars: Injected pytest fixture. + """ result = resolve(sample_query, max_chars=max_chars) if result and "content" in result and len(result["content"]) > 100: content = result["content"] From 8fdb9004a63e6c2ec84c231ca5bea373d2d52137 Mon Sep 17 00:00:00 2001 From: d-oit <6849456+d-oit@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:54:09 +0200 Subject: [PATCH 05/15] chore(ci): scope Python doc-coverage gate to public API via skip_doc_coverage --- .deepsource.toml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.deepsource.toml b/.deepsource.toml index 7e5a305a..6eef870c 100644 --- a/.deepsource.toml +++ b/.deepsource.toml @@ -103,6 +103,18 @@ enabled = true pattern = "JS-C1003" skip = true +[[analyzers]] +name = "python" +enabled = true + + [analyzers.meta] + # Agent skills (.agents/) and repo scripts (scripts/) are already excluded + # from analysis via exclude_patterns. Keep the doc-coverage gate scoped to + # public API documentation: skip module/magic/init/class/nonpublic artifacts + # so undocumented private helpers don't drag the DCV down (mirrors the JS + # analyzer's skip_doc_coverage policy). + skip_doc_coverage = ["module", "magic", "init", "class", "nonpublic"] + [[transformers]] name = "eslint" enabled = true From dbf25a17abfeb66da25532a6501785ace19ff48e Mon Sep 17 00:00:00 2001 From: d-oit <6849456+d-oit@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:58:06 +0200 Subject: [PATCH 06/15] docs(plans): record DeepSource Python gate fix and PR 625 staleness handling --- ...11-owlwatch-issues-pr624-625-2026-08-09.md | 22 +++++++++++++++++-- worklog.md | 1 + 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/plans/111-owlwatch-issues-pr624-625-2026-08-09.md b/plans/111-owlwatch-issues-pr624-625-2026-08-09.md index bfea7cc6..7049fc46 100644 --- a/plans/111-owlwatch-issues-pr624-625-2026-08-09.md +++ b/plans/111-owlwatch-issues-pr624-625-2026-08-09.md @@ -50,11 +50,29 @@ Attempted `eslint@10.8.1`. **Blocked upstream**: latest `eslint-plugin-react@7.3 - This plan documents all changes and the reconciliation. - `worklog.md` updated with a session entry. +### Phase 7 — DeepSource Documentation Coverage deep dive + Python gate fix (2026-08-09, follow-up session) + +The Documentation Coverage metric kept failing both PRs despite full module-level TSDoc/docstrings: +- **PR #624 (JS)**: 2.2% vs 74.3% baseline. Empirical investigation (per-file embedded metrics, artifact-model calibration scripts against the whole `src/` tree) showed the metric counts granular artifacts (params, properties, local consts, object keys) with opaque semantics — existing baseline files (`export/encrypt.ts`, 29 functions) pass with **zero** `@param` tags, while fully-documented new files score 2.2%. The JS `skip_doc_coverage` list is already at its documented max (function/class/method artifact types), so no in-repo config lever remains for JS. +- **PR #626 (Python)**: 33.5% vs 64.7% baseline. The Python analyzer was auto-detected (no `[[analyzers]] name="python"` section) and analyzed `.agents/` despite `exclude_patterns`. Python's `skip_doc_coverage` defaults to `["module", "magic", "init"]` — private helpers, class docstrings, and parameters all counted. + +**Fix applied (user-approved config change, PR #626)**: added a `[[analyzers]] name = "python"` section with `skip_doc_coverage = ["module", "magic", "init", "class", "nonpublic"]`, scoping the gate to public-API documentation (mirrors the JS analyzer's existing policy). Also: +- Converted all 4 `logger.info(f"...")` calls to lazy `%s` formatting (fixes PYL-W1203 flagged in the diff). +- Added Google-style `Args:` sections to all 24 test methods with fixture params. +- Result: **DeepSource Python on PR #626 PASSES** ("No blocking issues or failing metrics found"). + +**PR #624 (JS) gate status**: no valid in-repo config lever remains (all 6 skipable JS artifact types already skipped). Options: (a) relax/disable the Documentation Coverage PR gate in the DeepSource dashboard (recommended — metric semantics are inconsistent with the repo's doc conventions), (b) document every granular artifact in the diff (~400+ comments on mocks/object keys — noisy, uncertain), or (c) merge with documented exception. The `.deepsource.toml` Python section lands on the `main` merge path via PR #626, which also benefits #624's Python gate. + +### Phase 8 — PR #625 close/reopen + final state +PR #625 (dompurify) had all checks green, threads resolved, auto-merge armed, up to date with `main`, approvals required = 0 — yet `mergeStateStatus: BLOCKED` persisted through re-arm + empty-commit push. Per Plan 098 (GitHub merge-state staleness), performed close/reopen + re-armed protected squash auto-merge (`gh pr merge 625 --auto --squash --delete-branch`). Still reports BLOCKED — consistent with the documented stale-mergeability-cache symptom (PRs #583/#584 merged on GitHub's cache refresh, no `--admin` bypass used). + ## Success Criteria -- [x] PR #624: all DeepSource/OwlWatch/maintainer review comments addressed at source; pushed; CI re-running -- [x] PR #625: OwlWatch override comment resolved (code fix + thread reply); auto-merge re-armed +- [x] PR #624: all DeepSource/OwlWatch/maintainer review comments addressed at source; pushed; CI re-running (JS doc-coverage gate: dashboard decision pending, see Phase 7) +- [x] PR #625: OwlWatch override comment resolved (code fix + thread reply); close/reopen + auto-merge re-armed; waiting on GitHub cache refresh - [x] Issue #621: `resolve_url_stream` refactored; lizard metrics halved; tests added; suite green - [x] Issue #622: subprocess inputs SSRF-validated; tests added; suite green - [x] Issue #620: upgrade attempted, blocked upstream, documented with evidence, closed - [x] Issue #623: tracked to PR #624 merge (no independent code impact) - [x] New PR created for the issue/plans work +- [x] PR #626 DeepSource gates: Python + JavaScript green (config + doc fixes); Codacy green; unit tests pass (187 local) +- [x] `.deepsource.toml`: Python analyzer section added (skip_doc_coverage scoped to public API) — user-approved diff --git a/worklog.md b/worklog.md index 585acfff..affe72dc 100644 --- a/worklog.md +++ b/worklog.md @@ -12,6 +12,7 @@ Work Log (2026-08-09): - Issue #621: `resolve_url_stream` refactored 230→115 LOC / ccn 54→22 via 4 helpers; 5 mock-based cascade tests; hardened a flaky rate-limit test; 187 Python tests pass ×2; ruff clean. - Issue #620 (eslint 10): blocked upstream — `eslint-plugin-react@7.37.5` and `eslint-plugin-jsx-a11y@6.10.2` peers cap at eslint 9 and crash on 10; reverted to ^9, documented in Plan 111. - Plan 111 created; see `plans/111-owlwatch-issues-pr624-625-2026-08-09.md`. +- Follow-up (DeepSource doc-coverage gates): investigated the Documentation Coverage metric empirically (per-file embedded metrics + artifact-model calibration) — it counts granular artifacts (params/properties/object keys) with opaque semantics; JS skip list is at its documented max; Python analyzer was auto-detected and analyzed `.agents/` despite `exclude_patterns`. Fix on PR #626: added `[[analyzers]] name="python"` with `skip_doc_coverage = ["module","magic","init","class","nonpublic"]` (user-approved), converted 4 f-string loggers to lazy `%s` (PYL-W1203), added `Args:` to 24 test methods. DeepSource Python on #626 now PASSES. PR #624's JS gate needs a dashboard decision (documented in Plan 111 Phase 7). PR #625: close/reopen + re-armed auto-merge per Plan 098 staleness workflow. --- Task ID: redesign-1 From 99049316b488f2034c7abf0740b5748c9916c0b0 Mon Sep 17 00:00:00 2001 From: d-oit <6849456+d-oit@users.noreply.github.com> Date: Sun, 9 Aug 2026 16:06:51 +0200 Subject: [PATCH 07/15] fix(owlwatch): yield string probe results and reduce cascade complexity --- .../do-web-doc-resolver/scripts/resolve.py | 506 ++++++++++++------ .../do-web-doc-resolver/tests/test_resolve.py | 23 + 2 files changed, 377 insertions(+), 152 deletions(-) diff --git a/.agents/skills/do-web-doc-resolver/scripts/resolve.py b/.agents/skills/do-web-doc-resolver/scripts/resolve.py index 9a5a8a19..7b81f835 100644 --- a/.agents/skills/do-web-doc-resolver/scripts/resolve.py +++ b/.agents/skills/do-web-doc-resolver/scripts/resolve.py @@ -374,6 +374,45 @@ def _build_probe_output( _record_probe_success( p_name_done, pt_done, latency, metrics, trace, start_time, domain, q_score ) + out = _build_success_output( + res_or_content, p_name_done, pt_done, url, content, max_chars, metrics, trace, q_score + ) + return out, True + + +def _build_success_output( + res_or_content: Any, + p_name_done: str, + pt_done: ProviderType, + url: str, + content: str, + max_chars: int, + metrics: ResolveMetrics, + trace: ResolutionTrace | None, + q_score: Any, +) -> dict[str, Any]: + """Build the yielded result dict for an accepted probe result. + + llms.txt probes yield a compacted text dict; ResolvedResult objects yield + their serialized form; anything else (plain strings, ProviderResult) + yields a generic result dict keyed by the provider name. Previously the + generic case returned ``(None, True)`` — the probe was recorded as a + success but the result was dropped, failing the resolution. + + Args: + res_or_content: The provider result object or raw content. + p_name_done: Provider name that completed. + pt_done: Provider type that completed. + url: The URL being resolved. + content: Normalized probe content. + max_chars: Maximum content length to retain. + metrics: Metrics accumulator. + trace: Optional trace to attach. + q_score: Quality score of the content. + + Returns: + The result dict to yield. + """ if pt_done == ProviderType.LLMS_TXT: out: dict[str, Any] = { "source": "llms.txt", @@ -381,16 +420,19 @@ def _build_probe_output( "content": compact_content(content, max_chars), "metrics": metrics, } - if trace: - out["trace"] = trace.to_dict() - return out, True - if isinstance(res_or_content, ResolvedResult): + elif isinstance(res_or_content, ResolvedResult): res_or_content.metrics, res_or_content.score = metrics, q_score.score out = res_or_content.to_dict() - if trace: - out["trace"] = trace.to_dict() - return out, True - return None, True + else: + out = { + "source": p_name_done, + "url": url, + "content": compact_content(content, max_chars), + "metrics": metrics, + } + if trace: + out["trace"] = trace.to_dict() + return out def _process_probe_result( @@ -557,6 +599,101 @@ def _drain_completed_probes( return None +def _url_cascade(url: str, max_chars: int) -> dict[str, tuple[ProviderType, Callable[[], Any]]]: + """Build the URL provider cascade: provider name → (type, zero-arg launcher). + + Args: + url: The URL being resolved. + max_chars: Maximum content length to retain. + + Returns: + Mapping of provider name to (ProviderType, probe callable). + """ + return { + "llms_txt": (ProviderType.LLMS_TXT, lambda: fetch_llms_txt(url)), + "jina": (ProviderType.JINA, lambda: resolve_with_jina(url, max_chars)), + "firecrawl": (ProviderType.FIRECRAWL, lambda: resolve_with_firecrawl(url, max_chars)), + "direct_fetch": ( + ProviderType.DIRECT_FETCH, + lambda: fetch_url_content(url, max_chars=max_chars), + ), + "mistral_browser": ( + ProviderType.MISTRAL_BROWSER, + lambda: resolve_with_mistral_browser(url, max_chars), + ), + "duckduckgo": (ProviderType.DUCKDUCKGO, lambda: resolve_with_duckduckgo(url, max_chars)), + } + + +def _probe_round( + i: int, + p_name: str, + pt: ProviderType, + func: Callable[[], Any], + eligible: list[str], + budget: routing.ResolutionBudget, + cache: Any, + url: str, + executor: concurrent.futures.ThreadPoolExecutor, + active_futures: dict[concurrent.futures.Future[Any], tuple[str, ProviderType, float]], + domain: str, + max_chars: int, + metrics: ResolveMetrics, + trace: ResolutionTrace | None, + start_time: float, +) -> tuple[bool, dict[str, Any] | None]: + """Launch one URL probe and drain completed futures. + + Returns ``(stop, output)``: ``stop`` halts the cascade, ``output`` is the + result to yield (or None when nothing completed acceptably yet). + + Args: + i: Index of the current provider in the eligible list. + p_name: Provider name to probe. + pt: Provider type to probe. + func: Zero-argument probe callable. + eligible: Ordered provider list for the cascade. + budget: Resolution budget tracker. + cache: Cache handle for negative-cache lookups. + url: The URL being resolved. + executor: Thread pool used to launch probes. + active_futures: Map of in-flight futures to probe metadata. + domain: Extracted domain used for routing-memory keys. + max_chars: Maximum content length to retain. + metrics: Metrics accumulator. + trace: Optional trace to populate. + start_time: Epoch seconds when the resolution started. + + Returns: + (stop flag, output dict or None). + """ + future, stop = _launch_url_probe(p_name, pt, func, budget, cache, url, executor) + if stop: + return True, None + if future is None: + return False, None + logger.info("Starting probe: %s", p_name) + start_time_probe = time.time() + active_futures[future] = (p_name, pt, start_time_probe) + threshold = _routing_memory.get_p75_latency(domain or "any", p_name) / 1000.0 + out = _drain_completed_probes( + active_futures, + i, + eligible, + p_name, + threshold, + start_time_probe, + budget, + url, + max_chars, + metrics, + trace, + start_time, + domain, + ) + return False, out + + def resolve_url_stream( url: str, max_chars: int = MAX_CHARS, profile: Profile = Profile.BALANCED, trace: ResolutionTrace | None = None, @@ -595,20 +732,7 @@ def resolve_url_stream( provider_names = routing.plan_provider_order( target=url, is_url=True, routing_memory=_routing_memory ) - cascade_map: dict[str, tuple[ProviderType, Any]] = { - "llms_txt": (ProviderType.LLMS_TXT, lambda: fetch_llms_txt(url)), - "jina": (ProviderType.JINA, lambda: resolve_with_jina(url, max_chars)), - "firecrawl": (ProviderType.FIRECRAWL, lambda: resolve_with_firecrawl(url, max_chars)), - "direct_fetch": ( - ProviderType.DIRECT_FETCH, - lambda: fetch_url_content(url, max_chars=max_chars), - ), - "mistral_browser": ( - ProviderType.MISTRAL_BROWSER, - lambda: resolve_with_mistral_browser(url, max_chars), - ), - "duckduckgo": (ProviderType.DUCKDUCKGO, lambda: resolve_with_duckduckgo(url, max_chars)), - } + cascade_map = _url_cascade(url, max_chars) cache = _get_cache() domain = routing.extract_domain(url) @@ -619,32 +743,12 @@ def resolve_url_stream( try: for i, p_name in enumerate(eligible): pt, func = cascade_map[p_name] - future, stop = _launch_url_probe(p_name, pt, func, budget, cache, url, executor) + stop, out = _probe_round( + i, p_name, pt, func, eligible, budget, cache, url, executor, + active_futures, domain, max_chars, metrics, trace, start_time, + ) if stop: break - if future is None: - continue - - logger.info("Starting probe: %s", p_name) - start_time_probe = time.time() - active_futures[future] = (p_name, pt, start_time_probe) - threshold = _routing_memory.get_p75_latency(domain or "any", p_name) / 1000.0 - - out = _drain_completed_probes( - active_futures, - i, - eligible, - p_name, - threshold, - start_time_probe, - budget, - url, - max_chars, - metrics, - trace, - start_time, - domain, - ) if out is not None: yield out finally: @@ -687,6 +791,192 @@ def resolve_query( return {"source": "none", "query": query, "content": "Failed"} +# Provider cascade for search queries: name → (type, (query, max_chars) callable). +_QUERY_CASCADE: dict[str, tuple[ProviderType, Callable[[str, int], Any]]] = { + "exa_mcp": (ProviderType.EXA_MCP, resolve_with_exa_mcp), + "exa": (ProviderType.EXA, resolve_with_exa), + "tavily": (ProviderType.TAVILY, resolve_with_tavily), + "duckduckgo": (ProviderType.DUCKDUCKGO, resolve_with_duckduckgo), + "mistral_websearch": (ProviderType.MISTRAL_WEBSEARCH, resolve_with_mistral_websearch), +} + + +def _build_query_output( + res: Any, + p_name_done: str, + metrics: ResolveMetrics, + q_score: Any, + trace: ResolutionTrace | None, + start_time: float, +) -> dict[str, Any]: + """Build the yielded dict for an accepted query result. + + ProviderResult objects are normalized into ResolvedResult so consumers get + a uniform ``source``/``url``/``query`` shape; ResolvedResult passes through. + + Args: + res: The provider result (ProviderResult or ResolvedResult). + p_name_done: Provider name that completed. + metrics: Metrics accumulator. + q_score: Quality score of the content. + trace: Optional trace to attach. + start_time: Epoch seconds when the resolution started. + + Returns: + The serialized result dict. + """ + if isinstance(res, ProviderResult): + result = ResolvedResult( + source=res.source, + content=res.content or "", + url=res.url, + query=res.query, + ) + result.meta = res.meta + result.metrics, result.score = metrics, q_score.score + out = result.to_dict() + else: + res.metrics, res.score = metrics, q_score.score + out = res.to_dict() + if trace: + trace.total_latency_ms = int((time.time() - start_time) * 1000) + trace.final_source = p_name_done + trace.final_score = q_score.score + trace.success = True + out["trace"] = trace.to_dict() + return out + + +def _process_query_result( + future: concurrent.futures.Future[Any], + active_futures: dict[concurrent.futures.Future[Any], tuple[str, ProviderType, float]], + budget: routing.ResolutionBudget, + query: str, + metrics: ResolveMetrics, + trace: ResolutionTrace | None, + start_time: float, +) -> tuple[dict[str, Any] | None, bool]: + """Process one completed query probe. + + Records budget/circuit/metrics outcomes, gates on content quality, and + returns ``(output, acceptable)``; ``acceptable`` tells the caller to stop. + + Args: + future: The completed provider future. + active_futures: Map of in-flight futures to probe metadata. + budget: Resolution budget tracker. + query: The search query being resolved. + metrics: Metrics accumulator. + trace: Optional trace to populate. + start_time: Epoch seconds when the resolution started. + + Returns: + (output dict to yield, acceptable flag). + """ + p_name_done, pt_done, s_time = active_futures.pop(future) + latency = int((time.time() - s_time) * 1000) + budget.record_attempt(is_paid=pt_done.is_paid(), latency_ms=latency) + try: + res = future.result() + except Exception as e: + err_type = _detect_error_type(e) + if err_type not in (ErrorType.AUTH_ERROR, ErrorType.SSRF_BLOCKED): + _circuit_breakers.record_failure(p_name_done) + if trace: + step = TraceStep( + tool=p_name_done, duration_ms=latency, success=False, error=str(e) + ) + trace.steps.append(step) + metrics.record_provider(pt_done, latency, False) + return None, False + if not res: + _circuit_breakers.record_failure(p_name_done) + metrics.record_provider(pt_done, latency, False) + return None, False + if isinstance(res, ProviderResult): + if not res.ok: + _circuit_breakers.record_failure(p_name_done) + if trace: + step = TraceStep( + tool=p_name_done, duration_ms=latency, success=False, error=res.error + ) + trace.steps.append(step) + metrics.record_provider(pt_done, latency, False) + return None, False + content = res.content or "" + else: + content = res.content + q_score = quality.score_content(content) + if not q_score.acceptable: + cache_negative.write_negative_cache(_get_cache(), query, p_name_done, "thin_content", 1800) + _routing_memory.record("query", p_name_done, False, latency, q_score.score) + return None, False + _circuit_breakers.record_success(p_name_done) + metrics.record_provider(pt_done, latency, True) + _routing_memory.record("query", p_name_done, True, latency, q_score.score) + out = _build_query_output(res, p_name_done, metrics, q_score, trace, start_time) + return out, True + + +def _drain_query_probes( + active_futures: dict[concurrent.futures.Future[Any], tuple[str, ProviderType, float]], + i: int, + eligible: list[str], + p_name: str, + threshold: float, + start_time_probe: float, + budget: routing.ResolutionBudget, + query: str, + metrics: ResolveMetrics, + trace: ResolutionTrace | None, + start_time: float, +) -> dict[str, Any] | None: + """Wait for query probe completions until an acceptable result appears. + + Returns the output dict to yield, or None when the batch is exhausted + without an acceptable result. + + Args: + active_futures: Map of in-flight futures to probe metadata. + i: Index of the current provider in the eligible list. + eligible: Ordered provider list for the cascade. + p_name: Provider name that is currently probing. + threshold: Hedging threshold in seconds. + start_time_probe: Epoch seconds when the current probe launched. + budget: Resolution budget tracker. + query: The search query being resolved. + metrics: Metrics accumulator. + trace: Optional trace to populate. + start_time: Epoch seconds when the resolution started. + + Returns: + The result dict to yield, or None. + """ + while active_futures: + elapsed = time.time() - start_time_probe + if i < len(eligible) - 1 and elapsed >= threshold: + break + + done, _ = concurrent.futures.wait( + active_futures.keys(), + timeout=0.01, + return_when=concurrent.futures.FIRST_COMPLETED, + ) + for f in list(done): + if f not in active_futures: + continue + out, acceptable = _process_query_result( + f, active_futures, budget, query, metrics, trace, start_time + ) + if acceptable: + return out + if done: + break + if not active_futures: + break + return None + + def resolve_query_stream( query: str, max_chars: int = MAX_CHARS, @@ -719,20 +1009,13 @@ def resolve_query_stream( provider_names = routing.plan_provider_order( target=query, is_url=False, skip_providers=skip, routing_memory=_routing_memory ) - cascade_map = { - "exa_mcp": (ProviderType.EXA_MCP, resolve_with_exa_mcp), - "exa": (ProviderType.EXA, resolve_with_exa), - "tavily": (ProviderType.TAVILY, resolve_with_tavily), - "duckduckgo": (ProviderType.DUCKDUCKGO, resolve_with_duckduckgo), - "mistral_websearch": (ProviderType.MISTRAL_WEBSEARCH, resolve_with_mistral_websearch), - } cache = _get_cache() - eligible = [p for p in provider_names if p in cascade_map] + eligible = [p for p in provider_names if p in _QUERY_CASCADE] active_futures = {} executor = concurrent.futures.ThreadPoolExecutor(max_workers=max(1, len(eligible))) try: for i, p_name in enumerate(eligible): - pt, func = cascade_map[p_name] + pt, func = _QUERY_CASCADE[p_name] if not budget.can_try(is_paid=pt.is_paid()): if budget.stop_reason in ("paid_disabled", "max_paid_attempts"): continue @@ -746,103 +1029,22 @@ def resolve_query_stream( future = executor.submit(func, query, max_chars) active_futures[future] = (p_name, pt, start_time_probe) threshold = _routing_memory.get_p75_latency("query", p_name) / 1000.0 - while active_futures: - elapsed = time.time() - start_time_probe - if i < len(eligible) - 1 and elapsed >= threshold: - break - - done, _ = concurrent.futures.wait( - active_futures.keys(), - timeout=0.01, - return_when=concurrent.futures.FIRST_COMPLETED, - ) - found_acceptable = False - for f in list(done): - if f not in active_futures: - continue - p_name_done, pt_done, s_time = active_futures.pop(f) - latency = int((time.time() - s_time) * 1000) - budget.record_attempt(is_paid=pt_done.is_paid(), latency_ms=latency) - try: - res = f.result() - except Exception as e: - err_type = _detect_error_type(e) - if err_type not in (ErrorType.AUTH_ERROR, ErrorType.SSRF_BLOCKED): - _circuit_breakers.record_failure(p_name_done) - if trace: - step = TraceStep( - tool=p_name_done, - duration_ms=latency, - success=False, - error=str(e), - ) - trace.steps.append(step) - metrics.record_provider(pt_done, latency, False) - continue - if res: - if isinstance(res, ProviderResult): - if not res.ok: - _circuit_breakers.record_failure(p_name_done) - if trace: - step = TraceStep( - tool=p_name_done, - duration_ms=latency, - success=False, - error=res.error, - ) - trace.steps.append(step) - metrics.record_provider(pt_done, latency, False) - continue - content = res.content or "" - else: - content = res.content - q_score = quality.score_content(content) - if q_score.acceptable: - _circuit_breakers.record_success(p_name_done) - metrics.record_provider(pt_done, latency, True) - _routing_memory.record( - "query", p_name_done, True, latency, q_score.score - ) - - found_acceptable = True - if isinstance(res, ProviderResult): - result = ResolvedResult( - source=res.source, - content=res.content or "", - url=res.url, - query=res.query, - ) - result.meta = res.meta - result.metrics, result.score = metrics, q_score.score - out = result.to_dict() - else: - res.metrics, res.score = metrics, q_score.score - out = res.to_dict() - if trace: - trace.total_latency_ms = int((time.time() - start_time) * 1000) - trace.final_source = p_name_done - trace.final_score = q_score.score - trace.success = True - out["trace"] = trace.to_dict() - yield out - break - else: - cache_negative.write_negative_cache( - cache, query, p_name_done, "thin_content", 1800 - ) - _routing_memory.record( - "query", p_name_done, False, latency, q_score.score - ) - else: - _circuit_breakers.record_failure(p_name_done) - metrics.record_provider(pt_done, latency, False) - - if found_acceptable: - return - if done: - break - if not active_futures: - break + out = _drain_query_probes( + active_futures, + i, + eligible, + p_name, + threshold, + start_time_probe, + budget, + query, + metrics, + trace, + start_time, + ) + if out is not None: + yield out + return finally: executor.shutdown(wait=False, cancel_futures=True) diff --git a/.agents/skills/do-web-doc-resolver/tests/test_resolve.py b/.agents/skills/do-web-doc-resolver/tests/test_resolve.py index 3dbbbfb0..cf41fcfe 100644 --- a/.agents/skills/do-web-doc-resolver/tests/test_resolve.py +++ b/.agents/skills/do-web-doc-resolver/tests/test_resolve.py @@ -281,6 +281,29 @@ def test_acceptable_resolved_result_yields_content(self, mock_score, mock_fetch, assert first["url"] == "https://example.com" assert len(first["content"]) >= 600 + @patch("scripts.resolve.routing.plan_provider_order", return_value=["direct_fetch"]) + @patch("scripts.resolve.fetch_url_content") + @patch("scripts.resolve.quality.score_content") + def test_string_result_yields_content(self, mock_score, mock_fetch, mock_plan): + """ + A plain-string probe result should be yielded, not silently dropped. + Args: + mock_score: Injected pytest fixture. + mock_fetch: Injected pytest fixture. + mock_plan: Injected pytest fixture. + """ + from scripts.models import Profile + from scripts.resolve import resolve_url_stream + + mock_score.return_value = self._make_quality(acceptable=True) + mock_fetch.return_value = "B" * 600 # fetch_url_content returns a plain str + results = list(resolve_url_stream("https://example.com", profile=Profile.FAST)) + assert results + first = results[0] + assert first["source"] == "direct_fetch" + assert first["url"] == "https://example.com" + assert len(first["content"]) >= 600 + @patch("scripts.resolve.routing.plan_provider_order", return_value=["llms_txt"]) @patch("scripts.resolve.fetch_llms_txt") def test_llms_txt_yields_compacted_output(self, mock_llms, mock_plan): From 72c7efb12e6a59c860dff77575899760b9b0b2a5 Mon Sep 17 00:00:00 2001 From: d-oit <6849456+d-oit@users.noreply.github.com> Date: Sun, 9 Aug 2026 16:16:32 +0200 Subject: [PATCH 08/15] fix(owlwatch): remove unused p_name param from _drain_query_probes --- .agents/skills/do-web-doc-resolver/scripts/resolve.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/.agents/skills/do-web-doc-resolver/scripts/resolve.py b/.agents/skills/do-web-doc-resolver/scripts/resolve.py index 7b81f835..ca3f6cca 100644 --- a/.agents/skills/do-web-doc-resolver/scripts/resolve.py +++ b/.agents/skills/do-web-doc-resolver/scripts/resolve.py @@ -922,7 +922,6 @@ def _drain_query_probes( active_futures: dict[concurrent.futures.Future[Any], tuple[str, ProviderType, float]], i: int, eligible: list[str], - p_name: str, threshold: float, start_time_probe: float, budget: routing.ResolutionBudget, @@ -940,7 +939,6 @@ def _drain_query_probes( active_futures: Map of in-flight futures to probe metadata. i: Index of the current provider in the eligible list. eligible: Ordered provider list for the cascade. - p_name: Provider name that is currently probing. threshold: Hedging threshold in seconds. start_time_probe: Epoch seconds when the current probe launched. budget: Resolution budget tracker. @@ -1033,7 +1031,6 @@ def resolve_query_stream( active_futures, i, eligible, - p_name, threshold, start_time_probe, budget, From 5de6ecf9396caf7994c58503bbbc73ed4e7a466b Mon Sep 17 00:00:00 2001 From: d-oit <6849456+d-oit@users.noreply.github.com> Date: Sun, 9 Aug 2026 16:18:59 +0200 Subject: [PATCH 09/15] docs(plans): record PR 626 thread resolutions and OwlWatch HIGH fix --- plans/111-owlwatch-issues-pr624-625-2026-08-09.md | 11 +++++++++++ worklog.md | 3 ++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/plans/111-owlwatch-issues-pr624-625-2026-08-09.md b/plans/111-owlwatch-issues-pr624-625-2026-08-09.md index 7049fc46..2d5fe82c 100644 --- a/plans/111-owlwatch-issues-pr624-625-2026-08-09.md +++ b/plans/111-owlwatch-issues-pr624-625-2026-08-09.md @@ -66,6 +66,17 @@ The Documentation Coverage metric kept failing both PRs despite full module-leve ### Phase 8 — PR #625 close/reopen + final state PR #625 (dompurify) had all checks green, threads resolved, auto-merge armed, up to date with `main`, approvals required = 0 — yet `mergeStateStatus: BLOCKED` persisted through re-arm + empty-commit push. Per Plan 098 (GitHub merge-state staleness), performed close/reopen + re-armed protected squash auto-merge (`gh pr merge 625 --auto --squash --delete-branch`). Still reports BLOCKED — consistent with the documented stale-mergeability-cache symptom (PRs #583/#584 merged on GitHub's cache refresh, no `--admin` bypass used). +### Phase 9 — PR #626 review threads (all 8 resolved) + OwlWatch HIGH bug fix +PR #626 carried 8 unresolved review threads (ruleset requires thread resolution): 3 DeepSource minors (complexity on `_build_probe_output`/`resolve_url_stream`, lazy-logging) + 5 OwlWatch (complexity/length on `_build_probe_output`, `resolve_url_stream`, `resolve_query_stream`, and a **HIGH tracker: string-based probe results were dropped — `_build_probe_output` returned `(None, True)` for plain-string/ProviderResult successes, failing the resolution despite success**). + +Fixes on `feat/remediate-owlwatch-620-622` (commit `9904931`, then `72c7efb`): +- **HIGH bug**: new `_build_success_output` helper yields a generic result dict (`source`/`url`/`content`/`metrics`) for string/ProviderResult successes instead of dropping them — this is the real `direct_fetch` path (`fetch_url_content` returns a plain str). New regression test `test_string_result_yields_content`. 188 Python tests pass. +- **Complexity/length**: `_build_probe_output` ccn 16→9; `resolve_url_stream` 115→72 lines / ccn 10 (via `_url_cascade`, `_probe_round`); `resolve_query_stream` 160→83 lines / ccn 32→13 (via `_process_query_result`, `_build_query_output`, `_drain_query_probes`); lazy `%s` logging (a9b0812). +- **PYL-W0613**: unused `p_name` param removed from `_drain_query_probes` (72c7efb) — the only blocking issue on the refactor run (Major). +- All 8 threads replied-to + resolved via GraphQL. + +**Key learning**: with the Python analyzer now explicitly listed in `.deepsource.toml` (Phase 7), the Documentation Coverage metric became informational on PR #626 (run 27a2813b passed at 33.5% vs 64.7%); blocking issues (Major PYL-W0613) are what fail the gate now. + ## Success Criteria - [x] PR #624: all DeepSource/OwlWatch/maintainer review comments addressed at source; pushed; CI re-running (JS doc-coverage gate: dashboard decision pending, see Phase 7) - [x] PR #625: OwlWatch override comment resolved (code fix + thread reply); close/reopen + auto-merge re-armed; waiting on GitHub cache refresh diff --git a/worklog.md b/worklog.md index affe72dc..b9b048fb 100644 --- a/worklog.md +++ b/worklog.md @@ -12,7 +12,8 @@ Work Log (2026-08-09): - Issue #621: `resolve_url_stream` refactored 230→115 LOC / ccn 54→22 via 4 helpers; 5 mock-based cascade tests; hardened a flaky rate-limit test; 187 Python tests pass ×2; ruff clean. - Issue #620 (eslint 10): blocked upstream — `eslint-plugin-react@7.37.5` and `eslint-plugin-jsx-a11y@6.10.2` peers cap at eslint 9 and crash on 10; reverted to ^9, documented in Plan 111. - Plan 111 created; see `plans/111-owlwatch-issues-pr624-625-2026-08-09.md`. -- Follow-up (DeepSource doc-coverage gates): investigated the Documentation Coverage metric empirically (per-file embedded metrics + artifact-model calibration) — it counts granular artifacts (params/properties/object keys) with opaque semantics; JS skip list is at its documented max; Python analyzer was auto-detected and analyzed `.agents/` despite `exclude_patterns`. Fix on PR #626: added `[[analyzers]] name="python"` with `skip_doc_coverage = ["module","magic","init","class","nonpublic"]` (user-approved), converted 4 f-string loggers to lazy `%s` (PYL-W1203), added `Args:` to 24 test methods. DeepSource Python on #626 now PASSES. PR #624's JS gate needs a dashboard decision (documented in Plan 111 Phase 7). PR #625: close/reopen + re-armed auto-merge per Plan 098 staleness workflow. +- Follow-up (DeepSource doc-coverage gates): investigated the Documentation Coverage metric empirically (per-file embedded metrics + artifact-model calibration) — it counts granular artifacts (params/properties/object keys) with opaque semantics; JS skip list is at its documented max; Python analyzer was auto-detected and analyzed `.agents/` despite `exclude_patterns`. Fix on PR #626: added `[[analyzers]] name="python"` with `skip_doc_coverage = ["module","magic","init","class","nonpublic"]` (user-approved), converted 4 f-string loggers to lazy `%s` (PYL-W1203), added `Args:` to 24 test methods. With the Python analyzer explicitly listed, the doc-coverage metric became informational — blocking issues (PYL-W0613 Major) are what fail the gate. PR #624's JS gate still needs a dashboard decision (documented in Plan 111 Phase 7). PR #625: close/reopen + re-armed auto-merge per Plan 098 staleness workflow. +- PR #626 review threads: resolved all 8 (3 DeepSource minors + 5 OwlWatch). Fixed the OwlWatch HIGH bug — string-based probe results were dropped (`(None, True)`), now yielded via `_build_success_output`; regression test added. Reduced complexity: `_build_probe_output` 16→9 ccn, `resolve_url_stream` 115→72 lines/ccn 10, `resolve_query_stream` 160→83 lines/ccn 32→13. Removed unused `p_name` (PYL-W0613). 188 Python tests pass; commits 9904931 + 72c7efb. --- Task ID: redesign-1 From d4f5720460dd39ee4883399bdd5746aa52738664 Mon Sep 17 00:00:00 2001 From: d-oit <6849456+d-oit@users.noreply.github.com> Date: Sun, 9 Aug 2026 16:23:05 +0200 Subject: [PATCH 10/15] refactor(owlwatch): type q_score, hoist query cascade const, add ProviderResult test --- .../do-web-doc-resolver/scripts/resolve.py | 23 ++++++++--------- .../do-web-doc-resolver/tests/test_resolve.py | 25 +++++++++++++++++++ 2 files changed, 36 insertions(+), 12 deletions(-) diff --git a/.agents/skills/do-web-doc-resolver/scripts/resolve.py b/.agents/skills/do-web-doc-resolver/scripts/resolve.py index ca3f6cca..ca97ebb0 100644 --- a/.agents/skills/do-web-doc-resolver/scripts/resolve.py +++ b/.agents/skills/do-web-doc-resolver/scripts/resolve.py @@ -80,6 +80,15 @@ is_rate_limited = _is_rate_limited set_rate_limit = _set_rate_limit +# Provider cascade for search queries: name → (type, (query, max_chars) callable). +_QUERY_CASCADE: dict[str, tuple[ProviderType, Callable[[str, int], Any]]] = { + "exa_mcp": (ProviderType.EXA_MCP, resolve_with_exa_mcp), + "exa": (ProviderType.EXA, resolve_with_exa), + "tavily": (ProviderType.TAVILY, resolve_with_tavily), + "duckduckgo": (ProviderType.DUCKDUCKGO, resolve_with_duckduckgo), + "mistral_websearch": (ProviderType.MISTRAL_WEBSEARCH, resolve_with_mistral_websearch), +} + __all__ = [ "resolve", "resolve_url", @@ -389,7 +398,7 @@ def _build_success_output( max_chars: int, metrics: ResolveMetrics, trace: ResolutionTrace | None, - q_score: Any, + q_score: quality.QualityScore, ) -> dict[str, Any]: """Build the yielded result dict for an accepted probe result. @@ -791,21 +800,11 @@ def resolve_query( return {"source": "none", "query": query, "content": "Failed"} -# Provider cascade for search queries: name → (type, (query, max_chars) callable). -_QUERY_CASCADE: dict[str, tuple[ProviderType, Callable[[str, int], Any]]] = { - "exa_mcp": (ProviderType.EXA_MCP, resolve_with_exa_mcp), - "exa": (ProviderType.EXA, resolve_with_exa), - "tavily": (ProviderType.TAVILY, resolve_with_tavily), - "duckduckgo": (ProviderType.DUCKDUCKGO, resolve_with_duckduckgo), - "mistral_websearch": (ProviderType.MISTRAL_WEBSEARCH, resolve_with_mistral_websearch), -} - - def _build_query_output( res: Any, p_name_done: str, metrics: ResolveMetrics, - q_score: Any, + q_score: quality.QualityScore, trace: ResolutionTrace | None, start_time: float, ) -> dict[str, Any]: diff --git a/.agents/skills/do-web-doc-resolver/tests/test_resolve.py b/.agents/skills/do-web-doc-resolver/tests/test_resolve.py index cf41fcfe..0896192c 100644 --- a/.agents/skills/do-web-doc-resolver/tests/test_resolve.py +++ b/.agents/skills/do-web-doc-resolver/tests/test_resolve.py @@ -304,6 +304,31 @@ def test_string_result_yields_content(self, mock_score, mock_fetch, mock_plan): assert first["url"] == "https://example.com" assert len(first["content"]) >= 600 + @patch("scripts.resolve.routing.plan_provider_order", return_value=["jina"]) + @patch("scripts.resolve.resolve_with_jina") + @patch("scripts.resolve.quality.score_content") + def test_provider_result_success_yields_content(self, mock_score, mock_jina, mock_plan): + """ + An acceptable ProviderResult should be yielded, not silently dropped. + Args: + mock_score: Injected pytest fixture. + mock_jina: Injected pytest fixture. + mock_plan: Injected pytest fixture. + """ + from scripts.models import Profile, ProviderResult + from scripts.resolve import resolve_url_stream + + mock_score.return_value = self._make_quality(acceptable=True) + mock_jina.return_value = ProviderResult( + ok=True, source="jina", content="B" * 600, url="https://example.com" + ) + results = list(resolve_url_stream("https://example.com", profile=Profile.FAST)) + assert results + first = results[0] + assert first["source"] == "jina" + assert first["url"] == "https://example.com" + assert len(first["content"]) >= 600 + @patch("scripts.resolve.routing.plan_provider_order", return_value=["llms_txt"]) @patch("scripts.resolve.fetch_llms_txt") def test_llms_txt_yields_compacted_output(self, mock_llms, mock_plan): From 7cdf20e1b12a4be624580ffdab0015181b7c4080 Mon Sep 17 00:00:00 2001 From: d-oit <6849456+d-oit@users.noreply.github.com> Date: Sun, 9 Aug 2026 16:59:49 +0200 Subject: [PATCH 11/15] refactor(owlwatch): reduce complexity in resolve_with_exa and CLI main --- .../scripts/providers_impl.py | 51 ++++++--- .../do-web-doc-resolver/scripts/resolve.py | 107 ++++++++++++------ 2 files changed, 112 insertions(+), 46 deletions(-) diff --git a/.agents/skills/do-web-doc-resolver/scripts/providers_impl.py b/.agents/skills/do-web-doc-resolver/scripts/providers_impl.py index 95ad84a0..4ca35bd9 100644 --- a/.agents/skills/do-web-doc-resolver/scripts/providers_impl.py +++ b/.agents/skills/do-web-doc-resolver/scripts/providers_impl.py @@ -157,6 +157,42 @@ def resolve_with_exa_mcp(query: str, max_chars: int = MAX_CHARS) -> ProviderResu return ProviderResult(ok=False, error=str(e), meta=meta, query=query, source="exa_mcp") +def _exa_search(query: str, api_key: str): + """Run an Exa SDK search and return the raw response object. + + Args: + query: The search query. + api_key: EXA_API_KEY value. + + Returns: + The Exa ``search_and_contents`` response. + """ + from exa_py import Exa + + client = Exa(api_key) + return client.search_and_contents( + query, use_autoprompt=True, highlights=True, num_results=EXA_RESULTS + ) + + +def _exa_content(res) -> str: + """Join Exa result highlights/texts into a single markdown block. + + Args: + res: The Exa search response with a ``results`` sequence. + + Returns: + The concatenated highlight/text content. + """ + return "\n\n---\n\n".join( + [ + (r.highlight if hasattr(r, "highlight") and r.highlight else r.text) + for r in res.results + if (hasattr(r, "highlight") and r.highlight) or (hasattr(r, "text") and r.text) + ] + ) + + def resolve_with_exa(query: str, max_chars: int = MAX_CHARS) -> ProviderResult: """Resolve a query via the Exa SDK, requiring EXA_API_KEY. @@ -180,23 +216,12 @@ def resolve_with_exa(query: str, max_chars: int = MAX_CHARS) -> ProviderResult: meta = ProviderMeta(tool="exa", duration_ms=duration, error_type=error_type) return ProviderResult(ok=False, error="missing_api_key_or_rate_limited", meta=meta, query=query, source="exa") try: - from exa_py import Exa - - client = Exa(api_key) - res = client.search_and_contents( - query, use_autoprompt=True, highlights=True, num_results=EXA_RESULTS - ) + res = _exa_search(query, api_key) duration = int((time.time() - start) * 1000) if not res or not res.results: meta = ProviderMeta(tool="exa", duration_ms=duration, error_type="not_found") return ProviderResult(ok=False, error="no_results", meta=meta, query=query, source="exa") - content = "\n\n---\n\n".join( - [ - (r.highlight if hasattr(r, "highlight") and r.highlight else r.text) - for r in res.results - if (hasattr(r, "highlight") and r.highlight) or (hasattr(r, "text") and r.text) - ] - ) + content = _exa_content(res) meta = ProviderMeta(tool="exa", duration_ms=duration) result = ProviderResult(ok=True, content=content[:max_chars], meta=meta, query=query, source="exa") _save_to_cache(query, "exa", {"source": "exa", "content": content[:max_chars], "query": query}) diff --git a/.agents/skills/do-web-doc-resolver/scripts/resolve.py b/.agents/skills/do-web-doc-resolver/scripts/resolve.py index ca97ebb0..19b9f2c7 100644 --- a/.agents/skills/do-web-doc-resolver/scripts/resolve.py +++ b/.agents/skills/do-web-doc-resolver/scripts/resolve.py @@ -1172,8 +1172,12 @@ def resolve_query_with_order( return resolve_with_order(query, order, max_chars) -def main(): - """CLI entry point: resolve a URL or query with optional tracing.""" +def _build_cli_parser() -> argparse.ArgumentParser: + """Build the CLI argument parser. + + Returns: + A configured ArgumentParser for the resolver CLI. + """ parser = argparse.ArgumentParser(description="Web Doc Resolver") parser.add_argument("input", nargs="?", help="URL or query") parser.add_argument("--max-chars", type=int, default=MAX_CHARS) @@ -1186,6 +1190,72 @@ def main(): parser.add_argument("--providers-order", type=str) parser.add_argument("--log-level", default="INFO") parser.add_argument("--trace", action="store_true") + return parser + + +def _run_cli_resolution( + args: argparse.Namespace, + profile: Profile, + skip: set[str] | None, + trace: ResolutionTrace | None, +) -> list[dict[str, Any]]: + """Dispatch the CLI input to the matching resolution pipeline. + + Args: + args: Parsed CLI arguments. + profile: Resolution profile. + skip: Optional set of provider names to skip. + trace: Optional trace to populate. + + Returns: + The list of result dicts to print. + """ + if args.provider: + return [resolve_direct(args.input, ProviderType(args.provider), args.max_chars)] + if args.providers_order: + order = [ProviderType(p.strip()) for p in args.providers_order.split(",")] + return [resolve_with_order(args.input, order, args.max_chars)] + if is_url(args.input): + return list(resolve_url_stream(args.input, args.max_chars, profile, trace=trace)) + return list(resolve_query_stream(args.input, args.max_chars, skip, profile, trace=trace)) + + +def _print_cli_results( + results: list[dict[str, Any]], as_json: bool, show_trace: bool +) -> None: + """Print resolution results as JSON or human-readable text. + + Args: + results: Result dicts produced by the resolution pipeline. + as_json: Print compact JSON when True. + show_trace: Include the trace section when True. + """ + final_result = None + for res in results: + if not as_json and res.get("source") != "partial": + print(f"--- Source: {res.get('source')} ---") + print(res.get("content", "")[:500] + "...") + final_result = res + if as_json: + print( + json.dumps( + final_result, + indent=2, + default=lambda o: o.__dict__ if hasattr(o, "__dict__") else str(o), + ) + ) + return + print("\n=== FINAL RESULT ===") + if final_result: + print(final_result.get("content", "")) + if show_trace and final_result and "trace" in final_result: + print("\n=== TRACE ===") + print(json.dumps(final_result["trace"], indent=2)) + + +def main(): + """CLI entry point: resolve a URL or query with optional tracing.""" + parser = _build_cli_parser() args = parser.parse_args() logging.basicConfig(level=getattr(logging, args.log_level)) if not args.input: @@ -1201,37 +1271,8 @@ def main(): is_url=is_url_input, profile=args.profile, ) - if args.provider: - results = [resolve_direct(args.input, ProviderType(args.provider), args.max_chars)] - elif args.providers_order: - order = [ProviderType(p.strip()) for p in args.providers_order.split(",")] - results = [resolve_with_order(args.input, order, args.max_chars)] - else: - if is_url_input: - results = resolve_url_stream(args.input, args.max_chars, profile, trace=trace) - else: - results = resolve_query_stream(args.input, args.max_chars, skip, profile, trace=trace) - final_result = None - for res in results: - if not args.json and res.get("source") != "partial": - print(f"--- Source: {res.get('source')} ---") - print(res.get("content", "")[:500] + "...") - final_result = res - if args.json: - print( - json.dumps( - final_result, - indent=2, - default=lambda o: o.__dict__ if hasattr(o, "__dict__") else str(o), - ) - ) - else: - print("\n=== FINAL RESULT ===") - if final_result: - print(final_result.get("content", "")) - if args.trace and final_result and "trace" in final_result: - print("\n=== TRACE ===") - print(json.dumps(final_result["trace"], indent=2)) + results = _run_cli_resolution(args, profile, skip, trace) + _print_cli_results(results, args.json, args.trace) if __name__ == "__main__": From 3b463903330ed4bde6966b52da5f7516ea026af3 Mon Sep 17 00:00:00 2001 From: d-oit <6849456+d-oit@users.noreply.github.com> Date: Sun, 9 Aug 2026 17:51:37 +0200 Subject: [PATCH 12/15] refactor(owlwatch): extract _launch_query_probe to slim resolve_query_stream --- .../do-web-doc-resolver/scripts/resolve.py | 50 ++++++++++++++++--- 1 file changed, 43 insertions(+), 7 deletions(-) diff --git a/.agents/skills/do-web-doc-resolver/scripts/resolve.py b/.agents/skills/do-web-doc-resolver/scripts/resolve.py index 19b9f2c7..92dfe268 100644 --- a/.agents/skills/do-web-doc-resolver/scripts/resolve.py +++ b/.agents/skills/do-web-doc-resolver/scripts/resolve.py @@ -974,6 +974,44 @@ def _drain_query_probes( return None +def _launch_query_probe( + p_name: str, + pt: ProviderType, + func: Callable[[str, int], Any], + budget: routing.ResolutionBudget, + cache: Any, + query: str, + max_chars: int, + executor: concurrent.futures.ThreadPoolExecutor, +) -> tuple[concurrent.futures.Future[Any] | None, bool]: + """Submit a query probe when budget/cache/circuit state allow. + + Returns ``(future, stop)``: ``future`` is None when the probe was skipped + and the cascade should continue with the next provider; ``stop`` signals + that the whole cascade should halt. + + Args: + p_name: Provider name to probe. + pt: Provider type to probe. + func: (query, max_chars) callable that performs the probe. + budget: Resolution budget tracker. + cache: Cache handle for negative-cache lookups. + query: The search query being resolved. + max_chars: Maximum content length to retain. + executor: Thread pool used to launch the probe. + + Returns: + (future or None, stop flag) as described above. + """ + if not budget.can_try(is_paid=pt.is_paid()): + return None, budget.stop_reason not in ("paid_disabled", "max_paid_attempts") + if cache_negative.should_skip_from_negative_cache(cache, query, p_name): + return None, False + if _circuit_breakers.is_open(p_name): + return None, False + return executor.submit(func, query, max_chars), False + + def resolve_query_stream( query: str, max_chars: int = MAX_CHARS, @@ -1013,17 +1051,15 @@ def resolve_query_stream( try: for i, p_name in enumerate(eligible): pt, func = _QUERY_CASCADE[p_name] - if not budget.can_try(is_paid=pt.is_paid()): - if budget.stop_reason in ("paid_disabled", "max_paid_attempts"): - continue + future, stop = _launch_query_probe( + p_name, pt, func, budget, cache, query, max_chars, executor + ) + if stop: break - if cache_negative.should_skip_from_negative_cache(cache, query, p_name): - continue - if _circuit_breakers.is_open(p_name): + if future is None: continue logger.info("Starting probe: %s", p_name) start_time_probe = time.time() - future = executor.submit(func, query, max_chars) active_futures[future] = (p_name, pt, start_time_probe) threshold = _routing_memory.get_p75_latency("query", p_name) / 1000.0 out = _drain_query_probes( From 77913a539d5d6cec5a93419b577a49bf690adc81 Mon Sep 17 00:00:00 2001 From: d-oit <6849456+d-oit@users.noreply.github.com> Date: Sun, 9 Aug 2026 19:15:48 +0200 Subject: [PATCH 13/15] ci: nudge merge-state staleness re-evaluation (Plan 098) From 9590281788d6e96507b82b55fcca1ccf1d3c5f0e Mon Sep 17 00:00:00 2001 From: d-oit <6849456+d-oit@users.noreply.github.com> Date: Mon, 10 Aug 2026 18:24:34 +0200 Subject: [PATCH 14/15] fix(doc-resolver): block IPv6 literal SSRF; restore socket default timeout --- .../do-web-doc-resolver/scripts/utils.py | 9 +- .../tests/test_utils_ssrf.py | 92 +++++++++++++++++++ 2 files changed, 99 insertions(+), 2 deletions(-) create mode 100644 .agents/skills/do-web-doc-resolver/tests/test_utils_ssrf.py diff --git a/.agents/skills/do-web-doc-resolver/scripts/utils.py b/.agents/skills/do-web-doc-resolver/scripts/utils.py index 97b2c340..9786e004 100644 --- a/.agents/skills/do-web-doc-resolver/scripts/utils.py +++ b/.agents/skills/do-web-doc-resolver/scripts/utils.py @@ -89,7 +89,9 @@ def is_safe_url(url: str) -> bool: return False if parsed.scheme not in ("http", "https"): return False - hostname = parsed.netloc.split(":")[0] + # urlparse.hostname strips IPv6 brackets and userinfo, so both + # "http://[::1]/" and "http://user@192.168.0.1/" yield the raw host. + hostname = parsed.hostname or "" if hostname.lower() in ( "localhost", "localhost.localdomain", @@ -103,6 +105,7 @@ def is_safe_url(url: str) -> bool: if any(ip in network for network in BLOCKED_NETWORKS): return False except ValueError: + previous_timeout = socket.getdefaulttimeout() try: socket.setdefaulttimeout(5) infos = socket.getaddrinfo(hostname, None) @@ -113,7 +116,9 @@ def is_safe_url(url: str) -> bool: except Exception: pass finally: - socket.setdefaulttimeout(None) + # Restore the caller's default, not None, to avoid clobbering + # a timeout configured elsewhere in the process. + socket.setdefaulttimeout(previous_timeout) return True except Exception: return False diff --git a/.agents/skills/do-web-doc-resolver/tests/test_utils_ssrf.py b/.agents/skills/do-web-doc-resolver/tests/test_utils_ssrf.py new file mode 100644 index 00000000..68a4941d --- /dev/null +++ b/.agents/skills/do-web-doc-resolver/tests/test_utils_ssrf.py @@ -0,0 +1,92 @@ +""" +Tests for SSRF URL validation (is_safe_url). + +Focuses on the private-network blocklist and the socket default-timeout +save/restore contract introduced with the docling/OCR SSRF hardening. +""" + +import socket +from unittest.mock import patch + +from scripts.utils import is_safe_url + + +class TestIsSafeUrl: + """SSRF safety checks.""" + + def test_blocks_non_http_schemes(self): + """file://, data:, javascript: URLs are rejected.""" + assert is_safe_url("file:///etc/passwd") is False + assert is_safe_url("javascript:alert(1)") is False + assert is_safe_url("data:text/plain;base64,AA==") is False + + def test_blocks_localhost_aliases(self): + """localhost and loopback hostnames are rejected.""" + assert is_safe_url("http://localhost/foo") is False + assert is_safe_url("http://127.0.0.1/foo") is False + assert is_safe_url("http://0.0.0.0/foo") is False + + def test_blocks_private_ipv4(self): + """RFC1918 and link-local ranges are rejected.""" + assert is_safe_url("http://10.0.0.1/foo") is False + assert is_safe_url("http://172.16.0.1/foo") is False + assert is_safe_url("http://192.168.1.1/foo") is False + assert is_safe_url("http://169.254.169.254/latest/meta-data/") is False + + def test_blocks_private_ipv6(self): + """Loopback and ULA IPv6 addresses are rejected.""" + assert is_safe_url("http://[::1]/foo") is False + assert is_safe_url("http://[fc00::1]/foo") is False + + def test_blocks_public_hostname_resolving_to_private_ip(self): + """A hostname that resolves to a private IP is rejected.""" + addr = (socket.AF_INET, socket.SOCK_STREAM, 6, "", ("10.1.2.3", 80)) + with patch("socket.getaddrinfo", return_value=[addr]): + assert is_safe_url("http://example.internal/foo") is False + + def test_accepts_public_https_url(self): + """A well-formed public https URL is accepted.""" + assert is_safe_url("https://example.com/docs") is True + + +class TestSocketTimeoutRestore: + """The DNS-resolution path must restore the previous default timeout.""" + + def test_restores_previous_timeout_after_resolution(self): + """A pre-existing default timeout survives the lookup.""" + original = socket.getdefaulttimeout() + try: + socket.setdefaulttimeout(11) + with patch( + "socket.getaddrinfo", + return_value=[(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", 80))], + ): + assert is_safe_url("https://example.com/") is True + assert socket.getdefaulttimeout() == 11 + finally: + socket.setdefaulttimeout(original) + + def test_restores_previous_timeout_when_blocked(self): + """A blocked hostname still restores the prior timeout.""" + original = socket.getdefaulttimeout() + try: + socket.setdefaulttimeout(7) + with patch( + "socket.getaddrinfo", + return_value=[(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("127.0.0.1", 80))], + ): + assert is_safe_url("https://loopback.example/") is False + assert socket.getdefaulttimeout() == 7 + finally: + socket.setdefaulttimeout(original) + + def test_restores_when_resolution_raises(self): + """A failing getaddrinfo still restores the prior timeout.""" + original = socket.getdefaulttimeout() + try: + socket.setdefaulttimeout(3) + with patch("socket.getaddrinfo", side_effect=socket.gaierror("NXDOMAIN")): + assert is_safe_url("https://nonexistent.invalid/") is True + assert socket.getdefaulttimeout() == 3 + finally: + socket.setdefaulttimeout(original) From 854f0e2b86e3a2b21a3572191b3a2709d4a34a9c Mon Sep 17 00:00:00 2001 From: d-oit <6849456+d-oit@users.noreply.github.com> Date: Mon, 10 Aug 2026 19:10:19 +0200 Subject: [PATCH 15/15] fix(doc-resolver): document utils.py fully; static test methods; annotate lazy import cycle --- .../do-web-doc-resolver/scripts/utils.py | 28 ++++++++++++++++++- .../tests/test_utils_ssrf.py | 27 ++++++++++++------ 2 files changed, 45 insertions(+), 10 deletions(-) diff --git a/.agents/skills/do-web-doc-resolver/scripts/utils.py b/.agents/skills/do-web-doc-resolver/scripts/utils.py index 9786e004..385a2781 100644 --- a/.agents/skills/do-web-doc-resolver/scripts/utils.py +++ b/.agents/skills/do-web-doc-resolver/scripts/utils.py @@ -47,6 +47,7 @@ def create_session_with_retry() -> requests.Session: + """Build a requests session with retry and header configuration.""" session = requests.Session() retry_strategy = Retry( total=3, @@ -69,6 +70,7 @@ def create_session_with_retry() -> requests.Session: def get_session() -> requests.Session: + """Return the process-wide shared session, creating it on first use.""" global _global_session if _global_session is None: _global_session = create_session_with_retry() @@ -76,6 +78,7 @@ def get_session() -> requests.Session: def close_session() -> None: + """Close and release the shared session, if one exists.""" global _global_session if _global_session is not None: _global_session.close() @@ -83,6 +86,7 @@ def close_session() -> None: def is_safe_url(url: str) -> bool: + """Return False when a URL is non-HTTP or resolves to a blocked/private network.""" try: parsed = urlparse(url) if parsed.scheme.lower() in BLOCKED_SCHEMES: @@ -125,6 +129,7 @@ def is_safe_url(url: str) -> bool: def is_url(input_str: str) -> bool: + """Return True when the input parses as an http(s)/ftp URL with a host.""" if not input_str or not input_str.strip(): return False try: @@ -135,6 +140,7 @@ def is_url(input_str: str) -> bool: def validate_url(url: str, timeout: int = 10, check_ssrf: bool = True) -> ValidationResult: + """Validate a URL by format, SSRF policy, and an HTTP HEAD probe.""" if not url or not url.strip(): return ValidationResult(is_valid=False, error="Empty URL") if not is_url(url): @@ -166,6 +172,7 @@ def validate_url(url: str, timeout: int = 10, check_ssrf: bool = True) -> Valida def validate_links(links: list[str], timeout: int = 5) -> list[str]: + """Return the subset of links that pass SSRF checks and return < 400.""" valid_links = [] session = get_session() for link in links: @@ -181,6 +188,7 @@ def validate_links(links: list[str], timeout: int = 5) -> list[str]: def score_result(url: str | None, content: str) -> float: + """Score a resolved result in [0,1] by domain authority and content length.""" score = 0.5 if url: try: @@ -203,6 +211,7 @@ def score_result(url: str | None, content: str) -> float: def compact_content(content: str, max_chars: int) -> str: + """Dedupe repeated lines and truncate content to max_chars.""" lines = content.splitlines() unique_lines = set() compacted = [] @@ -218,21 +227,27 @@ def compact_content(content: str, max_chars: int) -> str: def extract_text_from_html(html: str, base_url: str = "") -> str: + """Strip script/style blocks and tags from HTML, returning plain text.""" class ScriptStyleStripper(HTMLParser): + """HTMLParser subclass that discards script/style content.""" def __init__(self) -> None: + """Initialize the result buffer and script/style nesting depth.""" super().__init__(convert_charrefs=False) self.result: list[str] = [] self._skip_depth = 0 def handle_starttag(self, tag, attrs): + """Track nesting depth for script/style start tags.""" if tag.lower() in ("script", "style"): self._skip_depth += 1 def handle_endtag(self, tag): + """Decrement nesting depth for script/style end tags.""" if tag.lower() in ("script", "style") and self._skip_depth > 0: self._skip_depth -= 1 def handle_data(self, data): + """Append text data that is not inside a script/style block.""" if self._skip_depth == 0: self.result.append(data) @@ -247,6 +262,7 @@ def handle_data(self, data): def fetch_url_content( url: str, timeout: int = DEFAULT_TIMEOUT, max_chars: int = MAX_CHARS ) -> ResolvedResult | None: + """Fetch a validated URL and return its extracted text as a ResolvedResult.""" validation = validate_url(url, timeout=timeout // 2) if not validation.is_valid: return None @@ -271,6 +287,7 @@ def fetch_url_content( def fetch_llms_txt(url: str) -> str | None: + """Return the site's llms.txt content, using the cache when fresh.""" try: parsed = urlparse(url) base_url = f"{parsed.scheme}://{parsed.netloc}" @@ -388,6 +405,7 @@ def normalize_query(query: str) -> str: def _cache_key(input_str: str, source: str) -> str: + """Hash the normalized input plus source into a stable cache key.""" # Use normalized input for cache key if is_url(input_str): normalized = normalize_url(input_str) @@ -399,7 +417,10 @@ def _cache_key(input_str: str, source: str) -> str: def _get_cache_proxy(): - from . import resolve + """Return resolve's shared cache if set, else this module's own cache.""" + # Lazy import keeps the resolve->utils dependency one-directional at + # import time; the static cycle is intentional and harmless here. + from . import resolve # pylint: disable=cyclic-import,import-outside-toplevel if hasattr(resolve, "_cache") and resolve._cache is not None: return resolve._cache @@ -407,6 +428,7 @@ def _get_cache_proxy(): def get_cache(): + """Create a diskcache instance in CACHE_DIR, or None when unavailable.""" try: import diskcache @@ -417,6 +439,7 @@ def get_cache(): def _get_cache(): + """Return the shared cache, resolving it lazily on first use.""" global _cache _cache = _get_cache_proxy() if _cache is None: @@ -425,6 +448,7 @@ def _get_cache(): def _get_from_cache(input_str: str, source: str) -> dict[str, Any] | None: + """Read a cached result for the input, or None on miss/unavailable cache.""" cache = _get_cache() if not cache: return None @@ -435,6 +459,7 @@ def _get_from_cache(input_str: str, source: str) -> dict[str, Any] | None: def _save_to_cache(input_str: str, source: str, result: dict[str, Any], ttl: int | None = None): + """Store a result under the input's cache key with the given TTL.""" cache = _get_cache() if not cache: return @@ -442,6 +467,7 @@ def _save_to_cache(input_str: str, source: str, result: dict[str, Any], ttl: int def _detect_error_type(error: Exception) -> ErrorType: + """Classify an exception message into the matching ErrorType category.""" error_msg = str(error).lower() if any(code in error_msg for code in ["429", "rate limit", "too many requests", "rate_limit"]): return ErrorType.RATE_LIMIT diff --git a/.agents/skills/do-web-doc-resolver/tests/test_utils_ssrf.py b/.agents/skills/do-web-doc-resolver/tests/test_utils_ssrf.py index 68a4941d..6c729cb3 100644 --- a/.agents/skills/do-web-doc-resolver/tests/test_utils_ssrf.py +++ b/.agents/skills/do-web-doc-resolver/tests/test_utils_ssrf.py @@ -14,37 +14,43 @@ class TestIsSafeUrl: """SSRF safety checks.""" - def test_blocks_non_http_schemes(self): + @staticmethod + def test_blocks_non_http_schemes(): """file://, data:, javascript: URLs are rejected.""" assert is_safe_url("file:///etc/passwd") is False assert is_safe_url("javascript:alert(1)") is False assert is_safe_url("data:text/plain;base64,AA==") is False - def test_blocks_localhost_aliases(self): + @staticmethod + def test_blocks_localhost_aliases(): """localhost and loopback hostnames are rejected.""" assert is_safe_url("http://localhost/foo") is False assert is_safe_url("http://127.0.0.1/foo") is False assert is_safe_url("http://0.0.0.0/foo") is False - def test_blocks_private_ipv4(self): + @staticmethod + def test_blocks_private_ipv4(): """RFC1918 and link-local ranges are rejected.""" assert is_safe_url("http://10.0.0.1/foo") is False assert is_safe_url("http://172.16.0.1/foo") is False assert is_safe_url("http://192.168.1.1/foo") is False assert is_safe_url("http://169.254.169.254/latest/meta-data/") is False - def test_blocks_private_ipv6(self): + @staticmethod + def test_blocks_private_ipv6(): """Loopback and ULA IPv6 addresses are rejected.""" assert is_safe_url("http://[::1]/foo") is False assert is_safe_url("http://[fc00::1]/foo") is False - def test_blocks_public_hostname_resolving_to_private_ip(self): + @staticmethod + def test_blocks_public_hostname_resolving_to_private_ip(): """A hostname that resolves to a private IP is rejected.""" addr = (socket.AF_INET, socket.SOCK_STREAM, 6, "", ("10.1.2.3", 80)) with patch("socket.getaddrinfo", return_value=[addr]): assert is_safe_url("http://example.internal/foo") is False - def test_accepts_public_https_url(self): + @staticmethod + def test_accepts_public_https_url(): """A well-formed public https URL is accepted.""" assert is_safe_url("https://example.com/docs") is True @@ -52,7 +58,8 @@ def test_accepts_public_https_url(self): class TestSocketTimeoutRestore: """The DNS-resolution path must restore the previous default timeout.""" - def test_restores_previous_timeout_after_resolution(self): + @staticmethod + def test_restores_previous_timeout_after_resolution(): """A pre-existing default timeout survives the lookup.""" original = socket.getdefaulttimeout() try: @@ -66,7 +73,8 @@ def test_restores_previous_timeout_after_resolution(self): finally: socket.setdefaulttimeout(original) - def test_restores_previous_timeout_when_blocked(self): + @staticmethod + def test_restores_previous_timeout_when_blocked(): """A blocked hostname still restores the prior timeout.""" original = socket.getdefaulttimeout() try: @@ -80,7 +88,8 @@ def test_restores_previous_timeout_when_blocked(self): finally: socket.setdefaulttimeout(original) - def test_restores_when_resolution_raises(self): + @staticmethod + def test_restores_when_resolution_raises(): """A failing getaddrinfo still restores the prior timeout.""" original = socket.getdefaulttimeout() try: