Files
AgentLab/backend/app/tools/custom.py
ThearchyHelios 941723b17c 端到端验收修复与指标出具:
- 身份与工具:登录页建第一个管理员(认人接口的 401 说明能不能建);搭建者有只读的人和组名录,选人给候选并核对名字;授权三行带标题;端点请求头名校验(空名、非法名存不进、发不出时说清原因);写入能力在工具页试运行一律演练、不发送,HTTP 状态照实说成败;写记忆不算只读、查询能力只能选同一端点上的只读能力;审批与遮罩的说法和实际规则一致;开登录时不要署名、导入和发布记登录的人;使用者不显示推演等只给搭建者的操作,403 按是否开登录说话;登录页只认一次人
- 流程与实例:图里用到的岗位(派给谁、超时升级给谁、发送确认派给谁)全部派生、没绑人的发布时报错;写外部系统的参数按能力声明的类型收拢后再算哈希和发送,收不成就不发;发起人的实例页只看这一步的状态,处理人才给待办卡;会签进度带登录核实和时间,升级后照实写规则;实例状态写流程的结论(已退回、未确认、等待核实写入结果),核实生效后不再挂失败原因;流程应用首页不派生最近封存运行的结果块,看板没数据时是安静的空态;样例⑩的金额带类型和下限、待办标题带金额,表单上下限前后端一起生效
- 看板与证据:定时任务模板加 ISO 周、上一 ISO 周、本月、上月;出处链穿过原样解析查询结果的整形节点,追不到原始数据的照实说停在哪里,样例⑨改用 cell();应用首页和版本历史都能做成应用;指标块单位不重复;版本列表带退役说明;定时任务页头跟着刷新;删视图要确认、新视图能起名;编辑器属性用中文名、术语带解释
- 指标出具(用户 2026-10-15 拍板):契约只写「指标来自」、上游没有报告撰写节点、成果里没有模型写的话时按指标出具——必需指标齐、指标输入追到原始数据、成果字段里的数回指得上才完整出具;受管级要求每个指标静态地追到原始数据(governed.metric_untraced);出具横幅和应用印章写明按指标
- 受管工作流降为已发布只给管理员(Q8 顺延,服务端同样拦);工作流接口带当前发布版的级别,画布上改过之后也认得出受管工作流
2026-10-15 07:15:37 +08:00

