- harness/es.py: append-only event store + проекции (образец — кооп-портал) - harness/levels.py: уровни 1с/1м/1ч/1д, контекст ∝ масштабу - harness/scheduler.py: уровневые потоки, идемпотентные тики, absorbed-стабы - harness/isolated_runner.py + _llm_worker.py: LLM-тик в сабпроцессе (глобальный asyncio-рантайм Strands не переживает 2+ create_harness) - cli.py: init/task/tasks/status/events + requeue (написан самим минутным агентом) - 9router: отдельный ключ sk-simbiot, модель auto (glm-5.3 первым)
206 lines
9.3 KiB
Python
206 lines
9.3 KiB
Python
"""web_fetch: fetch a URL and answer a prompt about its content.
|
|
|
|
Fetches the URL, reduces it to text, and asks a small fast model to answer the prompt over that
|
|
content, returning the answer rather than the raw page so large payloads never reach the main
|
|
agent's context. The summarizer runs on the same provider as the main agent (see
|
|
``resolve_web_fetch_model`` in ``models.py``), so credentials always align.
|
|
|
|
By default the HTTP request runs as ``curl`` inside the agent's ``sandbox`` (the same seam ``shell``
|
|
and the file tools use), so a sandbox with network isolation or egress rules covers ``web_fetch``
|
|
too. ``transport="direct"`` opts back into a plain standard-library request from the harness process.
|
|
Candidate to port into the core SDK later; keep it minimal and SDK-idiomatic.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import contextlib
|
|
import re
|
|
import shlex
|
|
import time
|
|
import uuid
|
|
from typing import Any
|
|
from urllib.parse import urlparse
|
|
from urllib.request import HTTPRedirectHandler, Request, build_opener
|
|
|
|
from strands import Agent
|
|
from strands.models import Model
|
|
from strands.sandbox import Sandbox
|
|
from strands.tools.decorator import tool
|
|
from strands.types.tools import ToolContext
|
|
|
|
from strands_harness.types.agent import WebFetchTransport
|
|
|
|
_USER_AGENT = "strands-harness/1.0"
|
|
_TIMEOUT = 30
|
|
_MAX_BYTES = 5 * 1024 * 1024
|
|
_MAX_CHARS = 50_000
|
|
_CACHE_TTL_SECONDS = 15 * 60
|
|
# The characters RFC 3986 allows anywhere in a URL; anything else (whitespace, quotes, control
|
|
# characters, non-ASCII) is rejected before the URL reaches a shell or a socket.
|
|
_URL_CHARS = re.compile(r"[A-Za-z0-9\-._~:/?#\[\]@!$&'()*+,;=%]+")
|
|
|
|
_SUMMARIZER_PROMPT = (
|
|
"You answer a request about a single fetched web page. Use only the provided content; if it "
|
|
"does not contain the answer, say so plainly. Be concise and factual, and preserve concrete "
|
|
"details (names, numbers, quotes, links) relevant to the request."
|
|
)
|
|
|
|
|
|
def _html_to_text(html: str) -> str:
|
|
html = re.sub(r"(?is)<(script|style)\b.*?</\1>", " ", html)
|
|
text = re.sub(r"(?s)<[^>]+>", " ", html)
|
|
text = re.sub(r"[ \t]+", " ", text)
|
|
text = re.sub(r"\n\s*\n\s*\n+", "\n\n", text)
|
|
return text.strip()
|
|
|
|
|
|
def _validate_url(url: str) -> str:
|
|
"""Return ``url`` stripped, or raise ``ValueError`` unless it is a well-formed http(s) URL."""
|
|
url = url.strip()
|
|
if not _URL_CHARS.fullmatch(url):
|
|
raise ValueError(
|
|
"web_fetch URLs may only contain the characters RFC 3986 allows; percent-encode spaces and non-ASCII "
|
|
"characters (and punycode the host) and retry."
|
|
)
|
|
parts = urlparse(url)
|
|
if parts.scheme not in ("http", "https"):
|
|
raise ValueError(f"web_fetch only supports http(s) URLs, got {url!r}.")
|
|
if not parts.hostname:
|
|
raise ValueError(f"web_fetch URL has no host: {url!r}.")
|
|
return url
|
|
|
|
|
|
def _curl_command(url: str, output: str) -> str:
|
|
# -g keeps ``{}``/``[]`` in the URL literal; --proto/--proto-redir keep the request and any redirect on
|
|
# http(s) (curl would otherwise follow a redirect to ftp://); --fail turns HTTP errors into a non-zero
|
|
# exit; -sS keeps curl's own error text on stderr. The body goes to a file (stdout is decoded text,
|
|
# which would lose the charset and split multi-byte characters) and is truncated in the sandbox before
|
|
# it is read back; stdout carries only the final hop's content type and URL.
|
|
out, part = shlex.quote(output), shlex.quote(output + ".part")
|
|
return (
|
|
f"curl -sSL -g --fail --proto '=http,https' --proto-redir '=http,https' --max-time {_TIMEOUT} "
|
|
f"-A {shlex.quote(_USER_AGENT)} -o {out} -w '%{{content_type}}\\n%{{url_effective}}' -- {shlex.quote(url)} "
|
|
f"&& head -c {_MAX_BYTES} {out} > {part} && mv -f {part} {out}"
|
|
)
|
|
|
|
|
|
def _decode(data: bytes, content_type: str) -> str:
|
|
match = re.search(r"charset=[\"']?([\w.-]+)", content_type, re.IGNORECASE)
|
|
try:
|
|
return data.decode(match.group(1) if match else "utf-8", errors="replace")
|
|
except LookupError:
|
|
return data.decode("utf-8", errors="replace")
|
|
|
|
|
|
def _to_text(data: bytes, content_type: str, resolved_url: str, url: str) -> tuple[str, str]:
|
|
raw = _decode(data, content_type)
|
|
text = _html_to_text(raw) if "html" in content_type.lower() else raw
|
|
return resolved_url.strip() or url, text[:_MAX_CHARS]
|
|
|
|
|
|
async def _fetch_curl(sandbox: Sandbox, url: str) -> tuple[str, str]:
|
|
"""Fetch a validated ``url`` with ``curl`` inside ``sandbox``; raises ``RuntimeError`` when curl fails."""
|
|
output = f"/tmp/strands-web-fetch-{uuid.uuid4().hex}"
|
|
try:
|
|
result = await sandbox.execute(_curl_command(url, output), timeout=_TIMEOUT + 5)
|
|
if result.exit_code != 0:
|
|
raise RuntimeError(result.stderr.strip() or f"curl exited with code {result.exit_code}")
|
|
# The trailer is the last two lines; the content-type line is empty when the header is absent.
|
|
lines = result.stdout.splitlines()
|
|
content_type = lines[-2].strip() if len(lines) >= 2 else ""
|
|
resolved_url = lines[-1].strip() if lines else ""
|
|
data = await sandbox.read_file(output)
|
|
finally:
|
|
with contextlib.suppress(Exception):
|
|
await sandbox.execute(f"rm -f {shlex.quote(output)} {shlex.quote(output + '.part')}", timeout=10)
|
|
return _to_text(data, content_type, resolved_url, url)
|
|
|
|
|
|
class _HttpOnlyRedirects(HTTPRedirectHandler):
|
|
"""Redirect handler that holds every hop to the same URL rules as the original request (urllib's
|
|
default would also follow a redirect to ``ftp://``)."""
|
|
|
|
def redirect_request(self, req, fp, code, msg, headers, newurl): # type: ignore[no-untyped-def]
|
|
_validate_url(newurl)
|
|
return super().redirect_request(req, fp, code, msg, headers, newurl)
|
|
|
|
|
|
def _fetch_direct_sync(url: str) -> tuple[str, str]:
|
|
deadline = time.monotonic() + _TIMEOUT
|
|
opener = build_opener(_HttpOnlyRedirects)
|
|
with opener.open(Request(url, headers={"User-Agent": _USER_AGENT}), timeout=_TIMEOUT) as response:
|
|
# ``timeout`` bounds each socket read; the deadline bounds the whole download.
|
|
chunks: list[bytes] = []
|
|
size = 0
|
|
while size < _MAX_BYTES:
|
|
chunk = response.read1(min(65536, _MAX_BYTES - size))
|
|
if not chunk:
|
|
break
|
|
chunks.append(chunk)
|
|
size += len(chunk)
|
|
if time.monotonic() > deadline:
|
|
raise TimeoutError(f"web_fetch gave up after {_TIMEOUT}s.")
|
|
content_type = response.headers.get("Content-Type", "")
|
|
resolved_url = response.geturl()
|
|
return _to_text(b"".join(chunks), content_type, resolved_url, url)
|
|
|
|
|
|
async def _fetch_direct(url: str) -> tuple[str, str]:
|
|
"""Fetch a validated ``url`` from the harness process with the standard library (bypasses the sandbox)."""
|
|
return await asyncio.to_thread(_fetch_direct_sync, url)
|
|
|
|
|
|
async def _fetch_text(sandbox: Sandbox, url: str, transport: WebFetchTransport) -> tuple[str, str]:
|
|
"""Fetch ``url`` over ``transport`` and reduce it to text. Returns ``(resolved_url, text)``; raises on failure."""
|
|
url = _validate_url(url)
|
|
if transport == "curl":
|
|
return await _fetch_curl(sandbox, url)
|
|
return await _fetch_direct(url)
|
|
|
|
|
|
def make_web_fetch(*, model: Model, transport: WebFetchTransport = "curl") -> Any:
|
|
"""Build a ``web_fetch`` tool whose summarizer answers over the fetched page using ``model``.
|
|
|
|
``transport`` is ``"curl"`` (run inside the agent's sandbox, the default) or ``"direct"`` (from the
|
|
harness process).
|
|
"""
|
|
if transport not in ("curl", "direct"):
|
|
raise ValueError(f"web_fetch transport must be 'curl' or 'direct', got {transport!r}.")
|
|
cache: dict[str, tuple[float, str, str]] = {}
|
|
|
|
@tool(name="web_fetch", context="tool_context")
|
|
async def web_fetch(url: str, tool_context: ToolContext, prompt: str = "") -> str:
|
|
"""Fetch a URL and answer a prompt about its content.
|
|
|
|
Performs an HTTP GET, reduces the response to text, and asks a small fast model to answer
|
|
the prompt using only that content, returning the answer rather than the raw page so large
|
|
pages do not flood the conversation. Fetched content is cached for 15 minutes, so repeated
|
|
fetches of the same URL are fast while each prompt is answered fresh.
|
|
|
|
Args:
|
|
url: The http(s) URL to fetch.
|
|
tool_context: Injected by the framework. Not user-facing.
|
|
prompt: What to extract from or answer about the fetched content. Leave empty to get
|
|
the whole page content back verbatim instead of a model-generated answer.
|
|
"""
|
|
cached = cache.get(url)
|
|
if cached and cached[0] > time.monotonic():
|
|
resolved_url, text = cached[1], cached[2]
|
|
else:
|
|
try:
|
|
resolved_url, text = await _fetch_text(tool_context.agent.sandbox, url, transport)
|
|
except Exception as exc:
|
|
return f"Failed to fetch {url}: {exc}"
|
|
cache[url] = (time.monotonic() + _CACHE_TTL_SECONDS, resolved_url, text)
|
|
|
|
if not prompt.strip():
|
|
return text
|
|
|
|
summarizer = Agent(model=model, system_prompt=_SUMMARIZER_PROMPT, callback_handler=None)
|
|
result = await summarizer.invoke_async(
|
|
f"Fetched URL: {resolved_url}\n\nRequest: {prompt}\n\n--- Content ---\n{text}"
|
|
)
|
|
return str(result)
|
|
|
|
return web_fetch
|