Files
AgentLab/backend/app/tools/custom.py
T
ThearchyHelios 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 并注明理由
2026-09-30 08:05:15 +08:00

246 lines
11 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.
from __future__ import annotations
import json
from typing import Any
from langchain_core.tools import BaseTool, StructuredTool
from pydantic import BaseModel, Field, create_model
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import CustomTool
from app.engine.expressions import render_deep, render_template
from app.sandbox.base import SandboxLimits
from app.sandbox.manager import sandbox_manager
from app.tools.net import UnsafeUrlError, safe_request
from app.tools.registry import ToolBuildError, ToolContext, prepare_args
_TYPES = {
"string": str,
"number": float,
"integer": int,
"boolean": bool,
"array": list,
"object": dict,
}
#: 手写参数定义时常见的类型写法(Python 的、其他语言的、缩写)。只用来在报错里
#: 猜「是不是想写 X」,不自动放行:悄悄按猜的类型建出来,试跑和运行对不上还没人知道
_TYPE_ALIASES = {
"int": "integer", "long": "integer", "float": "number", "double": "number",
"decimal": "number", "str": "string", "text": "string", "bool": "boolean",
"list": "array", "dict": "object", "map": "object", "json": "object",
}
def _type_guess(value: Any) -> str | None:
"""value 像是哪个合法类型;认不出来返回 None。"""
if not isinstance(value, str):
return None
low = value.strip().lower()
return low if low in _TYPES else _TYPE_ALIASES.get(low)
def schema_to_model(name: str, schema: dict[str, Any]) -> type[BaseModel]:
"""把 JSON Schema 转成 pydantic 模型,好让它能绑成 LangChain 工具。"""
props = (schema or {}).get("properties") or {}
required = set((schema or {}).get("required") or [])
fields: dict[str, Any] = {}
for key, spec in props.items():
py_type = _TYPES.get(spec.get("type", "string"), str)
description = spec.get("description", "")
if key in required:
fields[key] = (py_type, Field(description=description))
else:
fields[key] = (py_type | None, Field(default=spec.get("default"), description=description))
if not fields:
fields["input"] = (str, Field(default="", description="工具输入"))
return create_model(f"{name}_Args", **fields) # type: ignore[call-overload]
def schema_problem(schema: Any) -> str | None:
"""参数定义哪里写得不对;没问题返回 None。
参数定义是用户在编辑器里手写的 JSON。schema_to_model 只认 {"type": "integer"}
这样的写法,写成 "int" 或 {"type": "int"} 时要么炸在 .get 上、要么悄悄当成
字符串——前者会被说成「后端内部出错了」,后者试跑通过、到了模型那边类型不对。
"""
if not isinstance(schema, dict):
return "参数定义必须是一个 JSON 对象"
props = schema.get("properties")
if props is not None and not isinstance(props, dict):
return '参数定义格式有误:properties 必须是一个对象,每个参数一项,例如 {"n": {"type": "integer"}}'
for key, spec in (props or {}).items():
if not str(key).strip():
return "参数定义格式有误:存在空的参数名,请为其命名"
if str(key).startswith("_"):
return (f"参数定义格式有误:参数名 {key} 不能以下划线开头,"
f"请改为 {str(key).lstrip('_') or 'value'} 这样的名称")
if str(key).startswith("model_") and hasattr(BaseModel, str(key)):
return f"参数定义格式有误:参数名 {key} 与内部保留的名称冲突,请换一个名称"
if not isinstance(spec, dict):
# 示例照着他写的那个类型给,照抄就能改好:写了 "string" 却让人改成 integer 只会添乱
shown = json.dumps(spec, ensure_ascii=False)
meant = _type_guess(spec) or "string"
return (f'参数定义格式有误:参数 {key} 应写成 {{"type": "{meant}"}} 这样的对象,'
f"不能直接写 {shown}")
kind = spec.get("type", "string")
if not isinstance(kind, str) or kind not in _TYPES:
shown = kind if isinstance(kind, str) else json.dumps(kind, ensure_ascii=False)
meant = _type_guess(kind)
guess = f",是否应为 {meant}" if meant else ""
return (f"参数定义格式有误:无法识别参数 {key} 的类型「{shown}」{guess};"
f"只能是 {'、'.join(_TYPES)} 中的一个")
if not isinstance(spec.get("description", ""), str):
return f"参数定义格式有误:参数 {key} 的 description 必须是文字"
required = schema.get("required")
if required is not None and (
not isinstance(required, list) or not all(isinstance(k, str) for k in required)
):
return '参数定义格式有误:required 必须是参数名的列表,例如 ["n"]'
# 上面没认出来的写法,最后按运行时同一个转换真建一次:放行的一定建得出来
import warnings
try:
with warnings.catch_warnings():
warnings.simplefilter("ignore") # 和 BaseModel 同名的参数(json、copy)只是警告,照样能用
schema_to_model("probe", schema)
except Exception: # noqa: BLE001
return "参数定义格式有误:无法据此生成工具的参数表,请检查参数名和每个参数的 type"
return None
def broken_tool_message(name: str, problem: str) -> str:
"""库里存着的坏工具被节点绑上时的报错:哪个工具、坏在哪、去哪里改。"""
return f"自定义工具「{name}」的{problem}。到「工具」页修改参数定义后再运行"
class ToolFailure(dict):
"""工具自己报的失败:沙箱里代码出错、地址被拦、请求体不是 JSON。
运行时照旧当成 {"error": …} 交给模型——它看了会换个参数再试;但试跑时要
认得出这是失败,不能在界面上标「成功」再贴一段报错。所以是个 dict 的子类:
序列化出来和原来一模一样,只多了一个能 isinstance 的身份。
"""
async def _run_http(row: CustomTool, args: dict[str, Any]) -> Any:
"""HTTP 模板工具:url / headers / body 里可以用 {{ 参数名 }} 插值。"""
config = row.config or {}
ctx = dict(args)
url = render_template(config.get("url", ""), ctx)
headers = render_deep(config.get("headers") or {}, ctx)
body = config.get("body")
json_body = None
if body:
rendered = render_deep(body, ctx) if isinstance(body, dict) else render_template(str(body), ctx)
json_body = rendered if isinstance(rendered, dict) else json.loads(rendered or "null")
try:
result = await safe_request(
config.get("method", "GET"),
url,
headers={k: str(v) for k, v in (headers or {}).items()},
json_body=json_body,
timeout=config.get("timeout"),
)
except UnsafeUrlError as e:
return ToolFailure(error=str(e))
except json.JSONDecodeError as e:
return ToolFailure(error=f"请求体不是合法 JSON:{e}")
text = str(result.get("body", ""))
if config.get("parse_json"):
try:
return {"status": result["status"], "data": json.loads(text)}
except json.JSONDecodeError:
return {"status": result["status"], "body": text}
return {"status": result["status"], "body": text}
async def _run_python(row: CustomTool, args: dict[str, Any], ctx: ToolContext) -> Any:
"""Python 工具:用户代码跑在沙箱里,参数通过 args 变量注入。"""
config = row.config or {}
code = config.get("code", "")
preamble = (
"import json, sys\n"
f"args = json.loads({json.dumps(json.dumps(args, ensure_ascii=False))})\n"
)
result = await sandbox_manager.run(
preamble + code,
language="python",
limits=SandboxLimits(timeout=int(config.get("timeout") or 30)),
session_id=ctx.sandbox_session,
)
if not result.ok:
return ToolFailure(error=result.error or result.stderr or "执行失败", exit_code=result.exit_code)
out = result.stdout.strip()
try:
return json.loads(out)
except json.JSONDecodeError:
return out
async def build_custom_tools(
names: list[str], ctx: ToolContext, session: AsyncSession
) -> list[BaseTool]:
rows = list(
(
await session.execute(
select(CustomTool).where(
CustomTool.name.in_(names), CustomTool.enabled.is_(True)
)
)
).scalars()
)
# 保存时已经拒掉写坏的参数定义,但那道关卡之前存进去的还在库里。不交给
# schema_to_model 去炸——那样节点只能报一句「'str' object has no attribute 'get'」,
# 看不出是哪个工具。也不悄悄跳过它接着跑:少了一个它指望的工具,模型只会
# 假装调过、编出一个答案。绑了它的节点失败,协作成员这一步记为失败交给调度者
broken = [broken_tool_message(row.name, problem) for row in rows
if (problem := schema_problem(row.parameters or {}))]
if broken:
raise ToolBuildError(";".join(broken))
tools: list[BaseTool] = []
for row in rows:
args_model = schema_to_model(row.name, row.parameters or {})
def _make(bound_row: CustomTool):
async def _run(**kwargs: Any) -> str:
if bound_row.kind == "python":
value = await _run_python(bound_row, kwargs, ctx)
else:
value = await _run_http(bound_row, kwargs)
if isinstance(value, str):
return value
return json.dumps(value, ensure_ascii=False, default=str, indent=2)
return _run
tools.append(
StructuredTool(
name=row.name,
description=row.description or f"自定义工具 {row.name}",
args_schema=args_model,
coroutine=_make(row),
func=None,
# 信任三档按它认工具(trust.py);和工具列表里的 id 一致
metadata={"trust_key": row.name},
)
)
return tools
async def trial(row: CustomTool, args: dict[str, Any], ctx: ToolContext) -> tuple[Any, str | None]:
"""试跑一次,参数的处理和运行时一样:先按参数 schema 校验、纠正,再执行。
row 可以是还没保存的草稿。以前试跑直接把参数原样塞进去,运行时却要过
schema——试跑通过的参数,到了工作流里被拒。参数不对抛 ToolArgsError。
返回 (结果, 纠正说明或 None)。参数名写错被替换时,运行时会把说明报出去;
试跑也得报,否则用户照着一份「跑通了」的错参数去配工作流。
"""
payload, note = prepare_args(schema_to_model(row.name or "draft", row.parameters or {}), args)
if row.kind == "python":
return await _run_python(row, payload, ctx), note
return await _run_http(row, payload), note