mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 10:31:59 +00:00
03c18c4d78
一、文案(前端约 1600 处,后端传到界面的约 880 处) - 术语统一:工作流层面用「运行」(继续运行、重新运行、试运行),节点和工具层面用「执行」,不再用「跑」; 总时长 / 执行时长 / 等待审批;服务端;助手;Agent;token;工作流;固定版本;模型接入;系统提示;批准 / 驳回;受管级别 - 口语改书面:取不到、没取回来、读不懂、连不上、多半、照样、兜底、揉成这类说法改成「无法 / 未能 / 失败 / 请」;去掉拟人写法 - 不再露出实现细节:界面上不写 checkpoint、同一拍、哈希(凭证处除外),不写改版历史(「以前…」「老数据」); 报错、校验、门禁、自动修复里裸写的配置键名和枚举值换成界面上的中文标签,由新的 backend/app/engine/labels.py 统一提供; 发布弹窗里结论句的选项不再是 off / require_citation - 同一件事只留一种说法:生成阶段文案、出具档位说明、画布锁定提示各合成一份 - 去掉界面上的工业场景示例(MES、ERP、「张工」「王工」)和真实产品名,换成通用示例 二、说错的提示(都有测试证明) - 数据库测连接失败时的专用提示以前从不出现(按报错文字全等比较,永远比不上):改为按错误类型判断,不再拼出重复的前缀 - 「设置 → 供应商」改指「设置 → 模型接入」;「在设置里建数据源」改指「数据」页 - 缺少 API Key、模型接入已停用的报错不再夹英文,写明去哪里填 - 两处原因说错(「这种引用在后续版本支持」「这一版还不支持」)改成真实原因 - 节点说明里不准确的说法改正:代码节点在 microVM 或系统沙箱中执行;报告撰写、口径卡的说明不再说过头 - 导出的证据 CSV 里两列同名,第三列改为「片段编号」 三、前后端对齐 - 前端按后端原文识别日志、报错的地方,新旧两种写法都认得(历史运行里存的是旧文字),逐条补了新旧原文的核对 - 后端交回模型的原指令放进单独的 for_model 字段,前端在解析响应时去掉,不会显示到界面上 四、检查 - 检查脚本里按原文匹配的断言改成新文案;改完会变成永远成立的反向断言逐条修正,并做了变异验证 - 新增 check-copy(静态扫描前端界面字符串和后端上界面的文案):再出现「跑」「后端」「多半」「令牌」、 界面上的 Copilot、裸写的配置键名、半角标点等就报错;确需保留的写进 scripts/copy-allow.json 并注明理由
176 lines
7.8 KiB
Python
176 lines
7.8 KiB
Python
"""节点里调工具的公共部分:时限,以及「模型把工具调用写成了文字」的识别。
|
||
|
||
agent 节点、协作成员、工具节点三处都在调工具。以前时限只有数据层自己那一道
|
||
wait_for,而 wait_for 取消之后要等被取消的那一方收完尾:驱动关连接、回滚要排在
|
||
数据库那条语句后面。真实运行里一次「查询超过 30s 被中断」实际等了 90 秒——时限
|
||
到了、取消也发了,控制权却迟迟回不来,报错写的还是 30。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import re
|
||
from dataclasses import dataclass
|
||
from typing import Any, Awaitable
|
||
|
||
QUERY_PREFIX = "db_query__"
|
||
|
||
#: 到了工具自己的时限之后再宽限多久才放弃。工具自己的超时报错(「查询超过 30s
|
||
#: 被中断,加上 WHERE…」)比这里的说法更具体,先让它到;收尾卡住了才由这里兜底
|
||
GRACE_S = 1.0
|
||
|
||
#: 沙箱代码工具的时限来自参数(缺省 30 秒),由沙箱自己执行
|
||
_SANDBOX_TOOLS = {"python_exec", "shell_exec", "run_code"}
|
||
_HTTP_TOOLS = {"http_request", "web_fetch"}
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class Limit:
|
||
"""一次工具调用的时限。seconds 随 tool.start 发给界面,界面据此说「已超出 30s 上限」。"""
|
||
|
||
seconds: float
|
||
#: 到点由引擎收回控制权。只对「时限到了、收尾却可能卡住」的类型开:沙箱的时限
|
||
#: 里还含着起虚拟机、装依赖,按它掐会误杀冷启动;HTTP 客户端自己掐得准
|
||
enforce: bool
|
||
#: query | declared | sandbox | http
|
||
kind: str
|
||
|
||
@property
|
||
def self_timed(self) -> bool:
|
||
"""工具自己也按这个时限掐、并且自己收尾(数据层的查询)。引擎放弃它时只撒手、
|
||
不再取消:它那时多半正在收尾,再取消一次会把关连接那一步打断,连接就再也回不到池里。"""
|
||
return self.kind == "query"
|
||
|
||
|
||
def limit_of(tool: Any, name: str, args: dict[str, Any]) -> Limit | None:
|
||
"""这次调用的时限。说不出来就是 None——界面上不写上限,引擎也不掐。
|
||
|
||
工具自己在 metadata["timeout_s"] 里声明的优先(数字,或者按参数算的函数)。
|
||
"""
|
||
meta = (getattr(tool, "metadata", None) or {}) if tool is not None else {}
|
||
declared = meta.get("timeout_s")
|
||
if callable(declared):
|
||
declared = declared(args)
|
||
if not (isinstance(declared, (int, float)) and not isinstance(declared, bool) and declared > 0):
|
||
declared = None
|
||
if name.startswith(QUERY_PREFIX):
|
||
# 数据层自己按这个时限掐、自己收尾。数据源声明了自己的时限就用它,
|
||
# 否则按模块属性现取数据层的缺省——那个数只有这一处定义
|
||
from app.data import guard
|
||
|
||
return Limit(float(declared or guard.QueryLimits().timeout_seconds), True, "query")
|
||
if declared:
|
||
return Limit(float(declared), True, "declared")
|
||
if name in _SANDBOX_TOOLS:
|
||
try:
|
||
seconds = float(args.get("timeout") or 30)
|
||
except (TypeError, ValueError):
|
||
seconds = 30.0
|
||
return Limit(seconds, False, "sandbox")
|
||
if name in _HTTP_TOOLS:
|
||
from app.core.config import settings
|
||
|
||
return Limit(float(settings.http_tool_timeout), False, "http")
|
||
return None
|
||
|
||
|
||
def limit_fields(limit: Limit | None) -> dict[str, Any]:
|
||
"""tool.start 上附带的时限字段。没有时限就什么都不带,老客户端照旧。"""
|
||
if limit is None:
|
||
return {}
|
||
return {"timeout_s": int(limit.seconds) if limit.seconds.is_integer() else limit.seconds}
|
||
|
||
|
||
def _seconds(value: float) -> str:
|
||
return str(int(value)) if float(value).is_integer() else f"{value:g}"
|
||
|
||
|
||
class ToolTimeout(TimeoutError):
|
||
"""引擎等到时限就放弃了。消息直接给模型和人看:等了多久、为什么放弃、怎么办。"""
|
||
|
||
def __init__(self, name: str, limit: Limit) -> None:
|
||
self.name = name
|
||
self.limit = limit
|
||
seconds = _seconds(limit.seconds)
|
||
if limit.kind == "query":
|
||
text = (f"查询超过 {seconds} 秒没有返回,已停止等待(数据库可能仍在执行,连接会在后台"
|
||
"回收)。请添加 WHERE 条件或 LIMIT 缩小范围后重试")
|
||
else:
|
||
text = f"工具 {name} 超过 {seconds} 秒没有返回,已停止等待"
|
||
super().__init__(text)
|
||
|
||
|
||
async def run_bounded(call: Awaitable[Any], limit: Limit | None, name: str) -> Any:
|
||
"""跑一次工具调用;到了时限(加一点宽限)就收回控制权,不等它收尾。
|
||
|
||
被放弃的那次调用照样被取消,只是它的收尾(关连接、回滚)挪到后台去做完。
|
||
"""
|
||
if limit is None or not limit.enforce:
|
||
return await call
|
||
job = asyncio.ensure_future(call)
|
||
try:
|
||
done, _ = await asyncio.wait({job}, timeout=limit.seconds + GRACE_S)
|
||
except asyncio.CancelledError:
|
||
# 整次运行被取消(用户停止、服务关停):底下那次调用也不该接着跑
|
||
_abandon(job)
|
||
raise
|
||
if job in done:
|
||
return job.result()
|
||
_abandon(job, cancel=not limit.self_timed)
|
||
raise ToolTimeout(name, limit)
|
||
|
||
|
||
def _abandon(job: asyncio.Future[Any], *, cancel: bool = True) -> None:
|
||
if cancel:
|
||
job.cancel()
|
||
# 结果没人要了;把异常取走,免得事件循环报 "exception was never retrieved"
|
||
job.add_done_callback(lambda f: f.cancelled() or f.exception())
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 模型把工具调用写成了文字
|
||
#
|
||
# 有的模型(或它前面那层网关)不认工具调用接口、或者节点根本没绑工具,模型就把自己
|
||
# 训练时那套调用格式当正文吐出来。这一步没有任何真实调用,数据一次都没查到,而文字
|
||
# 看上去像是「在查」。认出来之后先纠正一次,仍然如此就判失败。
|
||
# --------------------------------------------------------------------------
|
||
|
||
_MARKUP = re.compile(
|
||
r"<[||]{1,2}\s*DSML\s*[||]{1,2}" # DeepSeek 的 DSML
|
||
r"|<[||]\s*tool[▁_ ]calls?[▁_ ]begin\s*[||]>" # DeepSeek 的特殊 token
|
||
r"|<\s*(?:tool_call|function_call)\s*>"
|
||
r"|<\s*invoke\s+name\s*=",
|
||
re.IGNORECASE,
|
||
)
|
||
# 只有一段 {"tool": …, "sql": …} 不算:正文里引用一段 JSON 很正常。
|
||
# 得同时自称是「假设」调的,才是在编
|
||
_TOOL_JSON = re.compile(r'\{\s*"tool"\s*:')
|
||
_SQL_KEY = re.compile(r'"sql"\s*:')
|
||
_PRETEND = re.compile(r"假设(?:调用|使用|执行)了?(?:工具|查询)|假装调用|模拟调用(?:了)?工具")
|
||
|
||
TOOL_MARKUP_ERROR = (
|
||
"模型以文本形式输出了工具调用的原始标记,未实际调用工具,这一步没有查询到任何数据。"
|
||
"常见原因:节点未绑定工具,或模型、服务不支持工具调用。"
|
||
"请在画布中为该节点绑定所需工具;如已绑定仍出现此问题,请换用支持工具调用的模型"
|
||
)
|
||
|
||
TOOL_MARKUP_NUDGE = (
|
||
"你上一条回复把工具调用写成了文字,系统没有执行它,也就没有拿到任何数据。"
|
||
"要用工具,请通过工具调用接口发起,不要把调用格式写进正文;手上没有能用的工具,"
|
||
"就直接说明查不到、缺什么,不要假设调用的结果。"
|
||
)
|
||
|
||
|
||
def leaked_markup(text: str | None) -> str | None:
|
||
"""正文里露出来的工具调用标记(截一小段,给警告用)。没有就是 None。"""
|
||
if not text:
|
||
return None
|
||
if m := _MARKUP.search(text):
|
||
return m.group(0)
|
||
if _TOOL_JSON.search(text) and _SQL_KEY.search(text) and (m := _PRETEND.search(text)):
|
||
return m.group(0)
|
||
return None
|
||
|
||
|
||
def markup_warning(snippet: str, who: str = "模型") -> str:
|
||
return f"{who}以文本形式输出了工具调用({snippet[:40]}…),未实际调用工具"
|