mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 07:02:00 +00:00
587bf7e27c
起因是一次探索运行:Copilot 改节点时把工具绑定整份抹掉,agent 没有工具只能写「假设调用」, 校验节点的自动修复编出 total_count=0 让 schema 通过,协作成员把工具调用标记当文字输出了四轮, 轮数用完后节点把原话当成功结果交出去——运行标成已完成,数据库一次都没查。 平台可信度(先写失败测试) - Copilot 修改节点改为合并:没写的字段保留,null 才删除;协作成员按名字合并。前后端同一套规则, 工具集合变小时自查报警、改图回执列出变化 - 新校验规则:提示词点名了某个工具而节点没有绑定它,判错误,Copilot 自查会交回去修 - 校验修复只许改格式:冒出原文没有的值就判修复无效;修复这次模型调用计入用量 - 识别模型把工具调用写成文字的情况(DSML、<tool_call> 等标记):纠正一次,仍然如此就判失败并说明原因 - 协作团队用完轮数不再按成功交付:默认失败,可选降档,点出一次都没派到的成员 - 审批恢复后,同一节点里已经执行过的工具调用复用结果,不再重复执行 后端 - 查询时限交给数据库执行(Oracle call_timeout、PG statement_timeout、MySQL max_execution_time、 SQLite progress handler),可按数据源配置;超时报错与实际耗时一致 - 等审批的运行可以真正取消;会话列表带最后一轮状态;测连接结果持久化;探查结构可只看不存; 自定义工具可草稿试跑、写坏的参数定义保存时拒绝;MCP 服务改动后作废旧工具缓存; 检索命中带贡献分解;重建索引在后台跑并报进度;报错统一收进 core/errors 前端 - 公共层:管理页共用件、失败说明(lib/explain)、健康状态进公共库;目录按表各自加载并有超时, 一张表卡住不再拖垮整站;上传显示真实字节进度;离线横幅只播报不变的句子 - 画布:卡片只说游标所在的那一次执行;等审批时可放弃运行;团队收尾判定与未完成说清楚; 回放用量取那一刻的值 - 编排页:Copilot 合并语义与工具变化回执;「用完轮数时」选项;正式运行期间锁住所有改图入口 - 回答流 / 问数据:新失败类型说人话并给下一步;画布与右栏互相定位;数据源范围、回收站、重新核对 - 记录页:「航迹」页签复用画布时间轴,可拖动回放、列出关键时刻;列表行带执行 / 等人 / 失败缩略条 - 外壳:统一的离开前确认(data router),导航、⌘K、快捷键、后退都会先问 验证(沙箱):tsc 干净,pytest 838 passed(两遍),14 个检查脚本中 13 个全绿; check-ui 余 1 项旧断言(按旧日期格式找运行行),3c 更新。
182 lines
6.2 KiB
Python
182 lines
6.2 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 挂掉
|
|
from app.core.errors import explain
|
|
|
|
return {"error": f"搜索失败:{explain(e)[0]}", "results": []}
|
|
return {"engine": engine, "query": args.query, "results": results}
|