Files
AgentLab/backend/app/core/errors.py
T
ThearchyHelios 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 更新。
2026-09-27 10:16:32 +08:00

212 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""面向用户的报错:说清发生了什么、为什么、怎么办,不把 Python 异常类名端上去。
以前各处都是 f"{type(e).__name__}: {e}",界面首行红字是
「_make_query_tool.<locals>._run() got an unexpected keyword argument 'query'」
——对业务用户是天书,也没有下一步。原始异常不丢:调用方把 raw(e) 放进响应或
事件的 detail 字段、写进日志,排查时照样找得到。
这里曾经是两份:接口层一份(测连接这类回「原因 + 怎么办」),执行层一份(节点
报错首行只要原因)。两份各认一套异常类名、各写一套说法,同一个 401 在设置页和
运行记录里是两句话。现在认异常的规则只有下面这一张表,两个入口只在一件事上分叉:
TypeError、KeyError 这类「程序自己的毛病」——
- 接口里冒出来的,是后端的 bug,原文里全是内部符号,说「后端内部出错了」;
- 节点里冒出来的,多半是工具被传错了参数、用户代码取错了字段,说清是哪个参数、
哪个字段,模型和人才知道该改什么。
判断按类名走 MRO,不 import 各家 SDK:没装的驱动和 SDK 不该让报错这一层也跟着
ImportError。
"""
from __future__ import annotations
import json
import re
from typing import Any
_TIMEOUT = {"TimeoutError", "TimeoutException", "APITimeoutError", "ReadTimeout",
"ConnectTimeout", "PoolTimeout", "WriteTimeout"}
_NETWORK = {"ConnectError", "ConnectionError", "APIConnectionError", "gaierror",
"ClientConnectorError", "RemoteProtocolError", "ReadError", "NetworkError"}
_AUTH = {"AuthenticationError", "PermissionDeniedError", "InvalidPasswordError",
"InvalidAuthorizationSpecificationError"}
#: 这几类是程序自己的毛病,str(e) 里全是内部符号,原样端上去只会更糊涂
_BUG = {"TypeError", "AttributeError", "KeyError", "NameError", "IndexError",
"UnboundLocalError", "AssertionError", "RecursionError"}
_UNREACHABLE = ("connection refused", "nodename nor servname", "name or service not known",
"all connection attempts failed", "network is unreachable")
#: SQLAlchemy 包过的驱动异常开头是「(sqlite3.OperationalError) 」
_WRAPPED = re.compile(r"^\([\w.]+\)\s*")
# 内部符号:`a.b.<locals>._run()`、`<function f at 0x…>`、模块路径。对使用者没有意义
_LOCALS = re.compile(r"[\w.]*<locals>\.?[\w.]*(\(\))?")
_REPR = re.compile(r"<(?:function|bound method|coroutine|object) [^>]*>")
# 包在消息里的异常类名("连不上:ConnectError: All connection attempts failed")
_CLASS = re.compile(r"\b[A-Z]\w*(?:Error|Exception|Warning)\b:\s*")
def raw(e: BaseException) -> str:
"""原始报错,给「技术细节」折叠区、事件的 detail 字段和日志用。"""
text = str(e).strip()
return (f"{type(e).__name__}: {text}" if text else type(e).__name__)[:2000]
def raw_detail(e: BaseException | None) -> str | None:
"""同 raw,没有异常时是 None——事件里 detail 字段就不带。"""
return None if e is None else raw(e)
def first_line(e: BaseException) -> str:
"""异常自己的说明,去掉驱动包装的类名前缀和 SQLAlchemy 的文档链接。"""
text = str(e).strip()
line = text.splitlines()[0] if text else ""
return _WRAPPED.sub("", line)[:300]
def _names(e: BaseException) -> set[str]:
return {c.__name__ for c in type(e).__mro__}
def _status(e: BaseException) -> int | None:
for obj in (e, getattr(e, "response", None)):
code = getattr(obj, "status_code", None)
if isinstance(code, int):
return code
return None
def _scrub(text: str) -> str:
text = _LOCALS.sub("内部函数", text)
text = _REPR.sub("内部对象", text)
text = _CLASS.sub("", text)
return " ".join(text.split())[:240]
def _known(e: BaseException, *, sniff: bool) -> tuple[str, str] | None:
"""两种场景说法一样的那些:鉴权、找不到、限流、对方故障、超时、网络、格式、文件。
sniff:类名认不出时要不要按原文字面猜。SDK 抛出来的裸异常要猜(原文就是
"Connection refused");执行层的异常常常是已经拼好的一句话、里头套着具体原因,
按字面猜会把那个原因换成笼统的一句。
"""
names = _names(e)
low = str(e).lower() if sniff else ""
status = _status(e)
if status in (401, 403) or names & _AUTH or "invalid api key" in low or "unauthorized" in low:
code = f"({status})" if status else ""
return (f"鉴权没通过{code}:对方拒绝了这把密钥",
"核对 API Key 有没有填错、过期,或者这个账号有没有这个模型的权限")
if status == 404 or "NotFoundError" in names:
return "对方说找不到(404)", "核对地址的路径(常见的是少了或多了 /v1)和模型名"
if status == 429 or "RateLimitError" in names:
return "请求太频繁,或者额度用完了(429)", "等一会儿再试;频繁出现就检查账号额度"
if isinstance(status, int) and status >= 500:
return f"对方服务出错了(HTTP {status})", "这是对方的问题,多半是暂时的,稍后再试"
if isinstance(e, TimeoutError) or names & _TIMEOUT or "timed out" in low:
return ("等待超时:对方没有在限定时间内响应",
"对方可能很忙或者网络不稳,稍后再试;一直超时就检查地址和网络")
if isinstance(e, ConnectionError) or names & _NETWORK or any(s in low for s in _UNREACHABLE):
return ("连不上对方的服务:网络不通,或者服务没有启动",
"核对地址和端口,确认服务已经启动、这台机器访问得到它")
if isinstance(e, json.JSONDecodeError):
return "返回的内容不是合法的 JSON", "地址多半指到了网页而不是 API,核对 Base URL"
if "ValidationError" in names and callable(getattr(e, "errors", None)):
return f"参数不对:{_first_validation(e)}", ""
if isinstance(e, PermissionError):
return "没有权限访问需要的文件或资源", ""
if isinstance(e, FileNotFoundError):
return f"找不到文件{'「' + str(e.filename) + '」' if e.filename else ''}", ""
if isinstance(e, (OverflowError, MemoryError)):
text = str(e).strip()
return f"数据超出了能处理的范围{':' + _scrub(text) if text else ''}", ""
return None
def explain(e: BaseException) -> tuple[str, str]:
"""接口层:(原因, 怎么办)。原因是一句人话;说不出怎么办时第二项是空串。"""
known = _known(e, sniff=True)
if known:
return known
if _names(e) & _BUG:
return "后端内部出错了", "这不是你操作的问题;把技术细节发给维护者"
return first_line(e) or "出了一个没有说明的错误", ""
def describe_exception(e: BaseException) -> str:
"""执行层:原因,一句话。「发生了什么」和「怎么办」由节点按场景拼。"""
text = str(e).strip()
if isinstance(e, TypeError):
if m := re.search(r"unexpected keyword argument '(\w+)'", text):
return f"参数对不上:它不接受名为「{m.group(1)}」的参数"
if m := re.search(r"missing \d+ required (?:positional |keyword-only )?arguments?: (.+)$", text):
wanted = "、".join(re.findall(r"'(\w+)'", m.group(1))) or m.group(1)
return f"缺少必填参数「{wanted}」"
if isinstance(e, KeyError):
return f"缺少字段「{e.args[0] if e.args else '?'}」"
known = _known(e, sniff=False)
if known:
return known[0]
return _scrub(text) or "没有给出原因的内部错误"
def message(e: BaseException, lead: str = "") -> str:
"""一整句:lead + 原因 + 怎么办。给只能放一个字符串的地方(HTTPException 的 detail)。"""
reason, hint = explain(e)
text = f"{lead}{reason}" if lead else reason
return f"{text}。{hint}" if hint else text
def payload(e: BaseException, lead: str = "") -> dict[str, Any]:
"""{ok: false, error, hint, detail}:测试连接这类「失败也是正常结果」的接口用。"""
reason, hint = explain(e)
return {"ok": False, "error": f"{lead}{reason}", "hint": hint, "detail": raw(e)}
def not_configured(e: BaseException) -> str:
"""模型接入没配好时的原话用的是内部叫法(provider 'x'、base_url),换成设置页上的说法。"""
text = re.sub(r"provider '([^']*)'", r"模型接入「\1」", str(e))
text = re.sub(r"模型 '([^']*)'", r"模型「\1」", text)
return text.replace("base_url", "Base URL").rstrip("。")
def _has_cjk(text: str) -> bool:
return any("一" <= ch <= "鿿" for ch in text)
def _first_validation(e: Any) -> str:
err = (e.errors() or [{}])[0]
loc = ".".join(str(p) for p in err.get("loc") or ())
msg = str(err.get("msg") or "").removeprefix("Value error, ")
if _has_cjk(msg):
return msg
return f"「{loc}」{'缺了' if err.get('type') == 'missing' else '格式不对'}" if loc else "格式不对"
def graph_error(e: BaseException) -> str:
"""工作流结构读不懂时,指到是哪个节点、哪一项。
pydantic 的原文是一大段英文,还列了全部十几种节点类型和一个文档链接,
用户从里面找不到自己错在哪。
"""
errors = e.errors() if callable(getattr(e, "errors", None)) else None
if not errors:
return explain(e)[0]
err = errors[0]
loc = list(err.get("loc") or ())
msg = str(err.get("msg") or "").removeprefix("Value error, ")
more = f"(另有 {len(errors) - 1} 处)" if len(errors) > 1 else ""
if len(loc) >= 2 and loc[0] in ("nodes", "edges") and isinstance(loc[1], int):
where = f"第 {loc[1] + 1} 个{'节点' if loc[0] == 'nodes' else '连线'}"
field = str(loc[2]) if len(loc) > 2 else ""
if err.get("type") == "enum" and field == "type":
return f"{where}的类型「{err.get('input')}」不认识{more}"
if err.get("type") == "missing":
return f"{where}缺了「{field}」{more}"
if _has_cjk(msg):
return f"{where}:{msg}{more}"
return f"{where}的「{field or '内容'}」格式不对{more}"
if _has_cjk(msg):
return msg + more
return f"「{'.'.join(str(p) for p in loc) or '整体'}」格式不对{more}"