simplify(compat): tool_search/skills_sync_client — drop 22 re-exports, repoint 5 callers + 5 test files; drop stale re-export prose in web_tools_truncate/file_operations_lint

This commit is contained in:
Teknium
2026-09-03 13:33:47 -07:00
parent e69b04ea86
commit 491e53ef3c
13 changed files with 85 additions and 90 deletions
+2 -1
View File
@@ -3761,7 +3761,8 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin, CLITuiMix
# the token (only issued for multi-member orgs), so a solo account never hits
# the network here. Both fail-quiet.
try:
from tools.skills_sync_client import maybe_pull_org_skills, maybe_pull_skills
from tools.skills_sync_client import maybe_pull_skills
from tools.skills_sync_client_org import maybe_pull_org_skills
except Exception:
return
for pull in (maybe_pull_skills, maybe_pull_org_skills):
+1 -1
View File
@@ -4439,7 +4439,7 @@ def _housekeeping_skill_sync() -> None:
def _housekeeping_org_skill_sync() -> None:
"""Gated on real org membership (the token must carry an org role): solo accounts never reach the network."""
from tools.skills_sync_client import maybe_pull_org_skills
from tools.skills_sync_client_org import maybe_pull_org_skills
maybe_pull_org_skills()
+5 -4
View File
@@ -1,8 +1,7 @@
"""Interactive messaging-platform setup wizards: WhatsApp (bridge + Cloud API), Slack manifest,
Skill Sync.
Split out of ``hermes_cli/main.py``, which re-imports every name (``hermes_cli.main.<name>`` keeps
resolving and monkeypatching). Names that stay in main are imported lazily at call time.
Split out of ``hermes_cli/main.py``. Names that still live in main are imported lazily at call time.
"""
import contextlib
@@ -246,9 +245,10 @@ def _sync_device(args, ssc) -> int:
def _sync_propose(args, ssc) -> int:
from tools.skills_sync_client_org import propose_skill
name = args.name
try:
result = ssc.propose_skill(name, message=args.message)
result = propose_skill(name, message=args.message)
except ssc.SyncInertError as e:
_err(f"cannot share this skill: {e}")
return 1
@@ -310,7 +310,8 @@ def _sync_pull(ssc, identity):
result = ssc.pull_skills(identity=identity)
# Refresh the org mirror too when this account belongs to an organisation (no-op
# otherwise), so one pull covers both.
org_result = ssc.maybe_pull_org_skills()
from tools.skills_sync_client_org import maybe_pull_org_skills
org_result = maybe_pull_org_skills()
if org_result:
n = len(org_result.get("updated") or [])
_err(f"org: refreshed {n} shared skill(s) from your organisation.")
+17 -16
View File
@@ -334,42 +334,43 @@ class TestLocalEditsSurviveOrgUpdates:
monkeypatch.setattr(
ssc, "_org_dir", lambda: skills / sku.ORG_MIRROR_DIR_NAME
)
return ssc, skills, d
from tools import skills_sync_client_org as org
return org, skills, d
def test_unmodified_skill_is_not_flagged(self, tmp_path, monkeypatch):
ssc, _skills, d = self._mirror(tmp_path, monkeypatch)
ssc._write_org_baseline(
org, _skills, d = self._mirror(tmp_path, monkeypatch)
org._write_org_baseline(
"org-1",
{"shared-x": {"fingerprint": ssc._skill_dir_fingerprint(d), "tree": "t1"}},
{"shared-x": {"fingerprint": org._skill_dir_fingerprint(d), "tree": "t1"}},
)
assert ssc.org_skill_is_locally_modified("shared-x", "org-1") is False
assert ssc.list_locally_modified_org_skills("org-1") == []
assert org.org_skill_is_locally_modified("shared-x", "org-1") is False
assert org.list_locally_modified_org_skills("org-1") == []
def test_edited_skill_is_detected(self, tmp_path, monkeypatch):
ssc, _skills, d = self._mirror(tmp_path, monkeypatch)
ssc._write_org_baseline(
org, _skills, d = self._mirror(tmp_path, monkeypatch)
org._write_org_baseline(
"org-1",
{"shared-x": {"fingerprint": ssc._skill_dir_fingerprint(d), "tree": "t1"}},
{"shared-x": {"fingerprint": org._skill_dir_fingerprint(d), "tree": "t1"}},
)
(d / "SKILL.md").write_text("---\nname: shared-x\n---\nEDITED\n", encoding="utf-8")
assert ssc.org_skill_is_locally_modified("shared-x", "org-1") is True
assert ssc.list_locally_modified_org_skills("org-1") == ["shared-x"]
assert org.org_skill_is_locally_modified("shared-x", "org-1") is True
assert org.list_locally_modified_org_skills("org-1") == ["shared-x"]
def test_missing_baseline_does_not_cry_wolf(self, tmp_path, monkeypatch):
ssc, _skills, _d = self._mirror(tmp_path, monkeypatch)
org, _skills, _d = self._mirror(tmp_path, monkeypatch)
# Mirror pulled before baselines existed — must not be reported as
# modified (that would block every update with a phantom conflict).
assert ssc.org_skill_is_locally_modified("shared-x", "org-1") is False
assert org.org_skill_is_locally_modified("shared-x", "org-1") is False
def test_fingerprint_is_content_based_not_mtime(self, tmp_path, monkeypatch):
import os
import time
ssc, _skills, d = self._mirror(tmp_path, monkeypatch)
before = ssc._skill_dir_fingerprint(d)
org, _skills, d = self._mirror(tmp_path, monkeypatch)
before = org._skill_dir_fingerprint(d)
time.sleep(0.01)
os.utime(d / "SKILL.md", None) # touch: mtime changes, content doesn't
assert ssc._skill_dir_fingerprint(d) == before
assert org._skill_dir_fingerprint(d) == before
def test_auto_propose_defaults_off(self, monkeypatch):
from tools import skills_sync_client as ssc
+3 -2
View File
@@ -28,7 +28,8 @@ from types import SimpleNamespace
import pytest
from agent.tool_dispatch_helpers import _plan_tool_batch_segments
from tools.tool_search import _short_desc, build_catalog, search_catalog
from tools.tool_search import build_catalog, search_catalog
from tools.tool_search_catalog import _short_desc
def _tc(name, arguments="{}", call_id=None):
@@ -300,7 +301,7 @@ class TestSourceNameIndexing:
# Compare in token space: the tokenizer may stem (e.g.
# "catalogsource" -> "catalogsourc"), and the contract is that
# the label lands in the document exactly once either way.
from tools.tool_search import _tokenize
from tools.tool_search_catalog import _tokenize
label_token = _tokenize(source_label)[0]
tokens_by_name = {entry.name: entry._tokens for entry in catalog}
assert tokens_by_name[names[0]].count(label_token) == 1
+34 -32
View File
@@ -22,6 +22,8 @@ from pathlib import Path
import pytest
import tools.skills_sync_client as ssc
import tools.skills_sync_client_org as org
import tools.skills_sync_client_wire as wire
# ---------------------------------------------------------------------------
@@ -268,7 +270,7 @@ def _jwt(claims: dict) -> str:
class TestAddressing:
def test_full_64_hex_address(self):
addr = ssc.wire_address(b"")
addr = wire.wire_address(b"")
# sha256 of empty is the well-known e3b0... digest, full 64 hex.
assert addr == (
"sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
@@ -278,21 +280,21 @@ class TestAddressing:
def test_address_differs_from_local_truncated_namespace(self):
# The wire full-64-hex must NOT equal the local truncated 16-hex form.
data = b"hello world"
full = ssc.wire_address(data)
full = wire.wire_address(data)
truncated = "sha256:" + hashlib.sha256(data).hexdigest()[:16]
assert full != truncated
assert len(full.split(":")[1]) == 64
assert len(truncated.split(":")[1]) == 16
def test_canonical_json_sorted_no_whitespace(self):
out = ssc.canonical_json_bytes({"b": 1, "a": 2})
out = wire.canonical_json_bytes({"b": 1, "a": 2})
assert out == b'{"a":2,"b":1}'
assert b" " not in out
assert not out.endswith(b"\n")
def test_canonical_json_stable(self):
obj = {"type": "tree", "entries": [{"name": "x", "hash": "sha256:aa"}]}
assert ssc.canonical_json_bytes(obj) == ssc.canonical_json_bytes(dict(obj))
assert wire.canonical_json_bytes(obj) == wire.canonical_json_bytes(dict(obj))
# ---------------------------------------------------------------------------
@@ -366,11 +368,11 @@ class TestObjectBuilding:
assert tree_hash.startswith("sha256:")
# tree object present and canonical
kind, data = objects.objects[tree_hash]
assert kind == ssc.KIND_TREE
assert kind == wire.KIND_TREE
tree = json.loads(data)
entries = {e["name"]: e for e in tree["entries"]}
assert entries["SKILL.md"]["mode"] == ssc.MODE_FILE
assert entries["run.sh"]["mode"] == ssc.MODE_EXEC
assert entries["SKILL.md"]["mode"] == wire.MODE_FILE
assert entries["run.sh"]["mode"] == wire.MODE_EXEC
# entries sorted by name (byte order)
names = [e["name"] for e in tree["entries"]]
assert names == sorted(names)
@@ -494,14 +496,14 @@ class TestEndToEnd:
client = ssc.SyncClient(base, "tok")
caps = client.capabilities()
assert caps["hsp_version"] == "1"
ssc._check_version(caps) # no raise
wire._check_version(caps) # no raise
def test_version_mismatch_raises(self, mock_server):
base, state = mock_server
state.hsp_version = "2"
client = ssc.SyncClient(base, "tok")
with pytest.raises(ssc.SyncError):
ssc._check_version(client.capabilities())
wire._check_version(client.capabilities())
def test_push_uploads_and_cas(self, mock_server, synced_env):
base, state = mock_server
@@ -514,7 +516,7 @@ class TestEndToEnd:
assert head == result["head"]
# commit object is present and well-formed
kind, data = state.objects[head]
assert kind == ssc.KIND_COMMIT
assert kind == wire.KIND_COMMIT
commit = json.loads(data)
assert commit["author"]["owner"] == "owner1"
assert commit["parents"] == [] # first commit
@@ -636,7 +638,7 @@ class TestOptInFlag:
class TestSyncManifest:
def test_build_parse_roundtrip(self):
data = ssc.build_sync_manifest_bytes({"beta": True, "alpha": False})
parsed = ssc.parse_sync_manifest(data)
parsed = wire.parse_sync_manifest(data)
assert parsed == {"alpha": False, "beta": True}
def test_manifest_wire_shape(self):
@@ -654,18 +656,18 @@ class TestSyncManifest:
def test_parse_rejects_malformed(self):
# Strict: unknown type, bad version, non-array skills, malformed entry.
assert ssc.parse_sync_manifest(b"not json") is None
assert ssc.parse_sync_manifest(b'{"type":"nope","version":1,"skills":[]}') is None
assert ssc.parse_sync_manifest(b'{"type":"sync-manifest","version":2,"skills":[]}') is None
assert ssc.parse_sync_manifest(b'{"type":"sync-manifest","version":1,"skills":{}}') is None
assert wire.parse_sync_manifest(b"not json") is None
assert wire.parse_sync_manifest(b'{"type":"nope","version":1,"skills":[]}') is None
assert wire.parse_sync_manifest(b'{"type":"sync-manifest","version":2,"skills":[]}') is None
assert wire.parse_sync_manifest(b'{"type":"sync-manifest","version":1,"skills":{}}') is None
assert (
ssc.parse_sync_manifest(
wire.parse_sync_manifest(
b'{"type":"sync-manifest","version":1,"skills":[{"name":"x"}]}'
)
is None
)
# A malformed manifest must NOT be mistaken for "no skills opted in".
assert ssc.parse_sync_manifest(b'{"type":"sync-manifest","version":1,"skills":[]}') == {}
assert wire.parse_sync_manifest(b'{"type":"sync-manifest","version":1,"skills":[]}') == {}
def test_snapshot_embeds_manifest_root_blob(self, mock_server, synced_env):
# snapshot_profile must add a root-level `sync-manifest` blob recording
@@ -914,7 +916,7 @@ class TestOrgEndToEnd:
home, skills, identity = synced_env
identity = {**identity, "org_id": "org-1", "org_role": "ADMIN"}
client = ssc.SyncClient(base, identity["api_key"])
result = ssc.propose_skill("alpha", client, identity=identity)
result = org.propose_skill("alpha", client, identity=identity)
assert result["ok"] is True
assert result.get("merged") is True
head = state.refs["refs/org/org-1/HEAD"]
@@ -930,7 +932,7 @@ class TestOrgEndToEnd:
# Seed an org HEAD as admin first.
admin_ident = {**identity, "org_id": "org-1", "org_role": "ADMIN"}
client = ssc.SyncClient(base, identity["api_key"])
seeded = ssc.propose_skill("alpha", client, identity=admin_ident)
seeded = org.propose_skill("alpha", client, identity=admin_ident)
# Member edits beta and proposes: server converts to 202.
state.org_role_admin = False
@@ -938,7 +940,7 @@ class TestOrgEndToEnd:
"---\nname: beta\n---\nbeta v2 member edit\n", encoding="utf-8"
)
member_ident = {**identity, "org_id": "org-1", "org_role": "MEMBER"}
result = ssc.propose_skill("beta", client, identity=member_ident)
result = org.propose_skill("beta", client, identity=member_ident)
assert result["ok"] is True
assert result.get("proposal_pending") is True
assert result["proposal_id"] == 1
@@ -955,12 +957,12 @@ class TestOrgEndToEnd:
home, skills, identity = synced_env
admin_ident = {**identity, "org_id": "org-1", "org_role": "ADMIN"}
client = ssc.SyncClient(base, identity["api_key"])
ssc.propose_skill("alpha", client, identity=admin_ident)
ssc.propose_skill("beta", client, identity=admin_ident)
org.propose_skill("alpha", client, identity=admin_ident)
org.propose_skill("beta", client, identity=admin_ident)
state.org_role_admin = False
member_ident = {**identity, "org_id": "org-1", "org_role": "MEMBER"}
result = ssc.propose_skill("alpha", client, identity=member_ident)
result = org.propose_skill("alpha", client, identity=member_ident)
# Walk the proposed commit's root: both skills present.
commit = json.loads(state.org_objects[result["commit"]][1])
root = json.loads(state.org_objects[commit["tree"]][1])
@@ -972,9 +974,9 @@ class TestOrgEndToEnd:
home, skills, identity = synced_env
admin_ident = {**identity, "org_id": "org-1", "org_role": "ADMIN"}
client = ssc.SyncClient(base, identity["api_key"])
ssc.propose_skill("alpha", client, identity=admin_ident)
org.propose_skill("alpha", client, identity=admin_ident)
result = ssc.pull_org_skills(client, identity=admin_ident)
result = org.pull_org_skills(client, identity=admin_ident)
assert result["ok"] is True
assert "alpha" in result["updated"]
mirrored = skills / "_org" / "org-1" / "alpha" / "SKILL.md"
@@ -986,7 +988,7 @@ class TestOrgEndToEnd:
home, skills, identity = synced_env
ident = {**identity, "org_id": "org-1", "org_role": "MEMBER"}
client = ssc.SyncClient(base, identity["api_key"])
result = ssc.pull_org_skills(client, identity=ident)
result = org.pull_org_skills(client, identity=ident)
assert result["ok"] is True
assert result["head"] is None
assert result["updated"] == []
@@ -998,7 +1000,7 @@ class TestOrgEndToEnd:
ident = {**identity, "org_id": "org-1", "org_role": "ADMIN"}
client = ssc.SyncClient(base, identity["api_key"])
with pytest.raises(ssc.SyncInertError):
ssc.propose_skill("alpha", client, identity=ident)
org.propose_skill("alpha", client, identity=ident)
def test_maybe_pull_org_inert_without_role(self, monkeypatch):
# Personal org: no org_role claim -> None, never raises.
@@ -1006,7 +1008,7 @@ class TestOrgEndToEnd:
import hermes_cli.auth as auth_mod
monkeypatch.setattr(auth_mod, "resolve_nous_runtime_credentials",
lambda **kw: {"api_key": token})
assert ssc.maybe_pull_org_skills() is None
assert org.maybe_pull_org_skills() is None
class TestOrgEndpointScoping:
@@ -1048,12 +1050,12 @@ class TestOrgEndpointScoping:
client = ssc.SyncClient(base, identity["api_key"])
# `synced_env` already seeds alpha and beta (beta under devops/).
first = ssc.propose_skill("alpha", client, identity=admin)
first = org.propose_skill("alpha", client, identity=admin)
assert first["ok"] is True
# Previously: base_head read as None -> CAS from None -> 409 ->
# SyncConflict escaped to the caller.
second = ssc.propose_skill("beta", client, identity=admin)
second = org.propose_skill("beta", client, identity=admin)
assert second["ok"] is True
# And the splice preserved the first skill rather than replacing it.
@@ -1074,9 +1076,9 @@ class TestOrgEndpointScoping:
home, skills, identity = synced_env
admin = self._admin(identity)
client = ssc.SyncClient(base, identity["api_key"])
ssc.propose_skill("alpha", client, identity=admin)
org.propose_skill("alpha", client, identity=admin)
result = ssc.pull_org_skills(client=client, identity=admin)
result = org.pull_org_skills(client=client, identity=admin)
assert result["ok"] is True
assert result["head"] == state.refs["refs/org/org-1/HEAD"], (
"pull must resolve the real org HEAD, not None"
+3 -2
View File
@@ -254,7 +254,8 @@ class TestThresholdGate:
class TestRetrieval:
def _fake_catalog(self):
"""Build a catalog directly without touching the registry."""
from tools.tool_search import CatalogEntry, _tokenize, _entry_search_text
from tools.tool_search import CatalogEntry
from tools.tool_search_catalog import _tokenize, _entry_search_text
defs = [
_td("github_create_issue", "Open a new issue in a GitHub repository",
{"title": {"type": "string"}, "body": {"type": "string"}}),
@@ -644,7 +645,7 @@ class TestCatalogListing:
assert result.listing_form in {"names", "groups", "mixed"}
def test_short_desc_first_sentence_and_clip(self):
from tools.tool_search import _short_desc
from tools.tool_search_catalog import _short_desc
assert _short_desc("Open an issue. Second sentence dropped.") == "Open an issue."
long = "word " * 40
s = _short_desc(long)
+4 -5
View File
@@ -64,7 +64,7 @@ def issue_defs():
class TestStemming:
def test_tokenize_stems_index_and_query_identically(self):
from tools.tool_search import _tokenize
from tools.tool_search_catalog import _tokenize
# Same stem on both sides is the whole contract.
assert _tokenize("issues") == _tokenize("issue")
assert _tokenize("creating messages") == _tokenize("create message")
@@ -87,7 +87,7 @@ class TestStemming:
assert names == ["mq_slack_post_message"]
def test_single_token_stems_are_cached(self):
from tools.tool_search import _stem, _tokenize
from tools.tool_search_catalog import _stem, _tokenize
_stem.cache_clear()
corpus = "issues creating issues creating"
@@ -102,12 +102,11 @@ class TestStemming:
def test_parallel_tokenize_search_and_dispatch_are_deterministic(self, issue_defs):
from tools.tool_search import (
ToolSearchConfig,
_stem,
_tokenize,
build_catalog,
dispatch_tool_search,
search_catalog,
)
from tools.tool_search_catalog import _stem, _tokenize
corpus = (
"issues",
@@ -174,7 +173,7 @@ class TestStemming:
concurrently on the underlying per-thread instances. A shared
stemmer's mutable parse state produces wrong stems or raises here.
"""
from tools.tool_search import _stem
from tools.tool_search_catalog import _stem
words = ["issues", "creating", "meetings", "categories", "searching"]
+1 -1
View File
@@ -1,7 +1,7 @@
"""Syntax-lint and LSP-diagnostics tier for ``tools.file_operations``.
``ShellFileOperations`` inherits ``LintMixin``; module constants and in-process
linters are pure and re-imported into ``tools.file_operations`` for back-compat.
linters are pure functions importable from this module.
"""
import ast
+2 -1
View File
@@ -279,7 +279,8 @@ def _maybe_auto_propose_org_edit(name: str, skill_path: Path) -> Optional[str]:
f"This skill is shared by your organisation. Your edit is "
f"saved locally and will not be overwritten by org updates. "
f"Run `hermes sync propose {name}` to share it back.")
result = ssc.propose_skill(name)
from tools.skills_sync_client_org import propose_skill
result = propose_skill(name)
if result.get("proposal_pending"):
return (
f"Auto-proposed to your organisation as proposal "
+7 -13
View File
@@ -17,12 +17,13 @@ from contextlib import suppress
from pathlib import Path, PurePosixPath
from typing import Any, Callable, Dict, List, Optional, Tuple
from tools.skills_sync_client_wire import ( # noqa: F401 (re-exports)
DEFAULT_MAX_OBJECT_BYTES, KIND_BLOB, KIND_COMMIT, KIND_TREE, MODE_EXEC, MODE_FILE, ObjectSet,
SyncClient, SyncConflict, SyncError, _check_version, assemble_root_from_skill_trees, build_commit,
build_root_tree, build_sync_manifest_bytes, build_tree, canonical_json_bytes, checked_capabilities,
materialize_tree, merge_skill, nest_skill_tree, parse_sync_manifest, read_manifest_of_root,
read_ref_hash, root_tree_of_commit, skill_trees_of_root, wire_address)
from tools.skills_sync_client_wire import (
DEFAULT_MAX_OBJECT_BYTES, KIND_BLOB, ObjectSet, SyncClient, SyncConflict, SyncError,
assemble_root_from_skill_trees, build_commit, build_root_tree, build_sync_manifest_bytes, build_tree,
checked_capabilities, materialize_tree, merge_skill, nest_skill_tree, read_manifest_of_root,
read_ref_hash, root_tree_of_commit, skill_trees_of_root)
from tools.skills_sync_client_org import (
ORG_DIR_NAME, list_locally_modified_org_skills, list_org_skill_names, resolve_org_identity)
logger = logging.getLogger(__name__)
# Gate claim (NAS's wire name; means "Nous admin" / Permissions.ADMIN_ACCESS). The bearer comes
@@ -495,10 +496,3 @@ def sync_status() -> Dict[str, Any]:
except Exception as e:
logger.debug("skills_sync_client: sync_status org lookup failed: %s", e)
return status
# Imported last: skills_sync_client_org reads this module's state lazily.
from tools.skills_sync_client_org import ( # noqa: E402,F401 (re-exports)
ORG_DIR_NAME, _skill_dir_fingerprint, _write_org_baseline, list_locally_modified_org_skills,
list_org_skill_names, maybe_pull_org_skills, org_skill_is_locally_modified, propose_skill,
pull_org_skills, resolve_org_identity)
+4 -5
View File
@@ -17,12 +17,11 @@ from dataclasses import dataclass
from typing import Any, Dict, Iterable, List, Optional, Tuple
from tools.registry import tool_error
from tools.tool_search_catalog import ( # noqa: F401 — re-exported public/test names
from tools.tool_search_catalog import (
BRIDGE_TOOL_NAMES, CHARS_PER_TOKEN, TOOL_CALL_NAME, TOOL_DESCRIBE_NAME, TOOL_SEARCH_NAME,
CatalogEntry, _corpus_stats, _entry_search_text, _fn, _listing_group_label,
_registry_entry, _registry_toolset, _short_desc, _stem, _tokenize, build_catalog,
build_catalog_listing_with_form, search_catalog)
from tools.tool_search_validation import validate_deferred_call_args # noqa: F401
CatalogEntry, _corpus_stats, _fn, _listing_group_label, _registry_entry, _registry_toolset,
build_catalog, build_catalog_listing_with_form, search_catalog)
from tools.tool_search_validation import validate_deferred_call_args
logger = logging.getLogger("tools.tool_search")
_MAX_QUERIES_PER_CALL = _MAX_DESCRIBE_NAMES_PER_CALL = 10 # bound the work one bridge call requests
+2 -7
View File
@@ -14,13 +14,8 @@ logger = logging.getLogger("tools.web_tools")
# Per-page char budget sent to the model (override: web.extract_char_limit); larger pages are head+tail
# truncated, full text stored on disk.
# ─── Parallel / Tavily / Firecrawl helpers — moved into plugins ────────────── After PR #25182, the
# per-vendor client construction, request helpers, and response normalizers all live in
# plugins.web.<vendor>.provider: - parallel: plugins/web/parallel/provider.py - tavily:
# plugins/web/tavily/provider.py - firecrawl: plugins/web/firecrawl/provider.py The names from the firecrawl
# plugin (Firecrawl proxy, _get_firecrawl_client, _to_plain_object, _normalize_result_list,
# _extract_web_search_results, _extract_scrape_payload, _is_tool_gateway_ready, etc.) are re-exported at the
# top of this module for backward-compat with integration tests and unit-test patches.
# Per-vendor client construction, request helpers and response normalizers live in
# plugins.web.<vendor>.provider (parallel / tavily / firecrawl) since PR #25182.
DEFAULT_EXTRACT_CHAR_LIMIT = 15000
# Ceiling on the full-text file written to cache/web so a multi-MB page can't write unbounded bytes on
# every extract; the model only ever sees char_limit.