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

416 lines
17 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 inspect
import json
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Awaitable, Callable
from langchain_core.tools import BaseTool, StructuredTool
from pydantic import BaseModel, ValidationError
ToolFunc = Callable[..., Awaitable[Any]]
class Versions(Enum):
"""ToolContext.data_versions 除了运行固定的那个 dict 之外的两个取值。"""
#: 不在运行里(工具库试用、试用前的副作用检查):用数据源的当前版本。这类调用要显式传它
CURRENT = "current"
#: 缺省值:构造 ToolContext 的地方没说用哪一版。在运行里漏传了固定的版本就是这样——上传源遇到它
#: 报错,不退回当前版本:静默改用当前版本,同一次运行就可能前后查到两版数据而没人察觉
UNSET = "unset"
@dataclass
class ToolContext:
"""一次调用里工具能用到的运行期依赖。"""
run_id: str = ""
node_id: str = ""
sandbox_session: str = "default"
memory_scope: str = "default"
collection: str = "default"
#: 往运行的事件流里发一条事件:节点传 NodeContext.emit(同步);协程函数也认,调用方 await 它的返回值
emit: Callable[..., Any] | None = None
#: 用哪一版上传表格。运行里是固定的版本(runs.data_versions:{源 id: {"snapshot": 快照 id, "name": 源名}}),
#: 由构造 ToolContext 的节点从运行上下文取来(tools/datasource.py 的 run_versions);同一段执行里各节点
#: 拿到的是同一个 dict,运行中途第一次用到的源固定下来后,后面的节点都看得到。
#: None 是升级前发起的运行,用当前版本;Versions.CURRENT 是不在运行里的调用,用当前版本;
#: 缺省 Versions.UNSET 是谁都没给——上传源遇到它报错,见 Versions
data_versions: dict[str, Any] | Versions | None = Versions.UNSET
#: 数据源查询结果交给模型、落进快照之前先遮(tools/datasource.py 的 run_masks_results):开登录后 user 角色发起的
#: 问数据运行,对它来说遮罩就是边界,事件、回答里只能有遮过的值
mask_results: bool = False
#: 数据源查询一律只读,可写源上的写语句也拒(tools/datasource.py 的 run_reads_only):同样是 user 角色的问数据运行。
#: 不能交给审批去拦——Agent 的 approval 是发起人自己写在图里的
force_readonly: bool = False
extra: dict[str, Any] = field(default_factory=dict)
@dataclass
class ToolSpec:
name: str
description: str
category: str
args_schema: type[BaseModel]
func: ToolFunc
# 需要人工确认才执行(写文件、跑代码、发请求这类有副作用的)
dangerous: bool = False
# 函数第一个参数是否接收 ToolContext
needs_context: bool = True
# 改平台自身的状态(写记忆):不危险到默认要人确认,但也不是只读——能力登记里不能记成只读(只读的才能正式运行免审批、
# 才能当写入的查询能力),见 capabilities.registry._discover
writes_state: bool = False
def json_schema(self) -> dict[str, Any]:
return self.args_schema.model_json_schema()
_REGISTRY: dict[str, ToolSpec] = {}
def register(
name: str,
description: str,
category: str,
args_schema: type[BaseModel],
*,
dangerous: bool = False,
needs_context: bool = True,
writes_state: bool = False,
) -> Callable[[ToolFunc], ToolFunc]:
def deco(func: ToolFunc) -> ToolFunc:
_REGISTRY[name] = ToolSpec(
name=name,
description=description,
category=category,
args_schema=args_schema,
func=func,
dangerous=dangerous,
needs_context=needs_context,
writes_state=writes_state,
)
return func
return deco
def all_specs() -> dict[str, ToolSpec]:
import app.tools.builtin # noqa: F401 触发内置工具注册
return dict(_REGISTRY)
def get_spec(name: str) -> ToolSpec | None:
return all_specs().get(name)
def call_is_dangerous(tool: BaseTool | None, name: str, args: dict[str, Any]) -> bool:
"""这一次调用要不要人工确认。所有审批关卡都问这一个函数。
内置工具危不危险是固定的(ToolSpec.dangerous)。动态工具要看这一次传了
什么:同一个 db_query__<源>,SELECT 不用确认,可写源上的 DELETE 必须确认。
它们把判定挂在工具的 metadata["dangerous_if"] 上。以前没有这一层,数据源
工具查不到 ToolSpec,于是在任何审批模式下都被当成安全的。
"""
spec = get_spec(name)
if spec is not None:
return spec.dangerous
judge = ((tool.metadata or {}) if tool is not None else {}).get("dangerous_if")
return bool(judge and judge(args))
def _stringify(value: Any) -> str:
"""工具结果最终是喂给模型的,所以统一成字符串;结构化数据转紧凑 JSON。"""
if isinstance(value, str):
return value
if value is None:
return ""
return json.dumps(value, ensure_ascii=False, default=str, indent=2)
def build_tool(spec: ToolSpec, ctx: ToolContext) -> BaseTool:
"""把注册表里的定义 + 运行期上下文,绑成一个 LangChain 工具。"""
async def _run(**kwargs: Any) -> str:
if spec.needs_context:
result = await spec.func(ctx, **kwargs)
else:
result = await spec.func(**kwargs)
return _stringify(result)
return StructuredTool(
name=spec.name,
description=spec.description,
args_schema=spec.args_schema,
coroutine=_run,
# 只给异步实现;同步路径直接报错,避免在事件循环里被阻塞调用
func=None,
)
async def build_tools(
names: list[str], ctx: ToolContext, *, session: Any = None, allow_write: bool = False
) -> list[BaseTool]:
"""按名字列表组装工具。
allow_write(应用平台 A5):写入能力只能放在调用工具节点里——只有调用工具节点传 True;agent、协作节点用默认值,
能力登记里治理后效果为 write 的工具在这里就被拒绝(ToolBuildError,绑了它的节点失败并说清去哪里改)。发布检查
另有一道 action.write_in_agent(engine/lint_capabilities.py),这里是运行时的那一道:草稿、探索运行不过发布检查。
视参数而定(dynamic)的照旧放行,每次调用按参数问审批(可写数据源的 DML、有副作用的内置工具);绑定端点、由平台
执行的除外,见 capabilities.registry.writes_externally。
四种来源:内置(静态注册)、MCP(mcp:<server>/<tool>)、数据源
(db_query__<源名> / db_schema__<源名>)、自定义(数据库里的 CustomTool)。
后两种都是运行时从库里读出来动态构造的,不在静态注册表里。
"""
from app.tools.datasource import QUERY_PREFIX, SCHEMA_PREFIX
specs = all_specs()
tools: list[BaseTool] = []
mcp_names: list[str] = []
datasource_names: list[str] = []
custom_names: list[str] = []
for name in names:
if name.startswith("mcp:"):
mcp_names.append(name)
elif name in specs:
tools.append(build_tool(specs[name], ctx))
elif name.startswith((QUERY_PREFIX, SCHEMA_PREFIX)):
datasource_names.append(name)
else:
custom_names.append(name)
if datasource_names and session is not None:
from app.tools.datasource import build_datasource_tools
tools.extend(await build_datasource_tools(datasource_names, ctx, session, force_readonly=ctx.force_readonly,
mask_results=ctx.mask_results))
if mcp_names:
from app.tools.mcp_manager import mcp_manager
tools.extend(await mcp_manager.get_tools(mcp_names))
if custom_names and session is not None:
from app.tools.custom import build_custom_tools
tools.extend(await build_custom_tools(custom_names, ctx, session))
if not allow_write and tools:
await _refuse_writes(tools, session)
return tools
async def _refuse_writes(tools: list[BaseTool], session: Any) -> None:
"""写外部系统的工具(capabilities.registry.writes_externally:治理后效果为 write,或绑定端点、视参数而定)不交给
agent。按能力登记的名字认(MCP 是 mcp:<服务>/<工具>,和 trust_key 一致)。"""
from sqlalchemy import select
from app.capabilities.registry import effective_effect, writes_externally
from app.db.models import Capability
keys = {(t.metadata or {}).get("trust_key") or t.name for t in tools}
async def lookup(s: Any) -> list[Any]:
return list((await s.execute(select(Capability).where(Capability.name.in_(list(keys))))).scalars())
if session is not None:
rows = await lookup(session)
else:
from app.db.base import SessionLocal
async with SessionLocal() as own:
rows = await lookup(own)
if writes := [row.name for row in rows if writes_externally(row.name, effective_effect(row), row.endpoint_id)]:
raise ToolBuildError(
f"{'、'.join(f'「{w}」' for w in writes)}是写入能力,只能放在「调用工具」节点里,由动作确认后发送;"
"请从 Agent 的工具清单中移除,改用「调用工具」节点")
class ToolBuildError(ValueError):
"""绑定的工具建不出来:它自己的配置写坏了(比如自定义工具的参数定义)。
message 直接给人看:是哪个工具、坏在哪、去哪里改。节点据此报一句清楚的错,
而不是让 schema 转换在半路炸出一句「'str' object has no attribute 'get'」。
"""
class ToolArgsError(ValueError):
"""工具参数对不上 schema。
message 是直接给人和模型看的:报错里必须说清楚"收到了什么"和"它接受什么",
因为看到这句话的下一步动作就是照着改参数名。不要再往外包一层。
"""
_TYPE_LABELS = {
"string": "字符串", "integer": "整数", "number": "数字",
"boolean": "布尔", "array": "数组", "object": "对象",
}
def _type_label(prop: dict[str, Any]) -> str:
"""JSON Schema 的类型写成人话。可选字段是 anyOf[T, null],要摊开看。"""
names: list[str] = []
for node in [prop, *(prop.get("anyOf") or [])]:
kind = node.get("type")
if isinstance(kind, str) and kind != "null":
names.append(_TYPE_LABELS.get(kind, kind))
return "/".join(dict.fromkeys(names)) or "任意"
def args_model_of(tool: BaseTool) -> type[BaseModel] | None:
"""工具的参数模型。
MCP 这类动态工具的 args_schema 可能直接是一份 JSON Schema dict,拿不到
pydantic 模型——那种只能原样放行,不能因为校验不了就拒绝调用。
"""
schema = getattr(tool, "args_schema", None)
if isinstance(schema, type) and issubclass(schema, BaseModel):
return schema
return None
def describe_args(
schema: type[BaseModel],
got: dict[str, Any] | None = None,
error: ValidationError | None = None,
) -> str:
"""把"哪里不对、它到底要什么"写成一句能照着改的话。
字段说明取 Field(description=...),各工具定义里本来就写了,这里只是搬到报错里
——模型拿到这句话就能在下一步自己改对,不用人工介入。
返回的永远是一句完整的话("参数有误:…。可用参数:…"),调用方直接拼在
工具名后面即可,不用猜这次会不会缺个主语。
"""
js = schema.model_json_schema()
props: dict[str, Any] = js.get("properties") or {}
required = [k for k in (js.get("required") or []) if k in props]
fields: list[str] = []
for key, prop in props.items():
tag = "必填" if key in required else "可选"
desc = str(prop.get("description") or "").strip()
fields.append(f"{key}({tag},{_type_label(prop)})" + (f" — {desc}" if desc else ""))
tail = "可用参数:" + ";".join(fields) if fields else "该工具不接受任何参数。"
got = dict(got or {})
unknown = [k for k in got if k not in props]
missing = [k for k in required if k not in got]
problems: list[str] = []
if unknown:
problems.append(f"{'、'.join(unknown)} 不是该工具的参数")
if missing:
problems.append(f"缺少必填参数 {'、'.join(missing)}")
for err in error.errors() if error else ():
loc = ".".join(str(p) for p in (err.get("loc") or ()))
if not loc or loc in unknown or loc in missing:
continue # 上面两条已经说过了,不重复
# 用 schema 里的类型说,不要把 pydantic 那句英文原样甩出去
problems.append(
f"{loc} 的值不合法,应为{_type_label(props[loc])}"
if loc in props
else f"{loc} 的值不合法({err.get('msg', '')})"
)
head = "参数有误:" + ";".join(problems) + "。" if problems else "参数有误。"
return f"{head}{tail}"
def prepare_args(
schema: type[BaseModel], args: dict[str, Any]
) -> tuple[dict[str, Any], str | None]:
"""校验工具参数,必要时纠正一个明显写错的参数名。
返回 (可直接展开给函数的 payload, 纠正说明或 None)。纠正说明不为空时调用方
**必须**把它说出去——参数还错在配置里/还错在模型脑子里,替它跑通一次却不吭声,
下一次照样踩。
多余的参数名在这里是硬错误,哪怕校验能过:pydantic 默认把认不出的键悄悄丢掉,
于是全可选字段的 schema 上 {"tabel": "orders"} 会"成功"执行一次什么都没查的调用。
这种静默失败比报错难查得多。
纠正只在候选唯一时发生。多于一个就有排列组合,猜对了也是运气,不如把 schema
摊开让人或模型自己改。
"""
args = dict(args or {})
js = schema.model_json_schema()
props: dict[str, Any] = js.get("properties") or {}
required = [k for k in (js.get("required") or []) if k in props]
unknown = [k for k in args if k not in props]
if not unknown:
try:
return schema(**args).model_dump(), None
except ValidationError as e:
raise ToolArgsError(describe_args(schema, args, e)) from e
# 少了哪个必填的,就是多出来那个想写的;一个必填都不缺时,退而看还空着哪个可选字段
missing = [k for k in required if k not in args] or [k for k in props if k not in args]
residual: ValidationError | None = None
if len(unknown) == 1 and len(missing) == 1:
fixed = dict(args)
fixed[missing[0]] = fixed.pop(unknown[0])
try:
payload = schema(**fixed).model_dump()
except ValidationError as e:
# 改完名还是不合法。把这些毛病一并说出来,省得对方改对了名字
# 再回来撞一次类型——describe_args 会跳过已经报过的那几个字段
residual = e
else:
return payload, f"参数名 {unknown[0]} 不存在,已按唯一候选 {missing[0]} 执行"
raise ToolArgsError(describe_args(schema, args, residual))
async def call_tool(
name: str,
args: dict[str, Any],
ctx: ToolContext,
session: Any = None,
*,
on_fix: Callable[[str], None] | None = None,
) -> Any:
"""直接调用一个工具(工具节点、以及设置页的"试一下"按钮用)。
两条分支都先过 args_schema。动态工具那条以前是裸 `**args` 展开的,参数名写错
时抛的是 TypeError,报错里只剩内部闭包名——`_make_query_tool.<locals>._run() got
an unexpected keyword argument 'query'` 既指不到是哪个工具,也说不出该填什么。
on_fix 在参数名被纠正时回调一次。call_tool 不该知道事件系统长什么样,
所以只把这件事交出去,由调用方决定说给谁听。
"""
spec = get_spec(name)
if spec:
payload, note = prepare_args(spec.args_schema, args)
if note and on_fix:
on_fix(note)
if spec.needs_context:
return await spec.func(ctx, **payload)
return await spec.func(**payload)
# 直接调用(调用工具节点、工具库试用):参数是人配的,不是模型挑的,写入能力也能调——写入在运行里另有动作流程把关
tools = await build_tools([name], ctx, session=session, allow_write=True)
if not tools:
raise KeyError(f"找不到工具 {name}")
tool = tools[0]
model = args_model_of(tool)
if model is None:
payload = dict(args)
else:
payload, note = prepare_args(model, args)
if note and on_fix:
on_fix(note)
if tool.coroutine:
return await tool.coroutine(**payload)
result = tool.invoke(payload)
if inspect.isawaitable(result):
return await result
return result