Skip to content

工具体系 ​

适用端:后端 | 类型:机制

定制功能

@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 对它完全无效。

于是有两个问题必须同时解决:

  1. 权限怎么校验——不能靠注解,只能编程式;而且必须与界面按钮用同一个权限串,否则授权页上勾了也不算。
  2. 身份从哪来——工具执行往往不在 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.java128工具声明注解,9 个属性
AgentToolFunction.java109执行契约:execute + paramsSchema + resultArrayKeys + resultShapeKnown
AgentToolRegistry.java387注册表:声明值(final)+按租户现算的生效值
AgentToolExecutor.java161所有工具调用的唯一入口
RiskLevel.java119五档风险枚举
ToolResultShapes.java152「返回形状」判据,提示词与校验器 V26 共用唯一一份
ToolKeySuggester.java55工具键打错时给相近候选(阈值 0.5、最多 2 条)
datainterface/DataInterfaceTool{Config,Function}.java89 / 110自定义工具的配置解析与动态执行体
agent/framework/agent/core/gateway/AgentSecurityGateway.java176六步断言
AgentExecutionContext.java213身份快照与跨线程显式重建
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.java144管理页接口

前端: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 是整条链路的收口。它的类注释把三件事写死在一处:过网关、重建身份、执行工具。

java
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 只有三行,分派给三个私有方法:

步断言位置失败错误码
1context.getUserId() != nullcheckIdentity:99AGENT_NO_LOGIN_CONTEXT
2context.getTenantId() != null:102AGENT_NO_TENANT_CONTEXT
3loginUser != null 且非跨租户状态:106、:113AGENT_NO_LOGIN_CONTEXT / AGENT_CROSS_TENANT_DENIED
4agent_config 查得到当前租户的行checkEnabled:126AGENT_DISABLED
5工具存在且已启用checkTool:143AGENT_TOOL_NOT_FOUND / AGENT_TOOL_DISABLED
6声明的权限串逐个 AND 通过:152AGENT_PERMISSION_DENIED

第 3 步的跨租户拒绝,理由不是「不方便」而是「校验会失效」:

java
// 🔴 跨租户状态下必须拒绝。
// 理由不是「不方便」而是「校验会失效」: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 语义,且要求全都有:

java
// 与界面按钮走同一套权限点。注意这里是编程式校验 ——
// 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 ​

枚举中文requireConfirmseverity典型工具
READ_ONLY只读false0查表结构、查表单设计、查控件契约
LOW低风险true1form_save_draft、datamodel_import、form_data_create
MEDIUM中风险true2form_update、form_data_update
HIGH高风险true3form_publish_bundle、process_create_and_deploy、data_interface_invoke
IRREVERSIBLE不可逆true4datamodel_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 是这个判据的唯一出处,两份常量:

