cc 2 minggu lalu
induk
melakukan
d417b90200

+ 3 - 2
ai-electron/frontend/src/ai/views/aiPlugin/index.vue

@@ -126,8 +126,9 @@
         </template>
 
         <div class="ai-plugin__hint">
-          本服务同时是一个 <b>MCP Server</b>:把上面这些工具暴露给外部客户端
-          (Claude Desktop / Cursor 等),让外部模型直接查案件数据。
+          本服务同时是一个 <b>MCP Server</b>,<b>只给外部客户端</b>(Claude Desktop / Cursor 等)使用,
+          <b>不参与 agent 会话</b>:它在独立的一份工具容器里,把同一批业务工具与基础工具、外加 4 个
+          案件管理工具全量暴露出去。外部模型接入后要先绑定案件才能查数据。
           <b>当前端点未做鉴权</b>,只应在本机或内网使用。
         </div>
 

+ 0 - 14
ai-server/src/main/java/com/zsjz/ai/module/agent/mcp/AgentScopeMcpToolProvider.java

@@ -159,20 +159,6 @@ public class AgentScopeMcpToolProvider implements ToolCallbackProvider {
 
     // ==================== 构建 ====================
 
-    /**
-     * 只读访问器:已缓存的工具容器(供「插件与技能」页枚举<b>基础工具</b>用)。
-     *
-     * <p>基础工具(SQL / 检索表结构 / Python / 图谱渲染 / 工作区信息)的注册条件与依赖都在本类,
-     * 与其在能力服务里再抄一遍,不如复用这份实例。首次调用会触发构建,
-     * 与 MCP 客户端首次连接时拿到的是同一份,没有运行时副作用。
-     */
-    public Toolkit toolkit() {
-        if (toolkit == null) {
-            getToolCallbacks();
-        }
-        return toolkit;
-    }
-
     private ToolCallback[] build() {
         Toolkit tk = buildToolkit();
         this.toolkit = tk;

+ 37 - 11
ai-server/src/main/java/com/zsjz/ai/module/agent/service/AgentService.java

@@ -156,6 +156,40 @@ public class AgentService extends ServiceImpl<AgentMapper, AgentEntity> {
         });
     }
 
+    /**
+     * 注册<b>基础设施工具</b>:不属于任何业务域、也不会被 {@code reset_equipped_tools} 收起,
+     * 会话里始终对模型可见。
+     *
+     * <p>抽成方法是为了让「插件与技能」页枚举到的清单与对话链路用的是<b>同一批代码</b>:
+     * 之前那份清单是从 MCP 侧的工具容器里借的,而 MCP 是给外部客户端用的,
+     * 与 agent 自己有什么工具是两件事。
+     */
+    private void registerInfraTools(Toolkit toolkit) {
+        if (ragSchemaService.isAvailable()) {
+            toolkit.registerTool(new RagSchemaSearchTool(ragSchemaService));
+        }
+        toolkit.registerTool(new SqlAnalysisTool(sqlQueryMapper, sqlResultStore, tableInfoMapper, tableFieldMapper));
+        if (pythonExecutor.isAvailable()) {
+            toolkit.registerTool(new PythonAnalysisTool(pythonExecutor));
+        }
+        toolkit.registerTool(new WorkspaceInfoTool(chatSessionMapper, caseInfoMapper));
+        toolkit.registerTool(new GraphRenderTool());
+    }
+
+    /**
+     * 装配一份「业务组全部激活」的 agent 工具容器,只用于<b>能力清单枚举</b>。
+     *
+     * <p>与 {@link #buildAgent} 用的是同一批注册代码,唯一区别是业务组全激活:
+     * 未激活组的工具不会进 {@code getToolSchemas()},页面就看不到它们。
+     * 返回的是独立实例,不影响对话链路上池化的 agent。
+     */
+    public Toolkit buildCapabilityToolkit() {
+        Toolkit toolkit = new Toolkit();
+        registerInfraTools(toolkit);
+        agentToolRegistry.registerBusinessTools(toolkit, AgentToolRegistry.registeredGroupNames());
+        return toolkit;
+    }
+
     /**
      * 构建 HarnessAgent(官方 builder)。
      */
