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