java
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数据建模IRREVERSIBLEinfra:data-model:create
datamodel_update数据建模IRREVERSIBLEinfra:data-model:update
datamodel_import数据建模LOWinfra:data-model:create
datamodel_preview_ddl数据建模READ_ONLYinfra:data-model:query
datamodel_list数据建模READ_ONLYinfra:data-model:query
datamodel_structure数据建模READ_ONLYinfra:data-model:query
datamodel_column_types数据建模READ_ONLYinfra:data-model:query
datamodel_physical_tables数据建模READ_ONLYinfra:data-model:query
form_save_draft表单设计LOWinfra:form-design:create
form_update表单设计MEDIUMinfra:form-design:update
form_publish_bundle表单设计HIGHinfra:form-design:publishsystem:menu:create、system:permission:assign-role-menu
form_list表单设计READ_ONLYinfra:form-design:query
form_get表单设计READ_ONLYinfra:form-design:query
form_inspect表单设计READ_ONLYinfra:form-design:query
widget_contract_get控件契约READ_ONLYinfra:form-design:query
form_data_create表单数据LOWagent:assistant:use
form_data_update表单数据MEDIUMagent:assistant:use
form_data_delete表单数据HIGHagent:assistant:use
form_data_get表单数据READ_ONLYagent:assistant:use
form_data_page表单数据READ_ONLYagent:assistant:use
data_interface_invoke数据接口HIGHinfra:data-interface:invoke
data_interface_list数据接口READ_ONLYagent:assistant:use
data_interface_query数据接口READ_ONLYagent:assistant:use
process_create_and_deploy流程审批HIGHbpm:model:createbpm:model:deploy
process_list流程审批READ_ONLYbpm:model:query
process_get_simple流程审批READ_ONLYbpm:model:query
doc_search平台手册READ_ONLYagent:assistant:use
diagnose_not_working诊断排错READ_ONLYagent:assistant:use
explain_form_rule诊断排错READ_ONLYinfra:form-design:query
explain_process诊断排错READ_ONLYbpm: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/createagent:tool-def:create创建用户自定义工具
PUT/updateagent:tool-def:update更新用户自定义工具
DELETE/deleteagent:tool-def:delete删除用户自定义工具
GET/getagent:tool-def:query获得用户自定义工具
GET/pageagent:tool-def:query分页
GET/data-interface-listagent:tool-def:query可关联的数据接口目录(自定义工具的下拉选项)
GET/registered-listagent:tool-def:query内置工具列表(合并生效值 + 代码默认值,供在线维护)
PUT/registered-updateagent:tool-def:update维护内置工具(整份提交覆盖,改完立即生效、无需重启)
DELETE/registered-resetagent: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 代码) ​

  1. 在 agent/tool/impl/<域>/ 下建类,@Component + implements AgentToolFunction;
  2. 挂 @AgentTool,9 个属性里必填的是 name、permission、risk:
    • name 用 snake_case 英文——它面向模型,给人看的是 displayName;
    • 建议提成 public static final String NAME,方便 ToolChangeExtractor 与测试引用;
    • permission 用与界面按钮同一个权限串,走菜单通道注册,这样授权界面、诊断页、继承树全部复用;
    • 若工具内部串了多个写操作,把整条链的权限一次性声明进 extraPermissions(AND 语义);
    • 白名单/清单类工具设 truncateResult = false,体积问题用分页参数解决(学 WidgetContractGetTool 的 types);
  3. 实现 execute(Map<String, Object> params)。🔴 实现里不要做权限校验——那是执行器的职责,在这里各写一遍必然有人漏写,而漏写的后果是无声的越权。例外见下面第 3 条;
  4. 覆写 paramsSchema() 说明每个参数叫什么、什么类型、必填还是可选。放在接口而不是注解上的理由(:31-37):它和 execute 里解析参数的代码是同一件事的两面,放同一个类里改的时候至少看得见;漂移的表现是模型按旧说明拼参数、工具报「缺少 xxx」、它照着错误再试一次,又是一轮计费;
  5. 覆写 resultArrayKeys()——全部返回分支的并集,条件性的键(如仅在有告警时才出现的 warnings)也要列。键名允许中文(选型指导、改表须知 是真的中文键,不是笔误)。形状真的不可静态声明时返回空列表并把 resultShapeKnown() 覆写成 false;🔴 别为了省事把「懒得核」写成 false——那一栏教模型多绕一步;
  6. 写工具还要加一个变更抽取器(ToolChangeExtractor 实现,toolId() 与工具键逐字一致),否则变更清单退化成兜底——ToolChangeExtractorCoverageTest 会红;
  7. 参数解析建议复用同域的 XxxParamResolver(tool/impl/ 下有 4 个),它们兼容多形态入参(Map / JSON 串、pk/id 别名)。类型错抛 AGENT_TOOL_PARAM_INVALID 业务异常,不要用 IllegalArgumentException(见坑 9);
  8. 跨模块只能注入他模块的 api 层:infra FormDesignApi/DataModelApi/DataInterfaceApi、bpm BpmModelApi/BpmCategoryApi、system MenuApi/SystemDiagnosticApi、ai AiRagApi。AgentArchitectureTest#testOnlyDependOnApiLayer 钉死这条;
  9. 注册是自动的:AgentToolRegistry#collect:99 用 @Autowired(required = false) 收全部 AgentToolFunction bean,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 步):

