소스 검색

feat: 严格优化 Agent 工具描述与参数必填契约

- 按工具语义补齐 required=true:person 通话基站/常联系/通话汇总/常交易/银行卡汇总,track 碰面/住宿/同住/同行/票务明细,fileId、图谱节点明细、query_table_data 条件字段等。- 夜间通话明细改为 personName / personNames 二选一校验,并在参数描述中写清;工具 description 同步说明必填边界。- 系统提示词新增「工具调用规则」:核对 schema required、先查人员/表名再调用、参数格式/枚举/默认值、Error 后修正重试、最少工具原则。- 新增 ToolSpecRequiredFieldContractTest(description 写必填 ⇒ required=true;required=true ⇒ 描述写明必填)与 MCP 嵌套 required schema 回归。- AI_AGENT.md 增加工具描述/参数规范与行业参考(Anthropic / OpenAI / LangChain / Tool Calling Schema Standards)。
cc 1 일 전
부모
커밋
22b1a3869a

+ 12 - 0
AI_AGENT.md

@@ -215,6 +215,18 @@ entity/       AgentChatSession、AgentMessage、AgentModel、AgentModelProvider
 > `graph` 的明细类工具同样没实现。改动工具清单时同步更新
 > `AgentToolRegistryTest#TOTAL_BUSINESS_TOOLS` 与 `AgentScopeMcpToolProviderTest` 的数量下限。
 