410 lines
20 KiB
Python
Raw Permalink 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.capabilities import secrets as vault
from app.capabilities.endpoints import bound_endpoint_ref, find_endpoint
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 里可以用 {{ 参数名 }} 插值。headers 的值存的是密文,这里解开再插值。"""
config = row.config or {}
ctx = dict(args)
url = render_template(config.get("url", ""), ctx)
headers = render_deep(vault.reveal(config.get("headers")), ctx)
if problem := vault.header_problem(headers):
# 发不出去的头(空名字、换行):照实说是哪一项,不是一句 LocalProtocolError
return ToolFailure(error=f"自定义工具「{row.name}」的{problem},请到「工具」页修改请求头")
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_info(session: AsyncSession, run_id: str) -> tuple[bool, str | None, bool]:
"""(正式运行?, 发起人, 是不是在一次真实的运行里)。工具库试用、试跑不在运行里:按探索运行算,发起人是当前请求的人。"""
from app.db.models import Run
run = await session.get(Run, run_id) if run_id else None
if run is None:
from app.api.deps import current_principal
return False, current_principal().username, False
return run.run_class == "formal", run.started_by, True
#: 效果从宽到严:只读、视参数而定、写入
_STRICTNESS = {"read": 0, "dynamic": 1, "write": 2}
def _system_effect(row: CustomTool) -> str:
"""按同步的规矩(capabilities.registry.sync_all)算这一行的效果:绑定端点的按方法,GET / HEAD 为只读、其余为写入;
别的视参数而定。"""
if bound_endpoint_ref(row):
return "read" if str((row.config or {}).get("method") or "GET").upper() in ("GET", "HEAD") else "write"
return "dynamic"
def _effect_of(row: CustomTool, cap: Any) -> str:
"""治理后的效果;登记里还没有(刚建、没保存的草稿)按同步的规矩算。
治理是对登记时的那个样子做的:手上这一行按同步的规矩算出来的和登记里的(effect_system)对不上——工具页试运行的草稿
把 GET 改成了 POST——取两者里更严的一个,不然草稿凭已保存那一版的「只读」把写入真发出去。对得上的(保存过的那一行)
照治理的走:管理员把一个用 POST 的查询接口治理成只读,是他的决定。"""
from app.capabilities.registry import effective_effect
own = _system_effect(row)
if cap is None:
return own
governed = effective_effect(cap)
if own == getattr(cap, "effect_system", own):
return governed
return max(governed, own, key=lambda e: _STRICTNESS.get(e, len(_STRICTNESS)))
async def held_write(session: AsyncSession, row: CustomTool) -> bool:
"""这个自定义工具是不是写外部系统的能力(capabilities.registry.writes_externally,和运行时、发布检查同一个判断)。
是的话不在运行里(工具页试运行、工具库执行)一律演练、不发送:对外写入只经调用工具节点,要确认、带幂等键、留生效
记录(A5)。绑定的端点已经没了的不算:执行时照实报端点不存在。"""
from app.capabilities.registry import get_capability
ref = bound_endpoint_ref(row)
endpoint = await find_endpoint(session, ref) if ref else None
if ref and endpoint is None:
return False
return _holds(row, await get_capability(session, row.name), endpoint.id if endpoint else None)
def _holds(row: CustomTool, cap: Any, endpoint_id: str | None) -> bool:
from app.capabilities.registry import writes_externally
return writes_externally(row.name, _effect_of(row, cap), endpoint_id)
def _write_refused(name: str) -> ToolFailure:
"""写入能力只能经调用工具节点发送:运行里别的路(Agent、直接调用)碰到它时的报错。"""
return ToolFailure(error=f"「{name}」是写入能力,只能经「调用工具」节点的动作确认后发送")
async def _run_endpoint(row: CustomTool, args: dict[str, Any], ctx: ToolContext) -> Any:
"""绑定登记端点的 HTTP 工具:由平台执行器发请求(capabilities/endpoints.py),模型只给参数、拿不到任意地址。
写入(held_write:治理后效果为 write,或者视参数而定——执行器分不出读写)不从这里发:运行里它要先留生效记录、按幂等键
发送、结果未知时转核实,那是调用工具节点的动作流程(app/process/actions.py);工具页试运行、工具库执行(不在运行里)
只演练,交回将要发送的请求(endpoints.rehearsal)。读取照常发,按探索运行走测试环境,没有测试环境时走生产环境。
请求按手上这一行拼(tool=row):工具页试运行的草稿发的是眼前编辑的方法、路径和请求体,不是按名字找到的已保存的那一版。"""
from app.capabilities import endpoints as ep
from app.capabilities.registry import endpoint_request, get_capability
from app.db.base import SessionLocal
async with SessionLocal() as session:
endpoint = await ep.find_endpoint(session, ep.bound_endpoint_ref(row))
if endpoint is None:
return ToolFailure(error=f"自定义工具「{row.name}」绑定的端点不存在,可能已被删除。请到「工具」页重新选择端点")
cap = await get_capability(session, row.name)
effect = _effect_of(row, cap)
formal, starter, in_run = await _run_info(session, ctx.run_id)
if _holds(row, cap, endpoint.id):
if in_run:
return _write_refused(row.name)
try:
return ep.rehearsal(endpoint, row.config or {}, cap, args)
except ep.EndpointError as e:
return ToolFailure(error=str(e))
environment = ep.pick_environment(endpoint, formal=formal, effect=effect)
result = await endpoint_request(session, cap if cap is not None else row.name, args,
environment=environment, on_behalf=starter,
run_id=ctx.run_id if in_run else None, node_id=ctx.node_id, tool=row)
out = ep.tool_result(result)
return ToolFailure(out) if "error" in out else out
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:
value = await _execute(bound_row, kwargs, ctx)
if isinstance(value, str):
return value
return json.dumps(value, ensure_ascii=False, default=str, indent=2)
return _run
meta: dict[str, Any] = {"trust_key": row.name}
if ref := bound_endpoint_ref(row):
endpoint = await find_endpoint(session, ref)
if endpoint is not None:
# 时限按端点登记的走:界面上写「已超出 N 秒上限」,引擎到点收回控制权(engine/toolcalls.limit_of)
meta["timeout_s"] = endpoint.timeout_s
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=meta,
)
)
return tools
def _rehearse_unbound(row: CustomTool, args: dict[str, Any]) -> dict[str, Any]:
"""没绑定端点、被治理为写入的自定义工具在工具页试运行、工具库执行时的演练回执:HTTP 工具交回将要发送的方法、地址、
请求体和请求头的名字;Python 工具不执行代码,交回这次的参数。"""
from app.capabilities.endpoints import REHEARSAL_MESSAGE
config = row.config or {}
if row.kind == "python":
return {"rehearsal": True, "message": "演练:写入能力不在这里真的执行,代码没有运行", "args": args}
body = config.get("body")
if body:
rendered = render_deep(body, args) if isinstance(body, dict) else render_template(str(body), args)
try:
body = rendered if isinstance(rendered, dict) else json.loads(rendered or "null")
except json.JSONDecodeError:
body = rendered
return {"rehearsal": True, "message": REHEARSAL_MESSAGE,
"request": {"method": str(config.get("method") or "GET").upper(),
"url": render_template(config.get("url", ""), args), "body": body or None,
"headers": sorted(config.get("headers") or {})}}
async def _execute(row: CustomTool, args: dict[str, Any], ctx: ToolContext) -> Any:
if bound_endpoint_ref(row):
return await _run_endpoint(row, args, ctx)
from app.db.base import SessionLocal
# 没绑定端点、被治理为写入的(effect=write):和绑定端点的写入一样,运行里不从这里发,试运行只演练
async with SessionLocal() as session:
if await held_write(session, row):
return _write_refused(row.name) if (await _run_info(session, ctx.run_id))[2] else _rehearse_unbound(row, args)
if row.kind == "python":
return await _run_python(row, args, ctx)
return await _run_http(row, args)
def http_failure(row: CustomTool, result: Any) -> dict[str, Any] | None:
"""HTTP 工具(绑定端点的、模板的)交回的结果带着非 2xx 的状态码:试运行、工具库执行照实说失败和状态码,
交回 {error, hint, detail};2xx 和别的结果交回 None。运行里照旧原样交给模型,它看得到状态码。结果的形状是平台拼的
({status, data | body}),工具库执行经 call_tool 拿到的是它的 JSON 文本,一并认。"""
from app.capabilities.endpoints import describe_status
if row.kind != "http":
return None
value = result
if isinstance(value, str):
try:
value = json.loads(value)
except ValueError:
return None
status = value.get("status") if isinstance(value, dict) else None
if not isinstance(status, int) or isinstance(status, bool) or 200 <= status < 300:
return None
hint = ("请检查认证头" if status in (401, 403) else "请检查路径和地址" if status == 404
else "请对照对方返回的内容(见技术细节)检查参数和请求体" if 400 <= status < 500
else "通常是对方系统的问题,请稍后重试或联系对方" if status >= 500 else "")
return {"error": f"请求失败:{describe_status(status)}", "hint": hint,
"detail": json.dumps(value, ensure_ascii=False, default=str)[:4000]}
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)
return await _execute(row, payload, ctx), note