java
// 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 = 静默放行(危险)。

  1. 🔴 删空权限是真放松(fail-open)。网关读生效权限串,删空之后「登录即可调」立刻对 AI 生效。系统不拦截,只要求二次确认 + 落审计行。别以为「有审计」等于「有防护」。
  2. 🔴 降风险只改界面标注(界面误导型,最终 fail-closed)。运行期各道闸全读 getDeclaredRisk()。真实后果是「设计器标成无需确认 → 用户放进 agent_loop → 发布期被 V3 按声明值拒」,用户看到的是一句和他刚才在管理页做的操作对不上的报错。
  3. 🔴 agent_tool_override 无唯一索引(fail-closed,且 UI 曾无出路)。软删墓碑占键,加不上唯一索引。并发保存能落两行 live;旧的 selectOne 实现会让「保存」与「恢复默认」两个按钮一起永久抛 TooManyResultsException。现为读时自愈 + log.error。
  4. 🔴 白名单被截断 → 模型凭记忆编造(fail-open,静默)。truncateResult = true 的清单类结果被砍到 4000 字符,模型拿到「看起来完整、实则残缺」的白名单,无从知道自己没看全。已实测炸过一次(dateTimePicker 事件,5 轮耗尽)。
  5. 🔴 form_data_update 直传 infra 会静默清空未传字段(fail-open,最坏的一类数据损坏)。FormDesignApi.updateData 是按 fieldMapping 全量覆盖的,传进去的 Map 里没有的字段一律写 null,而接口返回成功、无任何报错。工具内的对策是读-合-写:先 getData 读回整行、把 data 覆盖到同名字段、整体回写,效果是 PATCH 语义。这一手逐字镜像 BpmOnlineFormDataHandler.update(bpm 侧因同样的全量覆盖语义而采用的权威先例)。
  6. 🔴 原子动作权限声明不全 → 卡在链条中间(fail-closed,但代价高)。留下「发布了但没菜单的表单」或「建了但没部署的流程模型」;后者非超管既删不掉也改不了。
  7. 🔴 动态权限串有三处硬编码副本(fail-closed,但功能静默失效)。infra Controller、infra Service、FormDataPermissionChecker 各拼一遍 infra:form-design-run:{code}:{action}。infra 一改格式,AI 侧就对着一个不存在的串查询,返回 false → 拒绝:安全,但表单数据工具全部静默失灵。FormDataPermissionCheckerTest 把格式与各 action 拼串钉死,infra 改格式时那条测试会先红。
  8. 🔴 DataInterfaceApi 零权限校验(fail-open)。api 实现是薄委托,自身不做功能权限校验(DataInterfaceApiImpl 全文无校验;FormDesignApiImpl#createData 直转 runtime service)。AI 路径的权限完全来自网关的静态串——前提是走 AgentToolExecutor。直连 service 即绕过,这正是收口的意义。
  9. 🔴 工具抛 IllegalArgumentException 会让模型白烧一轮计费(fail-open 方向是钱)。AGENT_TOOL_PARAM_INVALID 必须是业务异常:IllegalArgumentException 会被全局异常处理器当成未知异常,返回「系统异常」——而 AI 的自修正完全依赖错误信息的可读性,「系统异常」什么也说明不了,只会让它原样重试一遍。
  10. LIST_WRAPPER_KEYS 那条判据没有守卫测试(fail-open 方向是钱)。「这名字不在声明里 ⟹ 结果对象上没这个键」依赖一条实测事实(2026-10-09 把 tool/impl 全翻了一遍:这五个名字只被当数组键用过,一共 6 处,且全部已声明)。将来哪个工具回一个标量 list(比如 {"list": "该表单没有列表页"}),V26 就会误报,而误报会让规划期的自修正回路去改一个没病的地方——每轮都是一次真实计费。别用这五个名字当标量键,要表达「没有列表」就换个名字(message / reason)。这条纪律只能靠 review 兜。
  11. AGENT_FORM_DATA_PERMISSION_DENIED 与 AGENT_PERMISSION_DENIED 是两条不同的错误码,别合并:前者是「你能不能对这张表单的数据做这个动作」(资源实例级),后者是「你能不能用这个工具」(静态工具权限)。合并了以后诊断页就分不清该去查角色授权还是查表单发布。
  12. datamodel_create 的重放必须幂等(fail-closed)。Agent 的失败重试、Plan 的重复确认、用户点两次「同意执行」都会让它跑两遍,而副作用不可回滚。第二次会撞 DATA_MODEL_TABLE_EXISTS,模型拿到「表已存在」就认为自己这一步失败了——而事实恰恰相反。后果不是多报一个错:模型会去「修复」它,常见做法是换个表名再建一次(库里多出一张重复表,没人知道哪张算数)。inspectExisting:185-188 的 🔴 还有一条:读不到就当作「没登记」,绝不当作「相符」——拿一个未知当已知,表现是告诉模型「你要的表已经有了」,而它要的表压根不存在,模型会接着往上绑表单、建流程,一路都成功,直到运行时才炸。
  13. 前端 RISK_ORDER 是后端 severity 的第三份副本(前两份是枚举声明顺序与 severity 字段)。后端有 RiskLevelTest 钉住那两份一致,前端这份钉不住,只能靠「谁改枚举谁同步这里」。真漂移了不会静默放过:两边判据不同向,表现要么是前端弹框而后端照收(多一次没意义的确认),要么是前端不弹而后端拒(保存失败并带明确错误文案)。
  14. 内置工具列表页刻意不给快捷开关(index.vue:120-126)。后端 detectRelaxation 是拿提交值比代码声明值:一个已经被放松过的工具,哪怕这次只翻 enabled,也会被判成放松并要求 confirmRelax = true。那个 true 的含义是「操作人看过代价后点的头」,列表页替用户恒发 true 等于把后端那道闸拆掉。所以列表页保持只读展示,要停用请走「维护」面板。
  15. 分组名下拉 allow-create,手打一个新分组名会凭空拆组(静默界面退化)。同名工具会被拆成两半显示,且没有任何报错。

→ 本节是贴着上下文的提醒;按症状查的全域清单见坑位索引。

测试 ​

enlev-module-agent/src/test/java/.../ 下与工具体系直接相关的测试文件 43 个,核心几份:

测试行数钉住什么
framework/agent/core/tool/AgentToolExecutorTest366收口顺序、沙箱 mock、registry-first 回落
framework/agent/core/gateway/AgentSecurityGatewayTest273六步断言逐个 fail-close
framework/agent/core/tool/AgentToolRegistryOverrideTest150声明值/生效值分层、按租户解析、空值语义
framework/agent/core/tool/RiskLevelTest75severity 升序与声明顺序一致、parseOrHigh 回落
framework/agent/core/manifest/ToolChangeExtractorCoverageTest415「每个写工具都有变更抽取器」双向强制(正向:写工具没抽取器 ⟹ 清单退化;反向:抽取器 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 infra FormDesignApi 与 system MenuApi;类注释里写的「POST /system/menu/create」是示意,实际经 api 层)。
  • AgentDefServiceImpl#validateToolKeys(发布期只读校验)、WorkflowDslValidator 的 V3/V11/V26、WorkflowChangeManifestBuilder 的实现未逐行读,本页对它们的描述转引自工具侧注释。
  • infra FormDesignRuntimeService.create 的行级权限过滤、bpm BpmModelApi 内部是否再校验:未读对应实现,转引自 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 的注释,是当时那次实测的记录,本页未复现。
  • 全部测试文件已确认存在且行数已数,但本页写作时未执行,其断言与实际行为是否一致未验证。

相关页面 ​