工具体系
适用端:后端 | 类型:机制
定制功能
@AgentTool 注解、AgentToolRegistry 注册表、AgentSecurityGateway 权限网关、AgentToolExecutor 执行器、agent_tool_override 在线维护——整套都是本项目自研,上游 yudao 没有任何对应物(上游的 AI 模块只有对话与绘图,没有「让模型调用平台内部写操作」这条链路)。
这一套也不是 MCP:工具是进程内的 Java bean,由执行器直接调方法,没有跨进程的 JSON-RPC 往返。好处是权限、风险等级、Plan 确认能在一处管住;代价是外部 MCP Server 的工具进不来。
定位:讲清楚「模型说了一句 datamodel_create,到 MySQL 里真的多出一张表」这中间发生了什么事,以及要加一个新工具该动哪几处。
前置阅读:后端架构与模块依赖(模块依赖方向)、权限体系总览(三层权限与权限串)。
它解决什么问题
平台的写操作原本只有一条入口:浏览器点按钮 → Controller → @PreAuthorize 校验 → Service。AI 助手要能建表、发布表单、部署流程,就必须另开一条入口——而这条入口天然绕过 Controller,@PreAuthorize 对它完全无效。
于是有两个问题必须同时解决:
- 权限怎么校验——不能靠注解,只能编程式;而且必须与界面按钮用同一个权限串,否则授权页上勾了也不算。
- 身份从哪来——工具执行往往不在 Servlet 线程上(SSE 流式、预热线程池、工作流调度器),那些线程的
ThreadLocal里没有登录用户、没有租户。
第二个问题更隐蔽,而且下游不会兜底。权限体系三层在「拿不到 userId」时语义并不一致:
| 层 | 无 userId 时 | 位置 |
|---|---|---|
| 功能权限 | 拒绝(fail-close) | SecurityFrameworkServiceImpl#hasAnyPermissions |
| 行权限 | 放行,不追加任何过滤 | DataRowFilterResolver#resolve |
| 字段权限 | 放行,字段一个都不裁 | PermissionFieldHelper#resolve |
后两者的设计理由写在 PermissionFieldHelper 类注释「无登录上下文 = 不限制」那一条:「走到这里的只有内部调用(定时任务、MQ、流程引擎回写),结果不会流向用户」。这个前提对 AI 不成立——AI 的执行结果恰恰要流向用户。所以 agent 模块必须自带完整防线,一步都不能指望下游。
与 ai 模块的做法相反
enlev-module-ai 用 TenantUtils.executeIgnore(8 处),那是把租户隔离整个关掉——TenantDatabaseInterceptor 直接 return true,不拼 tenant_id。agent 模块反过来:拿不到身份就罢工,绝不降级放行。有一条架构测试(AgentArchitectureTest#testNoTenantExecuteIgnore)钉死 agent 模块不得出现 executeIgnore。
代码在哪
后端根路径缩写 agent/ = enlev-module-agent/src/main/java/cn/enlev/ainb/module/agent/。
| 位置 | 文件 | 行数 | 职责 |
|---|---|---|---|
agent/framework/agent/core/tool/ | AgentTool.java | 128 | 工具声明注解,9 个属性 |
AgentToolFunction.java | 109 | 执行契约:execute + paramsSchema + resultArrayKeys + resultShapeKnown | |
AgentToolRegistry.java | 387 | 注册表:声明值(final)+按租户现算的生效值 | |
AgentToolExecutor.java | 161 | 所有工具调用的唯一入口 | |
RiskLevel.java | 119 | 五档风险枚举 | |
ToolResultShapes.java | 152 | 「返回形状」判据,提示词与校验器 V26 共用唯一一份 | |
ToolKeySuggester.java | 55 | 工具键打错时给相近候选(阈值 0.5、最多 2 条) | |
datainterface/DataInterfaceTool{Config,Function}.java | 89 / 110 | 自定义工具的配置解析与动态执行体 | |
agent/framework/agent/core/gateway/ | AgentSecurityGateway.java | 176 | 六步断言 |
AgentExecutionContext.java | 213 | 身份快照与跨线程显式重建 | |
agent/tool/impl/ | 36 个文件 | — | 30 个内置工具 + 6 个辅助类(4 个 ParamResolver、FormFieldFactory、FormDataPermissionChecker) |
agent/service/tooldef/ | AgentToolDefService{,Impl} | 158 / 298 | 自定义工具目录(CRUD + 网关回落 + 构造执行体) |
AgentToolOverrideService{,Impl} | 65 / 492 | 内置工具在线维护(放松检测 / 审计 / 缓存刷新) | |
agent/controller/admin/tooldef/ | AgentToolDefController.java | 144 | 管理页接口 |
前端:src/api/agent/tooldef/index.ts(225)、src/views/agent/tooldef/index.vue(543,双 tab)、components/BuiltinToolForm.vue(455,内置维护)、components/ToolDefForm.vue(499,自定义 CRUD)。
工具键就写在工具类自己身上
没有集中的常量表。30 个工具里有 16 个把键提成自己的 public static final String NAME(例:DataModelCreateTool.NAME = "datamodel_create",:98),注解里写 name = DataModelCreateTool.NAME;另外 14 个直接写字面量。ToolChangeExtractor#toolId() 必须与它逐字一致,由 ToolChangeExtractorCoverageTest 双向钉住(见「测试」)。
核心机制
一次工具调用的完整路径
🔴 平台没有原生 function-calling。模型靠一段 JSON 协议表达调用意图({"type":"tool_calls","calls":[...]}),由 ReactJsonCodec 从回复里抠出来。所以「工具选错名」「参数拼错格式」都是协议层的失败,不是 SDK 层的——错误消息的可读性直接决定模型能不能自修正(见坑 9)。
校验与执行必须一起放进重建后的上下文
AgentToolExecutor#execute:62 是整条链路的收口。它的类注释把三件事写死在一处:过网关、重建身份、执行工具。
Object result = context.execute(() -> {
// 1. 先过闸门。任一条不满足直接抛异常终止,不降级
gateway.check(context, toolName);
// 🔴 沙箱 mock 卡点(流程编排「沙箱测试」):闸门照过(权限语义不放宽),
// 但凡风险等级非 READ_ONLY 的工具,一律返回占位结果、绝不调用 function.execute ——
// 写操作在沙箱里不产生任何真实副作用。只读工具与大模型(不走本执行器)仍真实执行。
// 卡在这里而非 ToolNodeExecutor,是因为 agent_loop 子图的工具调用也汇聚到本入口,不会绕过。
if (context.isSandbox() && isNonReadOnly(toolName)) {
log.info("[execute][沙箱 mock 写操作工具({}) 用户({})]", toolName, context.getUserId());
return mockResult(toolName, params);
}
// registry-first:代码工具优先;查不到才回落到用户自定义工具目录(增量5a)。
AgentToolRegistry.ToolDefinition definition = toolRegistry.get(toolName);
AgentToolFunction function = (definition != null)
? definition.getFunction()
: toolDefService.buildFunction(toolName);
if (function == null) {
throw exception(AGENT_TOOL_NOT_FOUND, toolName);
}
return function.execute(params == null ? Map.of() : params);
});:65-80 记了一次真实事故,值得逐字读:最初的写法是先在当前线程过网关、再 context.execute 执行工具。那个写法有个隐藏前提——当前线程本来就有身份上下文。Servlet 线程上成立,所以一直没暴露;而网关自己要做两件依赖上下文的事(第 4 步查 agent_config 是分租户表,没 tenantId 直接抛「TenantContextHolder 不存在租户编号!」;第 6 步 hasPermission 读的是 SecurityContext 这个 ThreadLocal)。于是从预热线程池上调用时,工具还没跑就先炸在网关里,而且报的是数据库异常,跟真实原因毫无关系。
生产路径当时没出事,只是因为调用方(AgentAssistantServiceImpl#runToolLoop)自己在外面包了一层 context.execute——那是调用方的自觉,不是执行器的保证。注释的结论一句:「本类的存在意义就是不依赖调用方的自觉。」
刻意不用 TenantUtils.execute
AgentExecutionContext#execute:155 自己管理租户上下文,逻辑与 TenantUtils 一致,只是不吞异常类型。原因写在 :160-166:TenantUtils.execute 在 catch 里把所有异常包装成 RuntimeException,那会毁掉两件事——① ServiceException 被包装后全局异常处理器认不出来,用户看到「系统异常」而不是「您没有执行该操作的权限:…」;② AI 的自修正依赖准确的错误信息,包装后拿到的是一层无意义的外壳。
权限网关六步
AgentSecurityGateway#check:64 只有三行,分派给三个私有方法:
| 步 | 断言 | 位置 | 失败错误码 |
|---|---|---|---|
| 1 | context.getUserId() != null | checkIdentity:99 | AGENT_NO_LOGIN_CONTEXT |
| 2 | context.getTenantId() != null | :102 | AGENT_NO_TENANT_CONTEXT |
| 3 | loginUser != null 且非跨租户状态 | :106、:113 | AGENT_NO_LOGIN_CONTEXT / AGENT_CROSS_TENANT_DENIED |
| 4 | agent_config 查得到当前租户的行 | checkEnabled:126 | AGENT_DISABLED |
| 5 | 工具存在且已启用 | checkTool:143 | AGENT_TOOL_NOT_FOUND / AGENT_TOOL_DISABLED |
| 6 | 声明的权限串逐个 AND 通过 | :152 | AGENT_PERMISSION_DENIED |
第 3 步的跨租户拒绝,理由不是「不方便」而是「校验会失效」:
// 🔴 跨租户状态下必须拒绝。
// 理由不是「不方便」而是「校验会失效」:SecurityFrameworkUtils.skipPermissionCheck()
// 在 visitTenantId != tenantId 时返回 true,功能权限与数据权限会被<b>一起短路</b>。
// 会话若在这个状态下发起,后面第 6 步的权限校验等于没做。
if (loginUser.getVisitTenantId() != null
&& ObjUtil.notEqual(loginUser.getVisitTenantId(), loginUser.getTenantId())) {
throw exception(AGENT_CROSS_TENANT_DENIED,
loginUser.getVisitTenantId(), loginUser.getTenantId());
}第 4 步「查不到配置行就是未启用」是数据层面的事实,不靠默认值写对(selectCurrent() == null → 拒)。
第 6 步是 AND 语义,且要求全都有:
// 与界面按钮走同一套权限点。注意这里是编程式校验 ——
// AI 不经过 Controller,@PreAuthorize 对它完全无效。
//
// 🔴 「原子动作」类工具声明了多个权限串,这里要求<b>全都有</b>才放行。
// 只查第一个的话,用户会卡在链条中间 —— 而中间态往往比没做更糟
for (String permission : tool.getPermissions()) {
if (!securityFrameworkService.hasPermission(permission)) {
throw exception(AGENT_PERMISSION_DENIED, toolName, permission);
}
}AgentTool#extraPermissions:77-93 解释了「卡在中间」为什么比没做更糟:form_publish_bundle 内部串了五步、process_create_and_deploy 串了两步,只声明第一步的权限就会留下「发布了但没菜单的表单」或「建了但没部署的流程模型」。后者尤其糟——非超管改不了「从未部署过」的流程模型(BpmModelServiceImpl 的刻意设计),那个半成品用户既删不掉也改不了。
入口权限不在网关里
checkChat:86 只跑前 4 步,不校验 agent:assistant:use——那一条由 Controller 的 @PreAuthorize 负责。:81-84 的 ⚠️ 说明了分工理由:两处都写,将来改权限串时很容易只改一处,而「改了却还有一处在用旧串」的表现是随机的 403,极难查。入口权限归 Controller,工具权限归网关。
checkChat 存在的意义在 :76-79:一次对话未必会调到工具(模型直接作答,或检索那一步失败了),那样一个 check 都不会跑——身份是否完整、总开关是否开着全都没人验过,而 LLM 已经被调用、token 已经被消耗。
网关管不住什么
- 资源实例级的动态权限:
form_data_*五个工具的权限是infra:form-design-run:{code}:{action},code是运行时参数,静态注解表达不了——靠工具内的FormDataPermissionChecker自己校验(见「怎么扩展」第 3 条)。 - 写操作确认:Plan 预览确认与只读闸在别处(Plan 预览确认与 HITL)。
- prompt 注入:靠
PromptDataFence逐结果包裹(安全与提示注入防护)。
声明值 vs 生效值:内置工具可以在线维护
2026-09-28 的产品决策推翻了旧底线(「权限串/风险等级属于实现的一部分、不可运行时改写」),改成内置工具全字段自由维护。为此每个工具拆成两层(AgentToolRegistry 类注释 :25-52):
- 声明值(
declared*,final):代码即事实,来自@AgentTool注解,永不改变; - 生效值:默认等于声明值,可被
agent_tool_override覆盖表改写。getter 一律返回生效值,所以在线维护改完立即生效、无需重启;声明值经getDeclared*暴露,供管理页回显默认与放松告警。
🔴 生效值按【当前租户】解析,绝不写死在单例上。 注册表是单例,而 agent_tool_override 是租户级数据。若把某租户的覆盖直接改进单例字段,会污染所有租户(租户 A 停用某工具,租户 B 也被停用)。所以生效值不落在 ToolDefinition 上,而是每次 getter 调用时按 TenantContextHolder.getTenantId() 从 overridesByTenant 缓存现取现算(currentTenantOverride:344)。缓存按租户懒加载,内层 map 是不可变快照、刷新时整体替换(refreshTenantOverrides:332),读端无锁一致;无租户上下文(启动、异步、单测)时不查库、返回声明值。
ToolOverride 的空值语义逐字段不同,别混(:109-117):
| 字段 | null | 非 null |
|---|---|---|
displayName | 或空白 = 不覆盖(展示名回落工具名) | 覆盖 |
description / paramsSchema / groupName | 不覆盖 | 覆盖(含空串) |
risk | 不覆盖 | 覆盖(允许低于声明值,产品决策) |
permissions | 不覆盖 | 覆盖(含空列表 = 调用只需登录) |
enabled | 不覆盖 | 覆盖(可停用,也可重新启用) |
但风险等级是个例外——运行期各道闸读的都是声明值
AgentToolOverrideDO 类注释 :22-29 明确写了:DSL 校验 V3/V11、Planner 工具目录、agent_loop 只读限制、发布判定、沙箱 mock 判定、变更清单,读的全是 getDeclaredRisk()。生效风险值只剩界面在读(管理页 / 设计器目录 / 诊断端点)。
所以改风险等级不会让工具绕过任何判定,只会改它在界面上的标注。AgentToolExecutor#isNonReadOnly:127-130 的理由值得逐字读:「agent_tool_override 一行数据能把 IRREVERSIBLE 压成 READ_ONLY,压完之后按生效值判定就会让沙箱真的去建表——沙箱的全部承诺是「不产生真实副作用」,而这个承诺不该由一行可在线维护的数据来决定。」
注意这条只覆盖风险:网关判权限时读的仍是生效权限串,所以删空权限是实打实的放松,靠审计留痕、不靠这条红线兜住。
两种放松的代价不一样重
AgentToolOverrideServiceImpl:56-71(2026-10-05 实测口径):
- 降风险:不改变任何强制行为,只改变界面上的风险标注。真正要紧的后果反而是「设计器把它标成无需确认、用户放进
agent_loop、发布期被 V3 按声明值拒」这种界面误导; - 删权限:是真放松。网关逐个权限 AND 判定时读生效值,删空之后「调用只需登录」立刻对 AI 生效。
放松不拦截,但要二次确认并落一行 agent_tool_override_audit(B1 / S11,2026-10-05)。此前只有 log.warn——日志会滚会清,等于没留痕。审计表的判据在 detectRelaxation:只有把风险档调到低于代码声明、或把权限串删到少于代码声明时才落行。改展示名、改说明、调高风险、加权限都不落行——那些动作没有安全代价,全记只会把真正要复核的几十行淹进几千行流水里。
风险等级:五档,外加一列显式 severity
| 枚举 | 中文 | requireConfirm | severity | 典型工具 |
|---|---|---|---|---|
READ_ONLY | 只读 | false | 0 | 查表结构、查表单设计、查控件契约 |
LOW | 低风险 | true | 1 | form_save_draft、datamodel_import、form_data_create |
MEDIUM | 中风险 | true | 2 | form_update、form_data_update |
HIGH | 高风险 | true | 3 | form_publish_bundle、process_create_and_deploy、data_interface_invoke |
IRREVERSIBLE | 不可逆 | true | 4 | datamodel_create、datamodel_update |
两个设计细节:
🔴 severity 是单独一列,不让调用方比 ordinal()(:73-79)。五个常量的声明顺序恰好就是严重度升序,于是「比大小」看起来免费——但那份顺序是写给人看的排版,没有任何东西强制它。谁在 LOW 前面插一个常量,所有比大小的调用点会静默改判,表现是「manifest 把不可逆操作标成蓝色」。显式落一列就把「排版」和「语义」分开了,RiskLevelTest#severityAscendingInDeclarationOrder 再钉住两者当前一致——真有人重排声明顺序时会先撞红那条测试,而不是撞红用户。
🔴 parseOrHigh:108 缺失或非法一律回落 HIGH。回落方向是刻意的:宁可把只读误判成写(多要一次确认、多 mock 一次,都没有副作用),也不能把写误判成只读(真跑出副作用)。自定义工具的 agent_tool_def.risk_level 与任何将来承载风险等级的数据都走这一个入口——两份解析逻辑必然漂移,而漂移的表现是「同一个工具在沙箱里被 mock、在名称目录里标成只读」这种查不出原因的矛盾。
MEDIUM 那一档的存在理由是 form_update:它不是增量更新,没传的字段会被静默清空(联动规则、校验规则、自定义按钮、页签、打印模板、前端脚本),界面上要把「由非空变为空」的配置项单独高亮。
结果形状契约
全平台没有统一的列表包裹键:同样是一个列表,datamodel_list 回 models、form_list 回 items、data_interface_list 回 list。而 workflow 的 script 节点跑在沙箱里,拿到的就是这些对象——取列表必须先知道键名。规划期没有任何接口能查到返回契约,模型只能赌;赌错时它不报错,只是安静地拿到一个空列表,本节照样记成功,真正的失败要等下一节才冒出来。
所以 AgentToolFunction 上挂了两个默认方法:
resultArrayKeys()——返回值里数组值的键,按出现顺序。声明出来之后两件事才有唯一出处:规划提示词里那张「谁回哪些数组」的表由它渲染,校验器 V26 由它判「这里不点名会不会运行期抛」。resultShapeKnown()——形状能不能静态声明。返回空列表被渲染成「压根不回数组,别对它用pickRows」,那是一句确定的指引;形状不可知的工具若也返回空列表,提示词就对它说了一句假话。返回false的工具被单独渲染成一栏「形状随接口而定」,并附一句「要解析它的结果就先用 script 把键名打出来看一眼」——把一个静默的错误变成一个当场可见的动作。
🔴 resultArrayKeys 必须写全部返回分支的并集(:63-72)。形状是按分支变的,而模型在规划期不知道会走哪条:datamodel_preview_ddl 的 CREATE 分支一个数组都不回,ALTER 分支却回 addedColumns/modifiedColumns/droppedColumns/ddl 四个;widget_contract_get 正常时只回 widgets,问到不存在的类型时还会多出 已停用的类型 与 不存在的类型。只声明常见那条,漏掉的分支就会在运行期抛——而且专挑出错的时候抛,那时用户最需要看清错误,却看到一句「猜不出该取哪个数组」。
ToolResultShapes 是这个判据的唯一出处,两份常量:
public static final List<String> MAIN_LIST_KEYS = List.of("items", "list", "models");
public static final List<String> LIST_WRAPPER_KEYS = List.of("items", "list", "rows", "records", "models");MAIN_LIST_KEYS 只有三个,因为全平台实测下来只有这三种约定。🔴 不要为了让某个工具「也能不点名」而往里加键名(:44-49):加进去等于宣称「这个键名在全平台就是主列表的意思」,而 datamodel_structure 的 systemColumns / businessColumns 恰恰是反例——三个数组里第一个是系统列,任何「猜一个」的规则都会在某些流程里静默拿系统列去判业务列。该做的是让那个工具的调用方点名,不是放宽判据。
LIST_WRAPPER_KEYS 多出的 rows / records 不是本平台的约定,全平台没有任何工具用它们当结果键,但它们是模型最常猜的两个通用名,所以「写了它」同样是确定的取不到值。data 刻意不在清单里:它是 form_data_get 等工具真实的标量键,把它算进「猜错」就是一类稳定的假警报。
截断:白名单类工具必须关掉
工具返回值没有统一包装类型,路径是 Object → JSONUtil.toJsonStr → 按 truncateResult 截断(上限 AgentLoopProperties.toolResultMaxChars = 4000)→ PromptDataFence.fence("tool_observation", …) 逐结果包裹 → 以「- 工具名:」回灌模型(AgentChatToolLoop:285-300)。
AgentTool#truncateResult:107-126 的 🔴 段记了一次完整事故:截断一份清单和截断一份列表是两件完全不同的事。datamodel_list 截断了,模型少看到几个候选,顶多再查一次;而 widget_contract_get 截断了,模型拿到的是一份看起来完整、实则只剩前几个的白名单——它无从知道自己没看全,于是照着残缺清单选,选不到就凭记忆编一个。实测:4000 字符只装得下前 8 个控件(到 select 为止),datePicker 排在第 13 位;用户要「日期时间」字段时模型编了个 dateTimePicker,被校验拦下后反复重查白名单——每次都被同样截断,5 轮耗尽、规划彻底失败。
WidgetContractGetTool 的对策不是关掉截断了事,而是改成两段(类注释 :34-48):全量含 props 的输出实测有 20777 字符,砍掉 81%;于是默认返回全部控件的选型信息(type/label/group/supported/columnTypes,约 7000 字符,且已声明 truncateResult = false),而占掉 8988 字符的 props 改成按 types 参数取——模型定下要用哪几个控件之后再查那几个,一次只几百字符。30 个工具里只有它一个设了 truncateResult = false。
30 个内置工具全景
按 groupName 分八组(分组清单在 AgentTool:49):
| 工具键 | 组 | 风险 | 权限串 | extra |
|---|---|---|---|---|
datamodel_create | 数据建模 | IRREVERSIBLE | infra:data-model:create | |
datamodel_update | 数据建模 | IRREVERSIBLE | infra:data-model:update | |
datamodel_import | 数据建模 | LOW | infra:data-model:create | |
datamodel_preview_ddl | 数据建模 | READ_ONLY | infra:data-model:query | |
datamodel_list | 数据建模 | READ_ONLY | infra:data-model:query | |
datamodel_structure | 数据建模 | READ_ONLY | infra:data-model:query | |
datamodel_column_types | 数据建模 | READ_ONLY | infra:data-model:query | |
datamodel_physical_tables | 数据建模 | READ_ONLY | infra:data-model:query | |
form_save_draft | 表单设计 | LOW | infra:form-design:create | |
form_update | 表单设计 | MEDIUM | infra:form-design:update | |
form_publish_bundle | 表单设计 | HIGH | infra:form-design:publish | system:menu:create、system:permission:assign-role-menu |
form_list | 表单设计 | READ_ONLY | infra:form-design:query | |
form_get | 表单设计 | READ_ONLY | infra:form-design:query | |
form_inspect | 表单设计 | READ_ONLY | infra:form-design:query | |
widget_contract_get | 控件契约 | READ_ONLY | infra:form-design:query | |
form_data_create | 表单数据 | LOW | agent:assistant:use | |
form_data_update | 表单数据 | MEDIUM | agent:assistant:use | |
form_data_delete | 表单数据 | HIGH | agent:assistant:use | |
form_data_get | 表单数据 | READ_ONLY | agent:assistant:use | |
form_data_page | 表单数据 | READ_ONLY | agent:assistant:use | |
data_interface_invoke | 数据接口 | HIGH | infra:data-interface:invoke | |
data_interface_list | 数据接口 | READ_ONLY | agent:assistant:use | |
data_interface_query | 数据接口 | READ_ONLY | agent:assistant:use | |
process_create_and_deploy | 流程审批 | HIGH | bpm:model:create | bpm:model:deploy |
process_list | 流程审批 | READ_ONLY | bpm:model:query | |
process_get_simple | 流程审批 | READ_ONLY | bpm:model:query | |
doc_search | 平台手册 | READ_ONLY | agent:assistant:use | |
diagnose_not_working | 诊断排错 | READ_ONLY | agent:assistant:use | |
explain_form_rule | 诊断排错 | READ_ONLY | infra:form-design:query | |
explain_process | 诊断排错 | READ_ONLY | bpm:model:query |
统计:READ_ONLY 19、LOW 3、MEDIUM 2、HIGH 4、IRREVERSIBLE 2。
form_data_* 五个全部声明 agent:assistant:use——那是入口权限,真正的资源实例级校验在工具内(FormDataPermissionChecker)。
分组名刻意不跟着菜单名走
AgentTool:56-61 的 🔴 段:2026-10 那次菜单改名把「数据建模」改成「数据模型」、「数据接口」改成「接口管理」,但这两个分组名一个字没动。理由是它们描述的是一类工具在做什么(建模、调接口),不是用户从哪个菜单进去;「接口管理」当分组名反而看不出这组工具能干什么。而且改名还要连带迁移 agent_tool_override.group_name 里已存的覆盖值,漏一条就会出现同一组工具在管理页被拆成两半。读到这里的不要「顺手统一」。
数据模型
三张表都是 TenantBaseDO(租户级)。
agent_tool_def——只存「无 Java 代码」的用户自定义工具
AgentToolDefDO 类注释 :12-23 的 🔴 段划清了边界:@AgentTool 代码工具由 AgentToolRegistry 权威登记、只读暴露(GET /agent/config/tool-list),不落这张表。本表存的是数据接口工具(当前唯一可执行来源)/ 表单 / 脚本(预留)这类「定义即数据」的工具。
关键列:tool_key(租户内唯一,^[a-z][a-z0-9_]{1,63}$,禁与代码工具重名)、source(仅 DATA_INTERFACE 可执行)、risk_level(登记必填、无默认)、permissions(逗号分隔 AND)、config({code,mode} JSON)、params_schema(留空则按接口入参自动生成)。
索引:idx_tenant_source、idx_tenant_tool_key、PRIMARY。
config 这个列名是躲坑躲出来的
不叫 sql / value 之类,因为裸词会撞 jsqlparser 的解析。列里不含凭据。
agent_tool_override——内置工具的在线维护覆盖值
行不存在 = 纯代码默认;「恢复默认」= 删行。七个覆盖列 + remark。
🔴 这张表上没有 (tenant_id, tool_name) 唯一索引,而且加不上:deleted 是软删标记,「恢复默认」再「重新维护」留下的墓碑会继续占着同一个键(实测本仓 datamodel_create 名下已积 28 行墓碑)。于是并发保存能落下两行 live,而旧实现用 selectOne 读它——多行时永久抛 TooManyResultsException,且 update 与 reset 都走这个查询,等于「保存」与「恢复默认」两个按钮一起坏,UI 里没有出路(AgentToolOverrideServiceImpl:78-89,台账 D4-23)。现在改成读时自愈收敛 + log.error 留痕。
索引只有 idx_tenant_tool(非唯一)与 PRIMARY。
agent_tool_override_audit——每行是一次「放松」
列:tool_name、risk_from/to、require_confirm_from/to、permissions_from/to、operator_id、operator_name、remark。
三个刻意的取舍写在列注释里:
operator_id取不到登录用户时为 NULL 但仍落行——「痕迹宁可少个名字也不能不记」;operator_name冗余存昵称,省掉事后关联system_users那一步;remark把操作人在维护面板填的理由原样抄一份——覆盖行的remark会被后续维护重写,审计行必须自带当时的事实。
本表只插不改,updater 恒等于 creator。与覆盖行 upsert 同事务。
接口清单
AgentToolDefController.java,@RequestMapping("/agent/tool-def"):
| 方法 | 路径 | 权限 | 摘要 |
|---|---|---|---|
| POST | /create | agent:tool-def:create | 创建用户自定义工具 |
| PUT | /update | agent:tool-def:update | 更新用户自定义工具 |
| DELETE | /delete | agent:tool-def:delete | 删除用户自定义工具 |
| GET | /get | agent:tool-def:query | 获得用户自定义工具 |
| GET | /page | agent:tool-def:query | 分页 |
| GET | /data-interface-list | agent:tool-def:query | 可关联的数据接口目录(自定义工具的下拉选项) |
| GET | /registered-list | agent:tool-def:query | 内置工具列表(合并生效值 + 代码默认值,供在线维护) |
| PUT | /registered-update | agent:tool-def:update | 维护内置工具(整份提交覆盖,改完立即生效、无需重启) |
| DELETE | /registered-reset | agent:tool-def:update | 恢复内置工具默认(删覆盖行,生效值退回代码声明) |
另有 AgentConfigController 的 GET /agent/config/tool-list(权限 agent:config:query)= 已注册工具的当前生效值快照,只读。
前端 api 函数分两组:AgentToolDefApi.{getToolDefPage, getToolDef, createToolDef, updateToolDef, deleteToolDef, getBindableInterfaceList} 与 AgentRegisteredToolApi.{getRegisteredToolList, updateRegisteredTool, resetRegisteredTool}。
错误码分段:网关 1_041_000_000-006、工具参数 1_041_002_000/001、工具目录 1_041_007_xxx、覆盖 1_041_009_000/001。
怎么扩展
加一个内置工具(有 Java 代码)
- 在
agent/tool/impl/<域>/下建类,@Component+implements AgentToolFunction; - 挂
@AgentTool,9 个属性里必填的是name、permission、risk:name用snake_case英文——它面向模型,给人看的是displayName;- 建议提成
public static final String NAME,方便ToolChangeExtractor与测试引用; permission用与界面按钮同一个权限串,走菜单通道注册,这样授权界面、诊断页、继承树全部复用;- 若工具内部串了多个写操作,把整条链的权限一次性声明进
extraPermissions(AND 语义); - 白名单/清单类工具设
truncateResult = false,体积问题用分页参数解决(学WidgetContractGetTool的types);
- 实现
execute(Map<String, Object> params)。🔴 实现里不要做权限校验——那是执行器的职责,在这里各写一遍必然有人漏写,而漏写的后果是无声的越权。例外见下面第 3 条; - 覆写
paramsSchema()说明每个参数叫什么、什么类型、必填还是可选。放在接口而不是注解上的理由(:31-37):它和execute里解析参数的代码是同一件事的两面,放同一个类里改的时候至少看得见;漂移的表现是模型按旧说明拼参数、工具报「缺少 xxx」、它照着错误再试一次,又是一轮计费; - 覆写
resultArrayKeys()——全部返回分支的并集,条件性的键(如仅在有告警时才出现的warnings)也要列。键名允许中文(选型指导、改表须知是真的中文键,不是笔误)。形状真的不可静态声明时返回空列表并把resultShapeKnown()覆写成false;🔴 别为了省事把「懒得核」写成false——那一栏教模型多绕一步; - 写工具还要加一个变更抽取器(
ToolChangeExtractor实现,toolId()与工具键逐字一致),否则变更清单退化成兜底——ToolChangeExtractorCoverageTest会红; - 参数解析建议复用同域的
XxxParamResolver(tool/impl/下有 4 个),它们兼容多形态入参(Map / JSON 串、pk/id别名)。类型错抛AGENT_TOOL_PARAM_INVALID业务异常,不要用IllegalArgumentException(见坑 9); - 跨模块只能注入他模块的 api 层:infra
FormDesignApi/DataModelApi/DataInterfaceApi、bpmBpmModelApi/BpmCategoryApi、systemMenuApi/SystemDiagnosticApi、aiAiRagApi。AgentArchitectureTest#testOnlyDependOnApiLayer钉死这条; - 注册是自动的:
AgentToolRegistry#collect:99用@Autowired(required = false)收全部AgentToolFunctionbean,register:294用AopUtils.getTargetClass读注解(工具可能被事务/日志 AOP 代理,代理类上读不到注解)。重名直接抛IllegalStateException而不是后者覆盖前者——「哪个生效」若取决于扫描顺序就会变成很难查的诡异行为,尤其它们的权限串可能不同。
动态权限:网关结构上表达不了时才在工具内校验
FormDataPermissionChecker 是唯一被允许在工具内做权限校验的地方,理由写在类注释 :13-19:网关校验的是 @AgentTool.permission() 声明的静态权限串,编译期就固定了,拿不到运行时的 code 参数;而表单数据权限是资源实例级的——每张已发布表单在发布时都会随菜单按钮创建一个 infra:form-design-run:{code}:{action} 权限点。「静态串无法表达动态 code」这一点,与 infra 的 FormDesignRuntimeController 因 @PreAuthorize 表达不了 code 而弃注解、改编程式校验是同一个道理。
反过来,DataInterfaceInvokeTool 类注释 :15-26 明确写了它不在工具内校验(与表单数据工具相反,且刻意为之):数据接口的权限模型本就是纯静态单权限点,网关完全管得住。该契约由 DataInterfaceToolContractTest 反射钉死,防将来被「顺手放宽」。
🔴 判断标准就一句:权限串里有没有运行时才知道的部分。有 → 工具内校验;没有 → 一律交给网关。
加一个自定义工具(无 Java 代码)
走管理页「自定义工具」tab(POST /agent/tool-def/create),当前唯一可执行来源是数据接口:config 填 {code, mode},mode = QUERY 只读、INVOKE 可调写型接口。执行体是 DataInterfaceToolFunction,现场 new、不是 Spring bean(AgentToolDefServiceImpl#buildFunction)。
🔴 登记时服务端强拦一条安全边界(AgentToolDefServiceImpl#validateForSave 第 7 步):
// 7) 🔴 安全边界:全能力(含写)执行必须由网关把守,而网关只认 permissions 列。
// DataInterfaceApi 自己不做任何权限校验,漏声明 = 任何登录用户都能经 AI 改库。
if (config.isInvoke() && !splitPermissions(reqVO.getPermissions()).contains(INVOKE_PERMISSION)) {
throw exception(AGENT_TOOL_DEF_INVOKE_PERMISSION_MISSING, toolKey);
}toolKey 禁止与代码工具重名;即便漏了校验,registry-first 也保证代码工具永远优先,用户工具抢不到那个名字。
坑与注意
每条都注明失效方向:fail-closed = 拒绝/报错(安全但可能功能不通),fail-open = 静默放行(危险)。
- 🔴 删空权限是真放松(fail-open)。网关读生效权限串,删空之后「登录即可调」立刻对 AI 生效。系统不拦截,只要求二次确认 + 落审计行。别以为「有审计」等于「有防护」。
- 🔴 降风险只改界面标注(界面误导型,最终 fail-closed)。运行期各道闸全读
getDeclaredRisk()。真实后果是「设计器标成无需确认 → 用户放进agent_loop→ 发布期被 V3 按声明值拒」,用户看到的是一句和他刚才在管理页做的操作对不上的报错。 - 🔴
agent_tool_override无唯一索引(fail-closed,且 UI 曾无出路)。软删墓碑占键,加不上唯一索引。并发保存能落两行 live;旧的selectOne实现会让「保存」与「恢复默认」两个按钮一起永久抛TooManyResultsException。现为读时自愈 +log.error。 - 🔴 白名单被截断 → 模型凭记忆编造(fail-open,静默)。
truncateResult = true的清单类结果被砍到 4000 字符,模型拿到「看起来完整、实则残缺」的白名单,无从知道自己没看全。已实测炸过一次(dateTimePicker事件,5 轮耗尽)。 - 🔴
form_data_update直传 infra 会静默清空未传字段(fail-open,最坏的一类数据损坏)。FormDesignApi.updateData是按 fieldMapping 全量覆盖的,传进去的 Map 里没有的字段一律写 null,而接口返回成功、无任何报错。工具内的对策是读-合-写:先getData读回整行、把 data 覆盖到同名字段、整体回写,效果是 PATCH 语义。这一手逐字镜像BpmOnlineFormDataHandler.update(bpm 侧因同样的全量覆盖语义而采用的权威先例)。 - 🔴 原子动作权限声明不全 → 卡在链条中间(fail-closed,但代价高)。留下「发布了但没菜单的表单」或「建了但没部署的流程模型」;后者非超管既删不掉也改不了。
- 🔴 动态权限串有三处硬编码副本(fail-closed,但功能静默失效)。infra Controller、infra Service、
FormDataPermissionChecker各拼一遍infra:form-design-run:{code}:{action}。infra 一改格式,AI 侧就对着一个不存在的串查询,返回 false → 拒绝:安全,但表单数据工具全部静默失灵。FormDataPermissionCheckerTest把格式与各 action 拼串钉死,infra 改格式时那条测试会先红。 - 🔴
DataInterfaceApi零权限校验(fail-open)。api 实现是薄委托,自身不做功能权限校验(DataInterfaceApiImpl全文无校验;FormDesignApiImpl#createData直转 runtime service)。AI 路径的权限完全来自网关的静态串——前提是走AgentToolExecutor。直连 service 即绕过,这正是收口的意义。 - 🔴 工具抛
IllegalArgumentException会让模型白烧一轮计费(fail-open 方向是钱)。AGENT_TOOL_PARAM_INVALID必须是业务异常:IllegalArgumentException会被全局异常处理器当成未知异常,返回「系统异常」——而 AI 的自修正完全依赖错误信息的可读性,「系统异常」什么也说明不了,只会让它原样重试一遍。 LIST_WRAPPER_KEYS那条判据没有守卫测试(fail-open 方向是钱)。「这名字不在声明里 ⟹ 结果对象上没这个键」依赖一条实测事实(2026-10-09 把tool/impl全翻了一遍:这五个名字只被当数组键用过,一共 6 处,且全部已声明)。将来哪个工具回一个标量list(比如{"list": "该表单没有列表页"}),V26 就会误报,而误报会让规划期的自修正回路去改一个没病的地方——每轮都是一次真实计费。别用这五个名字当标量键,要表达「没有列表」就换个名字(message/reason)。这条纪律只能靠 review 兜。AGENT_FORM_DATA_PERMISSION_DENIED与AGENT_PERMISSION_DENIED是两条不同的错误码,别合并:前者是「你能不能对这张表单的数据做这个动作」(资源实例级),后者是「你能不能用这个工具」(静态工具权限)。合并了以后诊断页就分不清该去查角色授权还是查表单发布。datamodel_create的重放必须幂等(fail-closed)。Agent 的失败重试、Plan 的重复确认、用户点两次「同意执行」都会让它跑两遍,而副作用不可回滚。第二次会撞DATA_MODEL_TABLE_EXISTS,模型拿到「表已存在」就认为自己这一步失败了——而事实恰恰相反。后果不是多报一个错:模型会去「修复」它,常见做法是换个表名再建一次(库里多出一张重复表,没人知道哪张算数)。inspectExisting:185-188的 🔴 还有一条:读不到就当作「没登记」,绝不当作「相符」——拿一个未知当已知,表现是告诉模型「你要的表已经有了」,而它要的表压根不存在,模型会接着往上绑表单、建流程,一路都成功,直到运行时才炸。- 前端
RISK_ORDER是后端 severity 的第三份副本(前两份是枚举声明顺序与severity字段)。后端有RiskLevelTest钉住那两份一致,前端这份钉不住,只能靠「谁改枚举谁同步这里」。真漂移了不会静默放过:两边判据不同向,表现要么是前端弹框而后端照收(多一次没意义的确认),要么是前端不弹而后端拒(保存失败并带明确错误文案)。 - 内置工具列表页刻意不给快捷开关(
index.vue:120-126)。后端detectRelaxation是拿提交值比代码声明值:一个已经被放松过的工具,哪怕这次只翻enabled,也会被判成放松并要求confirmRelax = true。那个true的含义是「操作人看过代价后点的头」,列表页替用户恒发true等于把后端那道闸拆掉。所以列表页保持只读展示,要停用请走「维护」面板。 - 分组名下拉
allow-create,手打一个新分组名会凭空拆组(静默界面退化)。同名工具会被拆成两半显示,且没有任何报错。
→ 本节是贴着上下文的提醒;按症状查的全域清单见坑位索引。
测试
enlev-module-agent/src/test/java/.../ 下与工具体系直接相关的测试文件 43 个,核心几份:
| 测试 | 行数 | 钉住什么 |
|---|---|---|
framework/agent/core/tool/AgentToolExecutorTest | 366 | 收口顺序、沙箱 mock、registry-first 回落 |
framework/agent/core/gateway/AgentSecurityGatewayTest | 273 | 六步断言逐个 fail-close |
framework/agent/core/tool/AgentToolRegistryOverrideTest | 150 | 声明值/生效值分层、按租户解析、空值语义 |
framework/agent/core/tool/RiskLevelTest | 75 | severity 升序与声明顺序一致、parseOrHigh 回落 |
framework/agent/core/manifest/ToolChangeExtractorCoverageTest | 415 | 「每个写工具都有变更抽取器」双向强制(正向:写工具没抽取器 ⟹ 清单退化;反向:抽取器 toolId() 指向不存在的工具键 ⟹ 死代码) |
framework/AgentArchitectureTest | — | 只依赖他模块 api 层、禁用 executeIgnore |
framework/agent/core/plan/WorkflowDslPlannerPromptTest | — | 提示词里那段 JS 的 MAIN = [...] 与 ToolResultShapes.MAIN_LIST_KEYS 逐字比对,且把整段 pickRows 丢进真沙箱跑实测形状 |
tool/impl/formdata/FormDataPermissionCheckerTest | — | 动态权限串格式与各 action 拼串 |
tool/impl/datainterface/DataInterfaceToolContractTest | — | 反射钉住「工具内不做权限校验」这条契约 |
tool/impl/form/WidgetContractGetToolTest | — | 两段式返回与 truncateResult = false |
tool/impl/formdata/ 五个工具每个都有独立测试(FormDataCreateToolTest / FormDataUpdateToolTest / FormDataDeleteToolTest / FormDataGetToolTest / FormDataPageToolTest)。
这里的测试体裁值得学
ToolChangeExtractorCoverageTest 与 AgentArchitectureTest 都是源码扫描式守卫测试——它们不断言运行结果,而是遍历源码/注册表断言「某类东西必须成对出现」「某个 API 不得被调用」。这类测试的价值在于把「纪律」变成会红的东西:纪律写在注释里只约束读注释的人,写成测试则约束所有提交。详见测试规范。
未证实
读源码读到、但没跑起来验证的:
FormPublishBundleTool的五步执行体未逐行读(已证它 import infraFormDesignApi与 systemMenuApi;类注释里写的「POST /system/menu/create」是示意,实际经 api 层)。AgentDefServiceImpl#validateToolKeys(发布期只读校验)、WorkflowDslValidator的 V3/V11/V26、WorkflowChangeManifestBuilder的实现未逐行读,本页对它们的描述转引自工具侧注释。- infra
FormDesignRuntimeService.create的行级权限过滤、bpmBpmModelApi内部是否再校验:未读对应实现,转引自FormDataCreateTool/AgentTool注释。 doc_search与diagnose_not_working两个工具的执行体未细读(权限均agent:assistant:use、风险 READ_ONLY 已证)。
推理得出、没有取证的:
- 「删空权限后 AI 立刻能调」是从
checkTool读生效值 +ToolOverride空值语义推出来的,没有真的在库里删空一行再调一次工具验证。 agent_tool_override名下datamodel_create有 28 行墓碑,这个数字转引自AgentToolOverrideServiceImpl:78-89的注释,本页没有连库数过。- 「4000 字符只装得下前 8 个控件(到
select为止)、datePicker排第 13 位」转引自AgentTool:117-118与WidgetContractGetTool:34-48的注释,是当时那次实测的记录,本页未复现。 - 全部测试文件已确认存在且行数已数,但本页写作时未执行,其断言与实际行为是否一致未验证。
相关页面
- Agent 架构总览 —— 工具体系在整个模块里的位置
- 安全与提示注入防护 ——
PromptDataFence、跨租户红线、OutputModerator - Plan 预览确认与 HITL ——
requireConfirm的另一半:确认之后才执行 - 对话与 ReAct loop ——
tool_callsJSON 协议与结果回灌 - 工作流引擎 ——
ToolNodeExecutor与沙箱试跑 - 计量与配额 —— 「误报让自修正回路白烧一轮」烧的是什么
- 使用手册 04 AI · 工具管理 —— 管理页长什么样