@@ -180,18 +214,10 @@ public class AgentService extends ServiceImpl<AgentMapper, AgentEntity> {
         // 工具注册分两层:
         // 1) 基础设施工具(总是注册,且不进任何工具组 → 始终对模型可见):
         //    RAG 检索(可用时)、SQL 分析三件套、Python 分析(环境就绪时)、工作空间查询、关系图谱渲染;
-        // 2) 业务域工具(77 个,按 call/trans/track/otg/graph/person 分 6 组,由 AgentToolRegistry 注册):
-        //    默认只装备 person 组,其余组由模型调用 reset_equipped_tools 按需切换,避免每轮塞 77 份 schema。
+        // 2) 业务域工具(按 call/trans/track/graph/person/file 分组,由 AgentToolRegistry 注册):
+        //    默认只装备 person 组,其余组由模型调用 reset_equipped_tools 按需切换,避免每轮塞几十份 schema。
         Toolkit toolkit = new Toolkit();
-        if (ragSchemaService.isAvailable()) {
-            toolkit.registerTool(new RagSchemaSearchTool(ragSchemaService));
-        }
-        toolkit.registerTool(new SqlAnalysisTool(sqlQueryMapper, sqlResultStore, tableInfoMapper, tableFieldMapper));
-        if (pythonExecutor.isAvailable()) {
-            toolkit.registerTool(new PythonAnalysisTool(pythonExecutor));
-        }
-        toolkit.registerTool(new WorkspaceInfoTool(chatSessionMapper, caseInfoMapper));
-        toolkit.registerTool(new GraphRenderTool());
+        registerInfraTools(toolkit);
         agentToolRegistry.registerBusinessTools(toolkit);
         b.toolkit(toolkit);
         // 注册 reset_equipped_tools 元工具(内部会调 toolkit.registerMetaTool()),

+ 15 - 12
ai-server/src/main/java/com/zsjz/ai/module/agent/service/impl/AgentCapabilityServiceImpl.java

@@ -31,9 +31,11 @@ import java.nio.file.Path;
 import java.nio.file.Paths;
 import java.util.ArrayList;
 import java.util.Comparator;
+import java.util.HashSet;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 
 /**
  * 服务端 agent 能力清单与技能管理实现。
@@ -105,15 +107,16 @@ public class AgentCapabilityServiceImpl implements AgentCapabilityService {
 
     @Override
     public List<ToolGroupVO> listToolGroups() {
-        // 业务工具:独立 Toolkit 仅用于枚举;必须把全部组都激活,否则未激活组的工具不进 schema
-        Toolkit tk = new Toolkit();
-        agentToolRegistry.registerBusinessTools(tk, AgentToolRegistry.registeredGroupNames());
+        // 枚举用的是 agent 自己的工具装配(全组激活的那份),页面看到的即会话里能调用的;
+        // 不要从 MCP 侧借工具容器 —— MCP 是给外部客户端用的,与 agent 有什么工具是两件事。
+        Toolkit tk = agentService.buildCapabilityToolkit();
         Map<String, ToolVO> schemaByName = new LinkedHashMap<>();
         for (ToolSchema schema : tk.getToolSchemas()) {
             schemaByName.put(schema.getName(), toToolVO(schema));
         }
 
         List<ToolGroupVO> groups = new ArrayList<>();
+        Set<String> businessNames = new HashSet<>();
         for (String code : AgentToolRegistry.registeredGroupNames()) {
             ToolGroup group = tk.getToolGroup(code);
             if (group == null) {
@@ -121,6 +124,7 @@ public class AgentCapabilityServiceImpl implements AgentCapabilityService {
             }
             List<ToolVO> tools = new ArrayList<>();
             for (String toolName : group.getTools()) {
+                businessNames.add(toolName);
                 ToolVO tool = schemaByName.get(toolName);
                 if (tool == null) {
                     // 组里登记了名字但没注册工具(配置不一致):仍然列出,避免页面「少了一个」
@@ -141,15 +145,12 @@ public class AgentCapabilityServiceImpl implements AgentCapabilityService {
             groups.add(vo);
         }
 
-        // 基础工具:MCP 侧那份 Toolkit 已按可用性注册好(RAG/Python 不可用时不会注册),直接复用
-        List<ToolVO> base = new ArrayList<>();
-        for (ToolSchema schema : mcpToolProvider.toolkit().getToolSchemas()) {
-            if (!schemaByName.containsKey(schema.getName())) {
-                base.add(toToolVO(schema));
-            }
-        }
+        // 基础工具:agent 的基础设施工具(不属于任何业务组,会话里始终可用)
+        List<ToolVO> base = schemaByName.values().stream()
+                .filter(tool -> !businessNames.contains(tool.getName()))
+                .sorted(Comparator.comparing(ToolVO::getName, Comparator.nullsLast(String::compareTo)))
+                .toList();
         if (!base.isEmpty()) {
-            base.sort(Comparator.comparing(ToolVO::getName, Comparator.nullsLast(String::compareTo)));
             ToolGroupVO vo = new ToolGroupVO();
             vo.setCode(BASE_GROUP_CODE);
             vo.setLabel("基础工具");
@@ -189,7 +190,9 @@ public class AgentCapabilityServiceImpl implements AgentCapabilityService {
         vo.setPort(serverPort);
         vo.setEndpoint(joinPath(prefix, streamable ? mcpStreamableEndpoint : mcpSseEndpoint));
         vo.setSseEndpoint(joinPath(prefix, mcpSseEndpoint));
-        vo.setToolCount(mcpToolProvider.getToolCallbacks().length);
+        // 工具数是 MCP <b>自己对外暴露</b>的那一份(业务工具 + 基础工具 + 案件管理工具),
+        // 与 agent 会话内的工具清单是两件事:MCP 只服务外部客户端。未启用时不计算。
+        vo.setToolCount(mcpEnabled ? mcpToolProvider.getToolCallbacks().length : null);
         vo.setCaseTools(AgentScopeMcpToolProvider.CASE_TOOL_NAMES);
         vo.setNote("外部客户端接入后:先调 list_cases 取案件 ID,再调 open_case 绑定案件,"
                 + "之后所有业务工具都作用于该案件;换案件重复 open_case 即可。"

+ 137 - 0
ai-server/src/test/java/com/zsjz/ai/module/agent/service/AgentCapabilityToolkitTest.java

@@ -0,0 +1,137 @@
+package com.zsjz.ai.module.agent.service;
+
+import com.zsjz.ai.module.agent.WorkspaceManagerFactory;
+import com.zsjz.ai.module.agent.config.EtlProperties;
+import com.zsjz.ai.module.agent.followup.FollowupService;
+import com.zsjz.ai.module.agent.intent.IntentService;
+import com.zsjz.ai.module.agent.mapper.AgentChatSessionMapper;
+import com.zsjz.ai.module.agent.mapper.AgentModelMapper;
+import com.zsjz.ai.module.agent.mapper.SqlQueryMapper;
+import com.zsjz.ai.module.agent.python.PythonExecutor;
+import com.zsjz.ai.module.agent.rag.RagSchemaService;
+import com.zsjz.ai.module.agent.sql.SqlResultStore;
+import com.zsjz.ai.module.agent.tools.AgentToolRegistry;
+import com.zsjz.ai.module.call.service.CallContinuousService;
+import com.zsjz.ai.module.call.service.CallNightService;
+import com.zsjz.ai.module.call.service.CallRecordService;
+import com.zsjz.ai.module.call.service.CallSensitiveService;
+import com.zsjz.ai.module.dm.ai.FileAiProfileService;
+import com.zsjz.ai.module.graph.service.GraphService;
+import com.zsjz.ai.module.person.service.DataProfileService;
+import com.zsjz.ai.module.person.service.IntimacyService;
+import com.zsjz.ai.module.person.service.PersonBasicInfoService;
+import com.zsjz.ai.module.person.service.PersonGroupService;
+import com.zsjz.ai.module.plat.mapper.CaseInfoMapper;
+import com.zsjz.ai.module.plat.mapper.TableFieldMapper;
+import com.zsjz.ai.module.plat.mapper.TableInfoMapper;
+import com.zsjz.ai.module.track.service.TrackCellTowerService;
+import com.zsjz.ai.module.track.service.TrackEnLocalService;
+import com.zsjz.ai.module.track.service.TrackExpressInfoService;
+import com.zsjz.ai.module.track.service.TrackMeetService;
+import com.zsjz.ai.module.track.service.TrackTogetherLiveService;
+import com.zsjz.ai.module.track.service.TrackTogetherTravelService;
+import com.zsjz.ai.module.trans.service.TransBigService;
+import com.zsjz.ai.module.trans.service.TransCardHoldService;
+import com.zsjz.ai.module.trans.service.TransCashFlowService;
+import com.zsjz.ai.module.trans.service.TransContinuousService;
+import com.zsjz.ai.module.trans.service.TransFastFundFlowService;
+import com.zsjz.ai.module.trans.service.TransFinancialService;
+import com.zsjz.ai.module.trans.service.TransFixedDepositService;
+import com.zsjz.ai.module.trans.service.TransFrequencyService;
+import com.zsjz.ai.module.trans.service.TransFundFlowService;
+import com.zsjz.ai.module.trans.service.TransRecordService;
+import com.zsjz.ai.module.trans.service.TransSensitiveService;
+import io.agentscope.core.tool.Toolkit;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.HashSet;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+
+/**
+ * 「插件与技能」页的工具清单契约:清单必须来自 <b>agent 自己</b>的工具装配。
+ *
+ * <p>守两件事:
+ * <ol>
+ *   <li>枚举用具全部组激活,否则未激活组的工具不进 schema,页面上会「少工具」;</li>
+ *   <li>基础工具({@code execute_sql} 等)与业务工具的分界正确 ——
+ *       这份清单不依赖 MCP(MCP 只服务外部客户端,与 agent 有什么工具是两件事)。</li>
+ * </ol>
+ */
+class AgentCapabilityToolkitTest {
+
+    private static AgentService agentService() {
+        AgentToolRegistry registry = new AgentToolRegistry(
+                mock(SqlResultStore.class),
+                mock(CallRecordService.class), mock(CallNightService.class),
+                mock(CallContinuousService.class), mock(CallSensitiveService.class),
+                mock(TransBigService.class), mock(TransCardHoldService.class),
+                mock(TransCashFlowService.class), mock(TransContinuousService.class),
+                mock(TransFastFundFlowService.class), mock(TransFinancialService.class),
+                mock(TransFixedDepositService.class), mock(TransFrequencyService.class),
+                mock(TransFundFlowService.class), mock(TransRecordService.class),
+                mock(TransSensitiveService.class),
+                mock(TrackCellTowerService.class), mock(TrackEnLocalService.class),
+                mock(TrackExpressInfoService.class), mock(TrackMeetService.class),
+                mock(TrackTogetherLiveService.class), mock(TrackTogetherTravelService.class),
+                mock(GraphService.class),
+                mock(DataProfileService.class), mock(IntimacyService.class),
+                mock(PersonBasicInfoService.class), mock(PersonGroupService.class),
+                mock(FileAiProfileService.class));
+        return new AgentService(
+                mock(AgentModelFactory.class),
+                mock(AgentModelMapper.class),
+                Optional.empty(),
+                mock(RagSchemaService.class),
+                mock(EtlProperties.class),
+                mock(IntentService.class),
+                mock(FollowupService.class),
+                mock(SqlQueryMapper.class),
+                mock(SqlResultStore.class),
+                mock(TableInfoMapper.class),
+                mock(TableFieldMapper.class),
+                mock(PythonExecutor.class),
+                mock(AgentChatSessionMapper.class),
+                mock(CaseInfoMapper.class),
+                new WorkspaceManagerFactory(),
+                registry);
+    }
+
+    @Test
+    @DisplayName("能力清单:业务组全激活、每组可取到,基础工具与业务工具分界正确")
+    void capabilityToolkitExposesEverything() {
+        Toolkit toolkit = agentService().buildCapabilityToolkit();
+
+        Set<String> businessNames = new HashSet<>();
+        for (String group : AgentToolRegistry.registeredGroupNames()) {
+            assertNotNull(toolkit.getToolGroup(group), "组取不到: " + group);
+            businessNames.addAll(toolkit.getToolGroup(group).getTools());
+        }
+        // 全组激活:67 个业务工具都应在 schema 里(默认只装备 person 时会只剩 24 个)
+        Set<String> schemaNames = toolkit.getToolSchemas().stream()
+                .map(schema -> schema.getName())
+                .collect(Collectors.toSet());
+        assertTrue(schemaNames.containsAll(businessNames),
+                "有业务工具没进 schema,页面上会看不到它们");
+        assertEquals(67, businessNames.size(), "业务工具总数");
+
+        // 基础工具:不属于任何业务组、会话里始终可用(mocked 环境下 RAG/Python 视为不可用)
+        Set<String> baseNames = schemaNames.stream()
+                .filter(name -> !businessNames.contains(name))
+                .collect(Collectors.toSet());
+        assertTrue(baseNames.contains("execute_sql"), "SQL 工具应属于基础工具: " + baseNames);
+        assertTrue(baseNames.contains("get_current_workspace"), "工作区查询应属于基础工具");
+        assertFalse(baseNames.contains("list_person_names"), "业务工具不该被算进基础工具");
+        // 元工具由 builder.enableMetaTool(true) 注册,不在这份枚举容器里
+        assertFalse(baseNames.contains("reset_equipped_tools"));
+    }
+}