+### 工具描述与参数规范(新增 / 修改工具必读)
+
+工具 description 和每个 `@ToolParam.description` 直接决定模型是否会选对工具、是否会漏传参数,按「面向 Agent 的 UX」对待:
+
+- **工具 description**:一句话写清用途;再写「何时使用 / 何时不要用 / 与其他相似工具的边界」;涉及结果形态时写清返回结构(如 `nodeList/edgeList`、CSV 产物)。优先用短句和 `★` 标记关键边界,避免大段无结构说明。
+- **每个参数必须有 description**:写清类型、格式、枚举取值、单位、默认值、是否必填。日期统一 `yyyy-MM-dd`,金额按元,比较符枚举使用描述中的原值。
+- **语义必填字段必须 `required = true`**,并在 description 里写「必填 / 必传」;条件必填(如「lx=2/3 时必传」)或「建议必传」不要硬标 required,改为工具内可读校验 + description 说明。
+- **不要暴露** `orderKey` / `sort` / `tableName` 这类会被拼进 SQL 的内部字段;入参只保留真正参与业务条件的字段。
+- **错误信息要能指导重试**:缺参时给出期望结构和示例(参考 `get_case_graph` 的 `personNames` 报错),不要只抛「参数错误」。
+- **schema 契约测试**:`ToolSpecRequiredFieldContractTest` 会扫描所有 `*ToolSpecs`,校验「description 写必填 ⇒ required=true」以及「required=true ⇒ description 写明必填」;新增参数会自动纳入检查。
+- 参考行业实践:Anthropic 的 [Define tools](https://platform.claude.com/docs/en/agents-and-tools/tool-use/define-tools.md)(描述即 Agent UX、写清边界与示例)、OpenAI 的 [Function calling](https://platform.openai.com/docs/guides/function-calling)(strict schema / required)、LangChain 的 [Tools](https://docs.langchain.com/oss/python/langchain/tools)(参数 docstring)、以及社区整理的 [Tool Calling Schema Standards](https://github.com/agentpatterns-ai/website/blob/main/standards/tool-calling-schema-standards.md)(每个参数都写 description、schema 尽量扁平)。
+
 > 分组机制要点(`AgentToolRegistry` 注释里有完整说明):
 > - 组必须是 `ToolGroupScope.META`,否则 `reset_equipped_tools` 会拒绝("not manageable by this tool")
 >   且组名不会出现在该工具 `to_activate` 的 enum 里;

+ 8 - 1
ai-server/src/main/java/com/zsjz/ai/module/agent/service/AgentService.java

@@ -566,7 +566,14 @@ public class AgentService extends ServiceImpl<AgentMapper, AgentEntity> {
      */
     private String appendRenderPrompt(String sysPrompt) {
         String renderPrompt = """
-                
+
+                ## 工具调用规则(影响参数准确性,必须遵守)
+                1. 只调用当前可见工具列表中的工具;不要臆造工具名或参数。
+                2. 调用前核对该工具 schema 的 required:required 中的参数必须提供,不能漏;人员姓名/表名/字段不确定时先查(list_persons / list_tables / search_table_schema),不要猜。
+                3. 参数 description 里的格式、单位、枚举、默认值必须遵守:日期统一 yyyy-MM-dd,金额按元,枚举使用描述中的原值;必填参数缺失时先补查,不要传空串。
+                4. 工具返回 Error 时按错误提示修正参数后重试,不要原样重复同一组参数;连续失败时缩小查询范围或改用更合适的工具。
+                5. 一次只调用完成任务所需的最少工具,不要把无关工具一起激活;工具返回的全量数据以 CSV 产物为准,正文只做结论与判读。
+
                 ## 回复输出格式契约(前端渲染依赖,必须遵守)
                 聊天前端只把以下两种围栏代码块渲染为交互组件,其余代码块按普通文本展示:
                 1. 表格类工具(execute_sql 与各业务分析工具)的数据结果会自动导出为 CSV 产物文件,与该次工具调用**成对**展示在「过程」详情里,用户可预览前 200 行、下载全量。

+ 9 - 3
ai-server/src/main/java/com/zsjz/ai/module/agent/tools/CallAnalysisTool.java

@@ -181,17 +181,23 @@ public class CallAnalysisTool {
      */
     @Tool(name = "stat_call_night_detail",
           description = "统计夜间通话的第二层明细:在夜间时段内按「小时段」展开某个人与其对方的通话次数。"
-                  + "★ 必须传 personName(本方姓名)定位到某个人;通常先调用 stat_call_night_summary 拿到第一层结果再下钻。"
+                  + "★ personName 或 personNames 至少传一个(用于定位某个人);通常先调用 stat_call_night_summary 拿到第一层结果再下钻。"
                   + "otherName / otherPhone 可进一步限定对方(精确匹配,传「未知对手」「未知号码」表示对方信息缺失)。"
                   + "时段参数与第一层一致(nightStart/nightEnd 默认 18/6)。",
           readOnly = true)
     public ToolResultBlock statCallNightDetail(
-            @ToolParam(name = "query", description = "夜间通话明细筛选条件,personName 必传")
+            @ToolParam(name = "query", description = "夜间通话明细筛选条件,personName 或 personNames 至少传一个")
             CallToolSpecs.NightSpec query) {
         CallToolSpecs.NightSpec spec = query == null ? new CallToolSpecs.NightSpec() : query;
         try {
+            String personName = firstOf(spec.personName, spec.personNames);
+            if (personName == null) {
+                return ToolResults.error("personName 或 personNames 至少传一个(用于定位某个人),"
+                        + "可先调用 stat_call_night_summary 拿到第一层结果");
+
+            }
             CallNightQuery q = new CallNightQuery();
-            q.setPersonName(firstOf(spec.personName, spec.personNames));
+            q.setPersonName(personName);
             q.setOtherName(trimToNull(spec.otherName));
             q.setOtherPhone(trimToNull(spec.otherPhone));
             q.setStartDate(parseDate(spec.startDate, "startDate"));

+ 6 - 2
ai-server/src/main/java/com/zsjz/ai/module/agent/tools/CallToolSpecs.java

@@ -104,12 +104,16 @@ public final class CallToolSpecs {
     public static class NightSpec {
 
         /** 本方姓名列表 */
-        @ToolParam(name = "personNames", description = "本方(机主)姓名,可传多个;不传则统计全案所有对象人员",
+        @ToolParam(name = "personNames",
+                   description = "本方(机主)姓名,可传多个(OR)。汇总统计不传则统计全案;"
+                           + "明细工具要求 personNames 或 personName 至少传一个",
                    required = false)
         public List<String> personNames;
 
         /** 本方姓名(第二层下钻定位用) */
-        @ToolParam(name = "personName", description = "本方姓名,第二层(明细)下钻时必传,用于定位某个人的夜间通话明细",
+        @ToolParam(name = "personName",
+                   description = "本方姓名(单值写法)。明细工具:与 personNames 至少传一个,用于定位某个人的夜间通话明细;"
+                           + "汇总统计:作为 personNames 的替代写法",
                    required = false)
         public String personName;
 

+ 2 - 2
ai-server/src/main/java/com/zsjz/ai/module/agent/tools/FileToolSpecs.java

@@ -93,8 +93,8 @@ public final class FileToolSpecs {
          * 材料 ID
          */
         @ToolParam(name = "fileId",
-                   description = "材料 ID,取自 list_file_profiles 返回表格的 fileId 列(或 stat 结果里对应分类下再列一次)。必传",
-                   required = false)
+                   description = "材料 ID,必填;取自 list_file_profiles 返回表格的 fileId 列(或 stat 结果里对应分类下再列一次)",
+                   required = true)
         public Long fileId;
 
         /**

+ 4 - 4
ai-server/src/main/java/com/zsjz/ai/module/agent/tools/GovernTableTool.java

@@ -77,7 +77,7 @@ public class GovernTableTool {
           readOnly = true)
     public ToolResultBlock queryTableData(
             @ToolParam(name = "query",
-                       description = "查询对象:tableName 必填(要查询的表英文名),conditions 可选(不传=取该表全量)")
+                       description = "查询对象,必填:tableName 必填(要查询的表英文名),conditions 可选(不传=取该表全量)")
             QuerySpec query) {
         try {
             if (query == null) {
@@ -285,17 +285,17 @@ public class GovernTableTool {
 
         /** 表列名 */
         @ToolParam(name = "column",
-                   description = "表列名(英文,可用 list_tables 查询)", required = false)
+                   description = "表列名(英文,必填;可用 list_tables 查询)", required = true)
         public String column;
 
         /** 比较符 */
         @ToolParam(name = "operator",
-                   description = "比较符:= != > >= < <= LIKE IS NULL IS NOT NULL", required = false)
+                   description = "比较符,必填;只能取 = != > >= < <= LIKE IS NULL IS NOT NULL", required = true)
         public String operator;
 
         /** 比较值 */
         @ToolParam(name = "value",
-                   description = "比较值;IS NULL / IS NOT NULL 时不用传", required = false)
+                   description = "比较值;operator 为 IS NULL / IS NOT NULL 时不用传,其余比较符必填", required = false)
         public String value;
     }
 }

+ 1 - 1
ai-server/src/main/java/com/zsjz/ai/module/agent/tools/GraphAnalysisTool.java

@@ -114,7 +114,7 @@ public class GraphAnalysisTool {
           readOnly = true)
     public ToolResultBlock getCaseGraph(
             @ToolParam(name = "query",
-                       description = "关系图谱筛选条件,结构必须是 {\"type\":[...],\"personNames\":[\"姓名\"]};"
+                       description = "关系图谱筛选条件,必填;结构必须是 {\"type\":[...],\"personNames\":[\"姓名\"]};"
                                + "type 与 personNames 都必填,personNames 是数组")
             GraphToolSpecs.GraphViewSpec query) {
         GraphToolSpecs.GraphViewSpec spec = query == null ? new GraphToolSpecs.GraphViewSpec() : query;

+ 4 - 4
ai-server/src/main/java/com/zsjz/ai/module/agent/tools/GraphToolSpecs.java

@@ -198,15 +198,15 @@ public final class GraphToolSpecs {
 
         /** 节点类型 */
         @ToolParam(name = "clazz",
-                   description = "节点类型,即 nodeList[].clazz,只能取 card(银行卡/账户节点)或 tel(手机号节点)。必传",
-                   required = false)
+                   description = "节点类型,即 nodeList[].clazz,只能取 card(银行卡/账户节点)或 tel(手机号节点)。必填",
+                   required = true)
         public String clazz;
 
         /** 节点标识 */
         @ToolParam(name = "node",
                    description = "节点标识,即 nodeList[].node:clazz=card 时是卡号(该卡作为本方卡或对方卡都算),"
-                           + "clazz=tel 时是手机号(该号作为本方号码)。必传",
-                   required = false)
+                           + "clazz=tel 时是手机号(该号作为本方号码)。必填",
+                   required = true)
         public String node;
 
         /** 交易金额阈值(仅 card 节点) */

+ 9 - 9
ai-server/src/main/java/com/zsjz/ai/module/agent/tools/PersonToolSpecs.java

@@ -43,7 +43,7 @@ public final class PersonToolSpecs {
     public static class CallStationSpec {
 
         /** 本方姓名 */
-        @ToolParam(name = "personName", description = "本方(机主)姓名,精确匹配,必传,如「张三」", required = false)
+        @ToolParam(name = "personName", description = "本方(机主)姓名,精确匹配,必传,如「张三」", required = true)
         public String personName;
 
         /** 每页行数 */
@@ -59,7 +59,7 @@ public final class PersonToolSpecs {
     public static class CallTop10Spec {
 
         /** 本方姓名 */
-        @ToolParam(name = "personName", description = "本方(机主)姓名,精确匹配,必传,如「张三」", required = false)
+        @ToolParam(name = "personName", description = "本方(机主)姓名,精确匹配,必传,如「张三」", required = true)
         public String personName;
 
         /** 开始日期 */
@@ -82,7 +82,7 @@ public final class PersonToolSpecs {
     public static class CallRecordSummarySpec {
 
         /** 本方姓名 */
-        @ToolParam(name = "personName", description = "本方(机主)姓名,精确匹配,必传,如「张三」", required = false)
+        @ToolParam(name = "personName", description = "本方(机主)姓名,精确匹配,必传,如「张三」", required = true)
         public String personName;
 
         /** 对方姓名 */
@@ -118,8 +118,8 @@ public final class PersonToolSpecs {
 
         /** 号码列表 */
         @ToolParam(name = "phoneList",
-                   description = "要查询运营商归属的本方号码,可传多个,必传(至少一个)——底层 SQL 用 IN 展开,为空会报错",
-                   required = false)
+                   description = "要查询运营商归属的本方号码,可传多个,必填(至少一个)——底层 SQL 用 IN 展开,为空会报错",
+                   required = true)
         public List<String> phoneList;
 
         /** 运营商 */
@@ -139,7 +139,7 @@ public final class PersonToolSpecs {
     public static class TransTop10Spec {
 
         /** 本方姓名 */
-        @ToolParam(name = "personName", description = "本方(机主)姓名,精确匹配,必传,如「张三」", required = false)
+        @ToolParam(name = "personName", description = "本方(机主)姓名,精确匹配,必传,如「张三」", required = true)
         public String personName;
 
         /** 统计维度(分组字段) */
@@ -260,7 +260,7 @@ public final class PersonToolSpecs {
     public static class TransOtherNameSpec {
 
         /** 本方姓名 */
-        @ToolParam(name = "personName", description = "本方(机主)姓名,精确匹配,必传,如「张三」", required = false)
+        @ToolParam(name = "personName", description = "本方(机主)姓名,精确匹配,必传,如「张三」", required = true)
         public String personName;
 
         /** 每页行数 */
@@ -280,8 +280,8 @@ public final class PersonToolSpecs {
 
         /** 银行卡号列表 */
         @ToolParam(name = "cardNoList",
-                   description = "要统计的银行卡号,可传多个,必传(至少一个)——底层 SQL 用 IN 展开,为空会报错",
-                   required = false)
+                   description = "要统计的银行卡号,可传多个,必填(至少一个)——底层 SQL 用 IN 展开,为空会报错",
+                   required = true)
         public List<String> cardNoList;
 
         /** 每页行数 */

+ 1 - 1
ai-server/src/main/java/com/zsjz/ai/module/agent/tools/PythonAnalysisTool.java

@@ -44,7 +44,7 @@ public class PythonAnalysisTool {
                   + "适合 execute_sql 无法完成的复杂分析:统计建模、多步数据变换、专业可视化。",
           readOnly = true)
     public ToolResultBlock executePython(
-            @ToolParam(name = "code", description = "Python 3 代码,可用库:pandas/numpy/duckdb/matplotlib;"
+            @ToolParam(name = "code", description = "Python 3 代码,必填;可用库:pandas/numpy/duckdb/matplotlib;"
                     + "超时 60 秒,注意控制数据量") String code) {
         try {
             PythonExecutor.PythonResult result = pythonExecutor.execute(code);

+ 1 - 1
ai-server/src/main/java/com/zsjz/ai/module/agent/tools/RagSchemaSearchTool.java

@@ -39,7 +39,7 @@ public class RagSchemaSearchTool {
                   + "AI 智能清洗新建的动态表(ai_clean_*)同样会被检索到。",
           readOnly = true)
     public ToolResultBlock searchTableSchema(
-            @ToolParam(name = "query", description = "自然语言查询,例如:包含手机号和身份证号的表") String query,
+            @ToolParam(name = "query", description = "自然语言查询,必填,例如:包含手机号和身份证号的表") String query,
             @ToolParam(name = "top_k", description = "返回最相似的前 K 张表,默认 5", required = false) Integer topK) {
         try {
             List<RagSearchResultVO> results = ragSchemaService.search(query, topK);

+ 3 - 2
ai-server/src/main/java/com/zsjz/ai/module/agent/tools/SqlAnalysisTool.java

@@ -84,7 +84,7 @@ public class SqlAnalysisTool {
                   + "不需要为了控制返回量而强行加 LIMIT;仅在扫描代价过高时自行加 WHERE 过滤。",
           readOnly = true)
     public ToolResultBlock executeSql(
-            @ToolParam(name = "sql", description = "DuckDB 方言的只读 SQL 查询语句") String sql) {
+            @ToolParam(name = "sql", description = "DuckDB 方言的只读 SQL 查询语句,必填;只能单条语句") String sql) {
         try {
             String problem = validateReadOnly(sql);
             if (problem != null) {
@@ -159,7 +159,8 @@ public class SqlAnalysisTool {
                   + "用户需要常规图表可视化时必须调用本工具。注意:关系图谱/网络图/力导向图禁止使用本工具,必须先调用 get_case_graph 取数(前端会自动渲染关系图)。",
           readOnly = true)
     public ToolResultBlock renderChart(
-            @ToolParam(name = "option", description = "完整的 ECharts option JSON 对象(不是字符串),例如柱状图含 title/xAxis/yAxis/series") JsonNode option) {
+            @ToolParam(name = "option", description = "完整的 ECharts option JSON 对象(不是字符串),必填;"
+                    + "例如柱状图含 title/xAxis/yAxis/series") JsonNode option) {
         if (option == null || option.isNull()) {
             return error("option 不能为空");
         }

+ 11 - 11
ai-server/src/main/java/com/zsjz/ai/module/agent/tools/TrackToolSpecs.java

@@ -352,25 +352,25 @@ public final class TrackToolSpecs {
         /** 基站区域 ID */
         @ToolParam(name = "lac",
                    description = "基站区域编号 LAC,必填;取汇总结果里的 lac 列",
-                   required = false)
+                   required = true)
         public String lac;
 
         /** 基站小区 ID */
         @ToolParam(name = "cid",
                    description = "基站小区编号 CID,必填;取汇总结果里的 cid 列",
-                   required = false)
+                   required = true)
         public String cid;
 
         /** 人员 A */
         @ToolParam(name = "persona",
                    description = "人员 A 姓名,必填;取汇总结果里的 persona 列(两个姓名有先后顺序,需与汇总结果一致)",
-                   required = false)
+                   required = true)
         public String persona;
 
         /** 人员 B */
         @ToolParam(name = "personb",
                    description = "人员 B 姓名,必填;取汇总结果里的 personb 列",
-                   required = false)
+                   required = true)
         public String personb;
 
         /** 碰面时间窗口(分钟) */
@@ -513,13 +513,13 @@ public final class TrackToolSpecs {
         /** 调查对象姓名 */
         @ToolParam(name = "personName",
                    description = "被调查对象姓名,必填(系统按该姓名定位人员档案)",
-                   required = false)
+                   required = true)
         public String personName;
 
         /** 酒店名称 */
         @ToolParam(name = "hotelName",
                    description = "酒店名称,必填且精确匹配;取同住汇总结果里的 hotelName 列",
-                   required = false)
+                   required = true)
         public String hotelName;
 
         /** 同住人姓名 */
@@ -560,13 +560,13 @@ public final class TrackToolSpecs {
         /** 调查对象姓名 */
         @ToolParam(name = "personName",
                    description = "被调查对象姓名,必填(系统按该姓名定位其同住服务数据)",
-                   required = false)
+                   required = true)
         public String personName;
 
         /** 同住人姓名 */
         @ToolParam(name = "liveWithName",
                    description = "同住人姓名,必填且精确匹配",
-                   required = false)
+                   required = true)
         public String liveWithName;
 
         /** 酒店名称 */
@@ -636,7 +636,7 @@ public final class TrackToolSpecs {
                    description = "同行记录 id 列表,必填;取同行汇总结果里的 idList 列。"
                            + "元素形如 TOGETHER_FLIGHT_123、TOGETHER_TRAIN_456、RAIL_TICKET_789、"
                            + "FLIGHT_TICKET_1、FLIGHT_DEPARTURE_2、CAR_TICKET_3(前缀由汇总工具给出,不要自己编)",
-                   required = false)
+                   required = true)
         public List<String> ids;
 
         /** 本方姓名列表 */
@@ -661,13 +661,13 @@ public final class TrackToolSpecs {
                    description = "数据类型,必填;取值 TOGETHER_FLIGHT(同航班)、TOGETHER_TRAIN(同火车)、"
                            + "RAIL_TICKET(铁路订票)、FLIGHT_TICKET(民航订票)、"
                            + "FLIGHT_DEPARTURE(民航离港)、CAR_TICKET(汽车票务)",
-                   required = false)
+                   required = true)
         public String type;
 
         /** 记录主键 */
         @ToolParam(name = "id",
                    description = "记录主键 id,必填;取同行明细结果里 id 列中「_」之后的纯数字部分",
-                   required = false)
+                   required = true)
         public String id;
 
         /** 每页行数 */

+ 19 - 0
ai-server/src/test/java/com/zsjz/ai/module/agent/mcp/AgentScopeMcpToolProviderTest.java

@@ -3,6 +3,8 @@ package com.zsjz.ai.module.agent.mcp;
 import com.zsjz.ai.common.model.graph.dto.RelEdgeDTO;
 import com.zsjz.ai.common.model.graph.dto.RelNodeDTO;
 import com.zsjz.ai.common.model.plat.entity.CaseInfo;
+import com.zsjz.ai.common.utils.Json;
+import com.fasterxml.jackson.databind.JsonNode;
 import com.zsjz.ai.module.agent.artifact.ArtifactService;
 import com.zsjz.ai.module.agent.config.ArtifactProperties;
 import com.zsjz.ai.module.agent.mapper.AgentChatSessionMapper;
@@ -254,6 +256,23 @@ class AgentScopeMcpToolProviderTest {
         assertFalse(result.startsWith("\"{"), "返回值被二次转义了: " + result.substring(0, 20));
     }
 
+    @Test
+    @DisplayName("嵌套 query schema:业务必填参数必须进入 required 数组")
+    void requiredBusinessParamsReachMcpSchema() throws Exception {
+        ToolCallback callback = byName(provider(mock(McpCaseSession.class)).getToolCallbacks())
+                .get("stat_person_call_top10");
+        assertNotNull(callback);
+
+        JsonNode schema = Json.objectMapper().readTree(callback.getToolDefinition().inputSchema());
+        JsonNode query = schema.path("properties").path("query");
+        String required = query.path("required").toString();
+
+        assertTrue(required.contains("\"personName\""),
+                "personName 应进入 query.required: " + required);
+        assertFalse(required.contains("\"pageSize\""),
+                "可选参数 pageSize 不应进入 required: " + required);
+    }
+
     @Test
     @DisplayName("入参缺 personNames 时框架按 schema 直接拒绝,而不是让工具静默失败")
     void invalidInputGivesReadableError() {

+ 15 - 1
ai-server/src/test/java/com/zsjz/ai/module/agent/tools/CallAnalysisToolTest.java

@@ -35,6 +35,7 @@ import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
@@ -151,11 +152,24 @@ class CallAnalysisToolTest {
         }
         when(callNightService.statSecondCallNight(any())).thenReturn(list);
 
-        ToolResultBlock result = tool.statCallNightDetail(new CallToolSpecs.NightSpec());
+        CallToolSpecs.NightSpec spec = new CallToolSpecs.NightSpec();
+        spec.personNames = List.of("张三");
+        ToolResultBlock result = tool.statCallNightDetail(spec);
         assertEquals(ToolResultState.ERROR, result.getState(), text(result));
         assertTrue(text(result).contains("收窄"), text(result));
     }
 
+    @Test
+    @DisplayName("stat_call_night_detail 缺少 personName/personNames 时给出可读报错且不查库")
+    void nightDetailRequiresPerson() {
+        ToolResultBlock result = tool.statCallNightDetail(new CallToolSpecs.NightSpec());
+
+        assertEquals(ToolResultState.ERROR, result.getState(), text(result));
+        assertTrue(text(result).contains("personName"), text(result));
+        assertTrue(text(result).contains("personNames"), text(result));
+        verify(callNightService, never()).statSecondCallNight(any());
+    }
+
     @Test
     @DisplayName("默认(artifact.max-rows=0)不设行数上限:大结果集照常返回,全量交给 CSV 产物")
     void oversizedResultAcceptedByDefault() {

+ 157 - 0
ai-server/src/test/java/com/zsjz/ai/module/agent/tools/ToolSpecRequiredFieldContractTest.java

@@ -0,0 +1,157 @@
+package com.zsjz.ai.module.agent.tools;
+
+import io.agentscope.core.tool.Tool;
+import io.agentscope.core.tool.ToolParam;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
+import java.lang.reflect.Parameter;
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * 工具参数 schema 的契约测试。
+ *
+ * <p>大模型只按 JSON Schema 的 {@code required} 和参数 description 生成调用参数:
+ * <ul>
+ *   <li>description 里写了「必填 / 必传」的字段,必须同步 {@code required = true},
+ *       否则模型会把它当可选字段漏传;</li>
+ *   <li>反过来,{@code required = true} 的字段必须在 description 里明确写「必填 / 必传」,
+ *       让模型知道为什么必须给;</li>
+ *   <li>条件必填(如「lx 为 2/3 时必传」)和「建议必传」不算硬必填,由工具内校验兜底。</li>
+ * </ul>
+ *
+ * <p>这个测试覆盖所有 {@code *ToolSpecs} 的嵌套参数类,新增字段时会被自动纳入检查。
+ */
+class ToolSpecRequiredFieldContractTest {
+
+    private static final List<Class<?>> SPEC_GROUPS = List.of(
+            CallToolSpecs.class,
+            FileToolSpecs.class,
+            GraphToolSpecs.class,
+            PersonToolSpecs.class,
+            TrackToolSpecs.class,
+            TransToolSpecs.class);
+
+    private static final List<Class<?>> TOOL_CLASSES = List.of(
+            CallAnalysisTool.class,
+            FileAnalysisTool.class,
+            GovernTableTool.class,
+            GraphAnalysisTool.class,
+            PersonAnalysisTool.class,
+            PythonAnalysisTool.class,
+            RagSchemaSearchTool.class,
+            SqlAnalysisTool.class,
+            TrackAnalysisTool.class,
+            TransAnalysisTool.class,
+            WorkspaceInfoTool.class);
+
+    @Test
+    @DisplayName("参数 description 写「必填/必传」时,required 必须为 true(条件必填/建议必传除外)")
+    void requiredFlagMatchesDescription() {
+        List<String> problems = new ArrayList<>();
+        forEachToolParam((owner, field, param) -> {
+            String desc = param.description();
+            boolean hardRequired = desc.contains("必填") || desc.contains("必传");
+            if (!hardRequired || param.required()) {
+                return;
+            }
+            boolean conditional = desc.contains("时必") || desc.contains("建议必")
+                    || desc.contains("至少") || desc.contains("二选一");
+            if (!conditional) {
+                problems.add(owner + "." + field.getName() + " 描述写了必填但 required=false");
+            }
+        });
+        assertTrue(problems.isEmpty(), String.join("\n", problems));
+    }
+
+    @Test
+    @DisplayName("required=true 的参数必须在 description 里明确写「必填/必传」")
+    void requiredParameterDescriptionIsExplicit() {
+        List<String> problems = new ArrayList<>();
+        forEachToolParam((owner, field, param) -> {
+            if (!param.required()) {
+                return;
+            }
+            String desc = param.description();
+            if (!desc.contains("必填") && !desc.contains("必传")) {
+                problems.add(owner + "." + field.getName() + " required=true 但描述未写明必填");
+            }
+        });
+        assertTrue(problems.isEmpty(), String.join("\n", problems));
+    }
+
+    @Test
+    @DisplayName("每个 ToolParam 都必须有非空 description")
+    void everyParamHasDescription() {
+        List<String> problems = new ArrayList<>();
+        forEachToolParam((owner, field, param) -> {
+            if (param.description() == null || param.description().isBlank()) {
+                problems.add(owner + "." + field.getName() + " 缺少 description");
+            }
+        });
+        assertTrue(problems.isEmpty(), String.join("\n", problems));
+    }
+
+    @Test
+    @DisplayName("@Tool 方法参数:必须有 description,描述写必填时 required 不能是 false")
+    void toolMethodParamsHaveDescriptionAndRequiredConsistency() {
+        List<String> problems = new ArrayList<>();
+        for (Class<?> toolClass : TOOL_CLASSES) {
+            for (Method method : toolClass.getDeclaredMethods()) {
+                Tool tool = method.getAnnotation(Tool.class);
+                if (tool == null) {
+                    continue;
+                }
+                if (tool.name().isBlank() || tool.description().isBlank()) {
+                    problems.add(toolClass.getSimpleName() + "." + method.getName() + " 的 @Tool name/description 为空");
+                }
+                for (Parameter parameter : method.getParameters()) {
+                    ToolParam param = parameter.getAnnotation(ToolParam.class);
+                    if (param == null) {
+                        continue;
+                    }
+                    String desc = param.description();
+                    String where = toolClass.getSimpleName() + "." + method.getName() + "." + param.name();
+                    if (desc == null || desc.isBlank()) {
+                        problems.add(where + " 缺少 description");
+                        continue;
+                    }
+                    boolean hardRequired = desc.contains("必填") || desc.contains("必传");
+                    boolean conditional = desc.contains("时必") || desc.contains("建议必")
+                            || desc.contains("至少") || desc.contains("二选一");
+                    if (hardRequired && !conditional && !param.required()) {
+                        problems.add(where + " 描述写了必填但 required=false");
+                    }
+                }
+            }
+        }
+        assertTrue(problems.isEmpty(), String.join("\n", problems));
+    }
+
+    private interface ParamVisitor {
+        void visit(String owner, Field field, ToolParam param);
+    }
+
+    private static void forEachToolParam(ParamVisitor visitor) {
+        for (Class<?> group : SPEC_GROUPS) {
+            for (Class<?> spec : group.getDeclaredClasses()) {
+                if (!Modifier.isPublic(spec.getModifiers())) {
+                    continue;
+                }
+                for (Field field : spec.getDeclaredFields()) {
+                    ToolParam param = field.getAnnotation(ToolParam.class);
+                    if (param == null || Modifier.isStatic(field.getModifiers())) {
+                        continue;
+                    }
+                    visitor.visit(spec.getSimpleName(), field, param);
+                }
+            }
+        }
+    }
+}