mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-31 04:45:23 +08:00
205 lines
7.3 KiB
Python
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)))
|