Files
LangBot/plugins/misc/WebSearch/components/tools/visit_web.py

205 lines
7.3 KiB
Python

# Auto generated by LangBot Plugin SDK.
# Please refer to https://langbot.app/docs/en/plugin/dev/tutor.html for more details.
from __future__ import annotations
import asyncio
import re
import threading
import weakref
from typing import Any
from urllib.parse import urlsplit, urlunsplit
from langbot_plugin.api.definition.components.tool.tool import Tool
from .mux import process
import logging
MAX_CONCURRENT_FETCHES = 4
"""Upper bound on page fetches one process keeps in flight for this tool."""
FETCH_SLOT_WAIT_SECONDS = 10.0
"""How long a call may wait for a free fetch slot before it is rejected."""
_slots_by_loop: "weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, asyncio.Semaphore]" = (
weakref.WeakKeyDictionary()
)
_slots_guard = threading.Lock()
def _slots() -> asyncio.Semaphore:
"""Return the bounded fetch-slot pool of the running event loop.
``asyncio.Semaphore`` binds to the loop that first waits on it, while the
shared runtime and the test suite each drive their own loop, so the pool is
keyed by loop. It holds no tenant data.
"""
loop = asyncio.get_running_loop()
with _slots_guard:
slots = _slots_by_loop.get(loop)
if slots is None:
slots = asyncio.Semaphore(MAX_CONCURRENT_FETCHES)
_slots_by_loop[loop] = slots
return slots
class _SlotLease:
"""One acquired fetch slot, released at most once.
Ownership of the slot moves from the calling task to the worker thread on a
successful executor submission. Both sides call :meth:`release` on their
exit paths - the caller only when the submission itself raised - and the
lease makes that release idempotent, so a slot is never given back twice
(which would push the pool above its cap) and never leaked.
"""
def __init__(self, slots: asyncio.Semaphore, loop: asyncio.AbstractEventLoop) -> None:
self._slots = slots
self._loop = loop
self._lock = threading.Lock()
self._released = False
def release(self) -> None:
with self._lock:
if self._released:
return
self._released = True
try:
running = asyncio.get_running_loop()
except RuntimeError:
running = None
if running is self._loop:
# Called from the loop thread (a failed submission): release now.
self._slots.release()
return
try:
self._loop.call_soon_threadsafe(self._slots.release)
except RuntimeError:
# The loop is gone (plugin unloaded); nothing left to bound.
pass
def _consume_result(future: "asyncio.Future") -> None:
"""Mark a worker result as retrieved when nobody awaits it.
A cancelled call stops awaiting the worker, but the worker still finishes;
reading the outcome keeps a later failure from being reported as an
unretrieved future exception.
"""
if future.cancelled():
return
try:
future.exception()
except BaseException:
pass
def _worker(
url: str,
brief_len: int,
stop_event: threading.Event,
lease: _SlotLease,
) -> str:
"""Run the blocking adapter dispatch inside a worker thread.
The slot is released here, not in the async caller: cancelling the
invocation cannot interrupt a thread that is already downloading, so
releasing earlier would let abandoned downloads pile up past the bound.
The worker owns the slot from a successful submit onwards, which is what
makes the release happen exactly once - even when the caller was cancelled
while the work was still queued.
"""
try:
return process(url, brief_len, stop_event=stop_event)
finally:
lease.release()
def _redact(url: str) -> str:
"""Drop userinfo, the query string and the fragment from a URL."""
try:
parts = urlsplit(url)
host = parts.hostname
try:
port = parts.port
except ValueError:
port = None
except (TypeError, ValueError, AttributeError):
return _strip_url_tail(str(url))
if not host:
return _strip_url_tail(str(url))
netloc = host if port is None else f"{host}:{port}"
return urlunsplit((parts.scheme, netloc, parts.path, '', ''))
_URL_IN_TEXT = re.compile(r"[A-Za-z][A-Za-z0-9+.\-]*://[^\s'\"<>()\[\],;]+")
def _strip_url_tail(value: str) -> str:
"""Fallback scrub for a URL that :func:`urlsplit` cannot parse."""
head = re.split(r'([?#])', value, maxsplit=1)[0]
return re.sub(r'//[^/@]*@', '//', head)
def _redact_text(text: str) -> str:
"""Redact credentials/query/fragment from every URL inside ``text``."""
if not text:
return text
return _URL_IN_TEXT.sub(lambda match: _redact(match.group(0)), str(text))
class VisitWeb(Tool):
async def call(self, params: dict[str, Any]) -> dict[str, Any]:
"""Fill your tool code here"""
try:
if 'brief_len' not in params:
params['brief_len'] = 4096
url = params['url']
logging.info("start visit web: %s", _redact(url))
# The site adapters fetch and parse synchronously, so the whole call
# runs in a worker thread: the shared event loop keeps serving other
# tenants. Slot accounting and cancellation are described in
# _worker/safe_fetch.
loop = asyncio.get_running_loop()
slots = _slots()
try:
await asyncio.wait_for(slots.acquire(), timeout=FETCH_SLOT_WAIT_SECONDS)
except asyncio.TimeoutError:
return "error visit web: too many concurrent page fetches, please retry later"
stop_event = threading.Event()
lease = _SlotLease(slots, loop)
# Slot ownership, exactly once:
# * before a successful submit the calling task owns the slot;
# * if the submit itself raises, the caller is still the owner and
# releases through the lease (no worker ever existed);
# * after a successful submit the worker thread owns it and
# releases through the same lease in its own ``finally``, once it
# has ended. ``release()`` is idempotent, so the two owners can
# never give the same slot back twice.
try:
future = loop.run_in_executor(
None, _worker, url, params['brief_len'], stop_event, lease
)
except BaseException:
lease.release()
raise
future.add_done_callback(_consume_result)
try:
# ``shield`` stops an await-cancellation from cancelling the
# queued executor future: a worker that has not started yet must
# still run its cleanup, otherwise its slot would leak forever.
return await asyncio.shield(future)
except asyncio.CancelledError:
# Cancelling the future does not stop the already-running
# thread; the flag makes the fetch end its I/O and release the
# slot when the thread really returns.
stop_event.set()
raise
except Exception as e:
logging.error("[Webwlkr] error visit web: %s", _redact_text(str(e)))
return "error visit web:{}".format(_redact_text(str(e)))