Files
AgentLab/backend/app/tools/builtin/web.py
T
ThearchyHelios 964eeaa829 AgentLab:可视化 Agent 编排实验台
在画布上拖拽编排 agent 工作流,并实时看着它执行。

后端 FastAPI + LangGraph:把画布 JSON 编译成 StateGraph,15 种节点
(模型/Agent/多Agent、工具/沙箱、分支/循环/子图、记忆/检索/整形、
人工介入/结构校验)。选 LangGraph 是因为可视化编排真正难的是暂停、
恢复和回放,而 checkpointer + interrupt() 正好把这几件事做在了底层。

前端 React + React Flow:节点元数据驱动属性面板与连接桩,运行时
节点高亮、边流动画、卡片内流式 token 全部由同一份事件流驱动。

关键实现:
- Agent 内工具审批用 @task 缓存模型调用,interrupt 重放时不重复计费
- 沙箱保持只读根文件系统,代码经 exec+base64 写入(put_archive 会被
  只读容器拒绝),断网、非 root、内存/CPU/超时限额
- 检索为向量与 BM25 混合,本地哈希 embedding 零配置可用
- 分支条件走 AST 白名单求值器;HTTP 工具逐 IP 拦截内网与元数据地址
- Mock provider 让无 API Key 也能跑通完整链路

7 个内置模板全部冒烟通过,含真实模型端到端验证。
2026-09-13 17:27:00 +08:00

180 lines
6.1 KiB
Python

from __future__ import annotations
import json
import os
from typing import Any, Literal
from pydantic import BaseModel, Field
from app.tools.net import UnsafeUrlError, safe_request
from app.tools.registry import ToolContext, register
# --------------------------------------------------------------------------
# HTTP 请求
# --------------------------------------------------------------------------
class HttpRequestArgs(BaseModel):
url: str = Field(description="完整的 http/https URL")
method: Literal["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD"] = "GET"
headers: dict[str, str] | None = Field(default=None, description="额外请求头")
params: dict[str, str] | None = Field(default=None, description="查询参数")
json_body: dict[str, Any] | None = Field(default=None, description="JSON 请求体")
@register(
name="http_request",
category="网络",
description="发起 HTTP 请求并返回状态码与响应体。内网地址会被拒绝。",
args_schema=HttpRequestArgs,
dangerous=True,
)
async def http_request(ctx: ToolContext, **kwargs: Any) -> dict[str, Any]:
args = HttpRequestArgs(**kwargs)
try:
result = await safe_request(
args.method,
args.url,
headers=args.headers,
params=args.params,
json_body=args.json_body,
)
except UnsafeUrlError as e:
return {"error": str(e)}
body = result["body"]
if isinstance(body, str) and len(body) > 20_000:
result["body"] = body[:20_000] + "\n…(已截断)"
return result
# --------------------------------------------------------------------------
# 抓网页
# --------------------------------------------------------------------------
class WebFetchArgs(BaseModel):
url: str = Field(description="要抓取的网页地址")
max_chars: int = Field(default=8000, description="返回正文的最大字符数")
def _html_to_text(html: str) -> tuple[str, str]:
"""抽正文。去掉脚本样式导航,保留标题和段落结构。"""
from bs4 import BeautifulSoup
soup = BeautifulSoup(html, "html.parser")
for tag in soup(["script", "style", "noscript", "iframe", "svg", "nav", "footer", "header"]):
tag.decompose()
title = (soup.title.string or "").strip() if soup.title else ""
main = soup.find("article") or soup.find("main") or soup.body or soup
lines: list[str] = []
for el in main.find_all(["h1", "h2", "h3", "h4", "li", "p", "pre", "blockquote"]):
text = " ".join(el.get_text(" ", strip=True).split())
if not text:
continue
if el.name.startswith("h"):
lines.append(f"\n{'#' * int(el.name[1])} {text}")
elif el.name == "li":
lines.append(f"- {text}")
else:
lines.append(text)
body = "\n".join(lines)
if not body.strip():
body = " ".join(main.get_text(" ", strip=True).split())
return title, body
@register(
name="web_fetch",
category="网络",
description="抓取网页并转换成干净的正文文本,适合让模型阅读。",
args_schema=WebFetchArgs,
)
async def web_fetch(ctx: ToolContext, **kwargs: Any) -> dict[str, Any]:
args = WebFetchArgs(**kwargs)
try:
result = await safe_request("GET", args.url, headers={"User-Agent": "AgentLab/0.1"})
except UnsafeUrlError as e:
return {"error": str(e)}
if result["status"] >= 400:
return {"error": f"HTTP {result['status']}", "url": result["url"]}
title, text = _html_to_text(str(result["body"]))
truncated = len(text) > args.max_chars
return {
"url": result["url"],
"title": title,
"content": text[: args.max_chars] + ("\n…(已截断)" if truncated else ""),
"truncated": truncated,
}
# --------------------------------------------------------------------------
# 搜索
# --------------------------------------------------------------------------
class WebSearchArgs(BaseModel):
query: str = Field(description="搜索关键词")
max_results: int = Field(default=5, ge=1, le=20)
async def _tavily(query: str, n: int, key: str) -> list[dict[str, str]]:
result = await safe_request(
"POST",
"https://api.tavily.com/search",
json_body={"api_key": key, "query": query, "max_results": n},
)
data = json.loads(str(result["body"]))
return [
{"title": r.get("title", ""), "url": r.get("url", ""), "snippet": r.get("content", "")}
for r in data.get("results", [])
]
async def _duckduckgo(query: str, n: int) -> list[dict[str, str]]:
"""没有搜索 API key 时的兜底:解析 DuckDuckGo 的 lite 页面。"""
from bs4 import BeautifulSoup
result = await safe_request(
"POST",
"https://lite.duckduckgo.com/lite/",
data=f"q={query}",
headers={
"Content-Type": "application/x-www-form-urlencoded",
"User-Agent": "Mozilla/5.0 (compatible; AgentLab/0.1)",
},
)
soup = BeautifulSoup(str(result["body"]), "html.parser")
out: list[dict[str, str]] = []
for row in soup.select("a.result-link"):
out.append(
{
"title": row.get_text(" ", strip=True),
"url": row.get("href", ""),
"snippet": "",
}
)
if len(out) >= n:
break
return out
@register(
name="web_search",
category="网络",
description="搜索互联网。配了 TAVILY_API_KEY 就走 Tavily,否则退回 DuckDuckGo。",
args_schema=WebSearchArgs,
)
async def web_search(ctx: ToolContext, **kwargs: Any) -> dict[str, Any]:
args = WebSearchArgs(**kwargs)
key = os.environ.get("TAVILY_API_KEY")
try:
if key:
results = await _tavily(args.query, args.max_results, key)
engine = "tavily"
else:
results = await _duckduckgo(args.query, args.max_results)
engine = "duckduckgo"
except Exception as e: # noqa: BLE001 - 搜索失败不该让整个 run 挂掉
return {"error": f"搜索失败:{type(e).__name__}: {e}", "results": []}
return {"engine": engine, "query": args.query, "results": results}