feat: enhance AI agent functionality with tool management and logging improvements

- Added AllowedToolCodes field to Context for managing tool access.
- Introduced ResumeSource in aiReplyTraceData to track resume points.
- Enhanced logging with additional fields: PlannedSkillName, SkillRouteTrace, InterruptType, ResumeSource, and FinalStatus.
- Implemented functions to parse and resolve allowed tool codes for agents and skills.
- Refactored skill definition creation and update logic to streamline request handling.
- Updated MCP tool catalog to include source type and integrated built-in tools.
- Improved UI components to display additional tool information and enhance user experience.
This commit is contained in:
mlogclub
2026-04-10 14:43:57 +08:00
parent 7411bac57d
commit 1370e4b675
23 changed files with 642 additions and 222 deletions
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"os"
"strings"
einoadapter "cs-agent/internal/ai/runtime/internal/impl/adapter"
@@ -17,14 +18,16 @@ import (
)
type AgentFactory struct {
chatModelFactory *ChatModelFactory
toolFactory *ToolFactory
chatModelFactory *ChatModelFactory
toolFactory *ToolFactory
instructionAssembler *InstructionAssembler
}
func NewAgentFactory() *AgentFactory {
return &AgentFactory{
chatModelFactory: NewChatModelFactory(),
toolFactory: NewToolFactory(),
chatModelFactory: NewChatModelFactory(),
toolFactory: NewToolFactory(),
instructionAssembler: NewInstructionAssembler(),
}
}
@@ -94,13 +97,35 @@ func buildAgentInstruction(aiAgent *models.AIAgent, selectedSkill *models.SkillD
5. 如果用户只是咨询、抱怨或泛泛表达不满,但没有明确要求建单,优先继续澄清,不要主动创建工单。
`))
}
if len(appendixParts) == 0 {
return baseInstruction
projectRoot, _ := os.Getwd()
return NewInstructionAssembler().Build(InstructionAssemblerInput{
ProjectRoot: projectRoot,
AgentInstruction: baseInstruction,
SkillInstruction: firstAppendixPart(appendixParts),
ToolAppendices: remainingAppendixParts(appendixParts),
})
}
func firstAppendixPart(parts []string) string {
if len(parts) == 0 {
return ""
}
if baseInstruction == "" {
return strings.Join(appendixParts, "\n\n")
return strings.TrimSpace(parts[0])
}
func remainingAppendixParts(parts []string) []string {
if len(parts) <= 1 {
return nil
}
return baseInstruction + "\n\n" + strings.Join(appendixParts, "\n\n")
ret := make([]string, 0, len(parts)-1)
for _, item := range parts[1:] {
item = strings.TrimSpace(item)
if item == "" {
continue
}
ret = append(ret, item)
}
return ret
}
func buildSelectedSkillInstruction(skill *models.SkillDefinition, toolDefinitions []einoadapter.MCPToolDefinition) string {
@@ -0,0 +1,114 @@
package factory
import (
"os"
"path/filepath"
"strings"
"sync"
)
type InstructionAssembler struct {
governanceInstruction string
}
type InstructionAssemblerInput struct {
AgentInstruction string
SkillInstruction string
ToolAppendices []string
ProjectRoot string
ProjectInstruction string
}
var (
projectInstructionOnce sync.Once
projectInstructionText string
)
func NewInstructionAssembler() *InstructionAssembler {
return &InstructionAssembler{
governanceInstruction: strings.TrimSpace(`
你正在一个有明确工程约束的客服系统中工作。
执行时必须严格遵守当前注入的项目规则、Agent 规则和技能规则。
如果存在工具白名单限制,只能调用当前允许的工具;信息不足时优先追问,不要伪造事实或跳过必要确认。
`),
}
}
func (a *InstructionAssembler) Build(input InstructionAssemblerInput) string {
parts := make([]string, 0, 5)
projectInstruction := strings.TrimSpace(input.ProjectInstruction)
if projectInstruction == "" {
projectInstruction = loadProjectInstruction(input.ProjectRoot)
}
if projectInstruction != "" {
parts = append(parts, buildInstructionSection("项目级规则", projectInstruction))
}
if a != nil && strings.TrimSpace(a.governanceInstruction) != "" {
parts = append(parts, buildInstructionSection("系统治理规则", a.governanceInstruction))
}
if agentInstruction := strings.TrimSpace(input.AgentInstruction); agentInstruction != "" {
parts = append(parts, buildInstructionSection("Agent 规则", agentInstruction))
}
if skillInstruction := strings.TrimSpace(input.SkillInstruction); skillInstruction != "" {
parts = append(parts, buildInstructionSection("当前技能上下文", skillInstruction))
}
if appendix := buildToolAppendix(input.ToolAppendices); appendix != "" {
parts = append(parts, buildInstructionSection("工具补充规则", appendix))
}
return strings.TrimSpace(strings.Join(parts, "\n\n"))
}
func buildInstructionSection(title, body string) string {
title = strings.TrimSpace(title)
body = strings.TrimSpace(body)
if body == "" {
return ""
}
if title == "" {
return body
}
return title + "\n" + body
}
func buildToolAppendix(input []string) string {
if len(input) == 0 {
return ""
}
parts := make([]string, 0, len(input))
for _, item := range input {
item = strings.TrimSpace(item)
if item == "" {
continue
}
parts = append(parts, item)
}
return strings.TrimSpace(strings.Join(parts, "\n\n"))
}
func loadProjectInstruction(projectRoot string) string {
// TODO 这里还要读取工程目录中的AGENTS.md吗?
projectInstructionOnce.Do(func() {
candidates := []string{
"AGENTS.md",
}
projectRoot = strings.TrimSpace(projectRoot)
if projectRoot != "" {
candidates = append([]string{filepath.Join(projectRoot, "AGENTS.md")}, candidates...)
}
for _, candidate := range candidates {
candidate = strings.TrimSpace(candidate)
if candidate == "" {
continue
}
data, err := os.ReadFile(candidate)
if err != nil {
continue
}
projectInstructionText = strings.TrimSpace(string(data))
if projectInstructionText != "" {
return
}
}
})
return projectInstructionText
}
@@ -32,10 +32,14 @@ func (f *ToolFactory) BuildMCPTools(aiAgent *models.AIAgent) ([]impladapter.MCPT
if toolCode == "" {
toolCode = toolx.BuildMCPToolCode(item.ServerCode, item.ToolName)
}
serverCode, toolName := toolx.SplitMCPToolCode(toolCode)
if serverCode == "" || toolName == "" {
continue
}
definition := impladapter.MCPToolDefinition{
ToolCode: toolCode,
ServerCode: strings.TrimSpace(item.ServerCode),
ToolName: strings.TrimSpace(item.ToolName),
ServerCode: serverCode,
ToolName: toolName,
Title: strings.TrimSpace(item.Title),
Description: strings.TrimSpace(item.Description),
FixedArgs: cloneStringMap(item.Arguments),
+22 -1
View File
@@ -21,10 +21,17 @@ func (r *Registry) Resolve(ctx Context) (*ToolSet, error) {
Tools: make([]einotool.BaseTool, 0, len(r.tools)),
ToolCodes: make(map[string]string),
}
allowedToolCodes := makeAllowedToolCodeSet(ctx.AllowedToolCodes)
for _, toolDef := range r.tools {
if toolDef == nil || !toolDef.Enabled(ctx) {
continue
}
toolCode := strings.TrimSpace(toolDef.Code())
if len(allowedToolCodes) > 0 {
if _, ok := allowedToolCodes[toolCode]; !ok {
continue
}
}
tool, err := toolDef.Build(ctx)
if err != nil {
return nil, err
@@ -33,7 +40,6 @@ func (r *Registry) Resolve(ctx Context) (*ToolSet, error) {
continue
}
toolName := strings.TrimSpace(toolDef.Name())
toolCode := strings.TrimSpace(toolDef.Code())
if toolName == "" || toolCode == "" {
continue
}
@@ -42,3 +48,18 @@ func (r *Registry) Resolve(ctx Context) (*ToolSet, error) {
}
return ret, nil
}
func makeAllowedToolCodeSet(input []string) map[string]struct{} {
if len(input) == 0 {
return nil
}
ret := make(map[string]struct{}, len(input))
for _, item := range input {
item = strings.TrimSpace(item)
if item == "" {
continue
}
ret[item] = struct{}{}
}
return ret
}
+5 -4
View File
@@ -7,10 +7,11 @@ import (
)
type Context struct {
Conversation *models.Conversation
AIAgent *models.AIAgent
AIConfig *models.AIConfig
UserMessage *models.Message
Conversation *models.Conversation
AIAgent *models.AIAgent
AIConfig *models.AIConfig
UserMessage *models.Message
AllowedToolCodes []string
}
type ToolSet struct {
+35
View File
@@ -36,6 +36,7 @@ type aiReplyTraceData struct {
RecheckMs int64 `json:"recheckMs,omitempty"`
CommitMs int64 `json:"commitMs,omitempty"`
FinalAction string `json:"finalAction,omitempty"`
ResumeSource string `json:"resumeSource,omitempty"`
ReplySent bool `json:"replySent,omitempty"`
ReplyMessageID int64 `json:"replyMessageId,omitempty"`
Runtime json.RawMessage `json:"runtime,omitempty"`
@@ -162,6 +163,7 @@ func (s *aiReplyService) resumePendingInterrupt(ctx context.Context, conversatio
return fmt.Errorf("ai config is nil")
}
runtimeStartedAt := time.Now()
trace.ResumeSource = "pending_interrupt"
summary, err := Service.Resume(ctx, ResumeRequest{
Conversation: &conversation,
AIAgent: &aiAgent,
@@ -327,9 +329,14 @@ func (s *aiReplyService) writeRunLog(startedAt time.Time, message models.Message
UserMessage: strings.TrimSpace(question),
PlannedAction: plannedAction,
PlannedSkillCode: strings.TrimSpace(summaryPlannedSkillCode(summary)),
PlannedSkillName: strings.TrimSpace(summaryPlannedSkillName(summary)),
SkillRouteTrace: strings.TrimSpace(summarySkillRouteTrace(summary)),
PlannedToolCode: plannedToolCode,
PlanReason: planReason,
InterruptType: firstInterruptType(summary),
ResumeSource: runLogResumeSource(trace),
FinalAction: toRunLogFinalAction(summary),
FinalStatus: runLogFinalStatus(summary),
ReplyText: buildRunLogReplyText(summary),
ErrorMessage: errorMessage,
LatencyMs: time.Since(startedAt).Milliseconds(),
@@ -422,6 +429,34 @@ func summaryPlannedSkillCode(summary *Summary) string {
return strings.TrimSpace(summary.PlannedSkillCode)
}
func summaryPlannedSkillName(summary *Summary) string {
if summary == nil {
return ""
}
return strings.TrimSpace(summary.PlannedSkillName)
}
func summarySkillRouteTrace(summary *Summary) string {
if summary == nil {
return ""
}
return strings.TrimSpace(summary.SkillRouteTrace)
}
func runLogResumeSource(trace *aiReplyTraceData) string {
if trace == nil {
return ""
}
return strings.TrimSpace(trace.ResumeSource)
}
func runLogFinalStatus(summary *Summary) string {
if summary == nil {
return ""
}
return strings.TrimSpace(summary.Status)
}
func (s *aiReplyService) incrementAIReplyRounds(conversationID int64, nextRounds int, aiAgentName string) error {
return repositories.ConversationRepository.Updates(sqls.DB(), conversationID, map[string]any{
"ai_reply_rounds": nextRounds,
+79 -4
View File
@@ -10,6 +10,7 @@ import (
"cs-agent/internal/ai/runtime/tools"
"cs-agent/internal/ai/skills"
"cs-agent/internal/models"
"cs-agent/internal/pkg/toolx"
)
var Service = newService()
@@ -89,10 +90,11 @@ func (s *service) prepareToolsForRun(req *Request) error {
return nil
}
toolSet, err := s.registry.Resolve(registry.Context{
Conversation: req.Conversation,
AIAgent: req.AIAgent,
AIConfig: req.AIConfig,
UserMessage: req.UserMessage,
Conversation: req.Conversation,
AIAgent: req.AIAgent,
AIConfig: req.AIConfig,
UserMessage: req.UserMessage,
AllowedToolCodes: resolveAllowedToolCodes(req.AIAgent, req.SelectedSkill),
})
if err != nil {
return err
@@ -200,3 +202,76 @@ func cloneSkillDefinition(item *models.SkillDefinition) *models.SkillDefinition
clone := *item
return &clone
}
func parseSkillAllowedToolCodes(skill *models.SkillDefinition) []string {
if skill == nil {
return nil
}
raw := strings.TrimSpace(skill.AllowedToolCodes)
if raw == "" {
return nil
}
var items []string
if err := json.Unmarshal([]byte(raw), &items); err != nil {
return nil
}
ret := make([]string, 0, len(items))
for _, item := range items {
item = strings.TrimSpace(item)
if item == "" {
continue
}
ret = append(ret, item)
}
return ret
}
func parseAgentAllowedToolCodes(aiAgent *models.AIAgent) []string {
if aiAgent == nil || strings.TrimSpace(aiAgent.AllowedMCPTools) == "" {
return nil
}
items, err := toolx.ParseAgentMCPToolsJSON(aiAgent.AllowedMCPTools)
if err != nil {
return nil
}
ret := make([]string, 0, len(items))
for _, item := range items {
toolCode := strings.TrimSpace(item.ToolCode)
if toolCode == "" {
continue
}
ret = append(ret, toolCode)
}
return ret
}
func resolveAllowedToolCodes(aiAgent *models.AIAgent, skill *models.SkillDefinition) []string {
agentAllowed := parseAgentAllowedToolCodes(aiAgent)
skillAllowed := parseSkillAllowedToolCodes(skill)
switch {
case len(agentAllowed) == 0:
return skillAllowed
case len(skillAllowed) == 0:
return agentAllowed
default:
skillSet := make(map[string]struct{}, len(skillAllowed))
for _, item := range skillAllowed {
item = strings.TrimSpace(item)
if item == "" {
continue
}
skillSet[item] = struct{}{}
}
ret := make([]string, 0, len(agentAllowed))
for _, item := range agentAllowed {
item = strings.TrimSpace(item)
if item == "" {
continue
}
if _, ok := skillSet[item]; ok {
ret = append(ret, item)
}
}
return ret
}
}
@@ -10,6 +10,7 @@ import (
"cs-agent/internal/models"
"cs-agent/internal/pkg/dto"
"cs-agent/internal/pkg/dto/request"
"cs-agent/internal/pkg/toolx"
"cs-agent/internal/services"
componenttool "github.com/cloudwego/eino/components/tool"
@@ -20,8 +21,8 @@ import (
)
const (
CreateTicketConfirmToolCode = "builtin/create_ticket_with_confirmation"
CreateTicketConfirmToolName = "create_ticket_with_confirmation"
CreateTicketConfirmToolCode = toolx.BuiltinCreateTicketConfirmToolCode
CreateTicketConfirmToolName = toolx.BuiltinCreateTicketConfirmToolName
)
type CreateTicketConfirmState struct {
@@ -18,9 +18,14 @@ func BuildAgentRunLog(item *models.AgentRunLog) response.AgentRunLogResponse {
UserMessage: item.UserMessage,
PlannedAction: item.PlannedAction,
PlannedSkillCode: item.PlannedSkillCode,
PlannedSkillName: item.PlannedSkillName,
SkillRouteTrace: item.SkillRouteTrace,
PlannedToolCode: item.PlannedToolCode,
PlanReason: item.PlanReason,
InterruptType: item.InterruptType,
ResumeSource: item.ResumeSource,
FinalAction: item.FinalAction,
FinalStatus: item.FinalStatus,
ReplyText: item.ReplyText,
ErrorMessage: item.ErrorMessage,
LatencyMs: item.LatencyMs,
@@ -174,12 +174,29 @@ func buildAIAgentResponse(item *models.AIAgent) response.AIAgentResponse {
if toolCode == "" {
toolCode = toolx.BuildMCPToolCode(tool.ServerCode, tool.ToolName)
}
serverCode := strings.TrimSpace(tool.ServerCode)
toolName := strings.TrimSpace(tool.ToolName)
if toolCode == toolx.BuiltinCreateTicketConfirmToolCode {
serverCode = toolx.BuiltinToolCatalogServerCode
toolName = toolx.BuiltinCreateTicketConfirmToolName
} else if parsedServerCode, parsedToolName := toolx.SplitMCPToolCode(toolCode); parsedServerCode != "" && parsedToolName != "" {
serverCode = parsedServerCode
toolName = parsedToolName
}
title := strings.TrimSpace(tool.Title)
if title == "" && toolCode == toolx.BuiltinCreateTicketConfirmToolCode {
title = toolx.BuiltinCreateTicketConfirmToolTitle
}
description := strings.TrimSpace(tool.Description)
if description == "" && toolCode == toolx.BuiltinCreateTicketConfirmToolCode {
description = toolx.BuiltinCreateTicketConfirmToolDescription
}
ret.DirectTools = append(ret.DirectTools, response.AIAgentMCPToolResponse{
ToolCode: toolCode,
ServerCode: strings.TrimSpace(tool.ServerCode),
ToolName: strings.TrimSpace(tool.ToolName),
Title: strings.TrimSpace(tool.Title),
Description: strings.TrimSpace(tool.Description),
ServerCode: serverCode,
ToolName: toolName,
Title: title,
Description: description,
Arguments: tool.Arguments,
})
}
@@ -38,6 +38,7 @@ func (c *MCPController) AnyCatalog() *web.JsonResult {
ToolCode: item.ToolCode,
ServerCode: item.ServerCode,
ToolName: item.ToolName,
SourceType: item.SourceType,
Title: item.Title,
Description: item.Description,
InputSchema: item.InputSchema,
@@ -2,18 +2,13 @@ package console
import (
"context"
"encoding/json"
"strings"
"time"
"cs-agent/internal/builders"
"cs-agent/internal/models"
"cs-agent/internal/pkg/constants"
"cs-agent/internal/pkg/dto/request"
"cs-agent/internal/pkg/dto/response"
"cs-agent/internal/pkg/enums"
"cs-agent/internal/pkg/errorsx"
"cs-agent/internal/pkg/utils"
"cs-agent/internal/services"
"github.com/kataras/iris/v12"
@@ -80,29 +75,8 @@ func (c *SkillDefinitionController) PostCreate() *web.JsonResult {
if err := params.ReadJSON(c.Ctx, &req); err != nil {
return web.JsonError(err)
}
if err := validateSkillDefinitionRequest(req); err != nil {
return web.JsonError(err)
}
if services.SkillDefinitionService.Take("code = ?", strings.TrimSpace(req.Code)) != nil {
return web.JsonErrorMsg("Skill 编码已存在")
}
item := &models.SkillDefinition{
Code: strings.TrimSpace(req.Code),
Name: strings.TrimSpace(req.Name),
Description: strings.TrimSpace(req.Description),
Content: strings.TrimSpace(req.Content),
Examples: mustMarshalJSONStringArray(req.Examples),
AllowedToolCodes: mustMarshalJSONStringArray(req.AllowedToolCodes),
Priority: normalizeSkillPriority(req.Priority),
Status: enums.StatusOk,
Remark: strings.TrimSpace(req.Remark),
AuditFields: utils.BuildAuditFields(operator),
}
if item.Priority <= 0 {
item.Priority = services.SkillDefinitionService.NextPriority()
}
if err := services.SkillDefinitionService.Create(item); err != nil {
item, err := services.SkillDefinitionService.CreateSkillDefinition(req, operator)
if err != nil {
return web.JsonError(err)
}
return web.JsonData(builders.BuildSkillDefinitionResponse(item))
@@ -118,35 +92,7 @@ func (c *SkillDefinitionController) PostUpdate() *web.JsonResult {
if err := params.ReadJSON(c.Ctx, &req); err != nil {
return web.JsonError(err)
}
if req.ID <= 0 {
return web.JsonErrorMsg("Skill ID 不合法")
}
if err := validateSkillDefinitionRequest(req.CreateSkillDefinitionRequest); err != nil {
return web.JsonError(err)
}
item := services.SkillDefinitionService.Get(req.ID)
if item == nil {
return web.JsonErrorMsg("Skill 不存在")
}
exists := services.SkillDefinitionService.Take("code = ? AND id <> ?", strings.TrimSpace(req.Code), req.ID)
if exists != nil {
return web.JsonErrorMsg("Skill 编码已存在")
}
if err := services.SkillDefinitionService.Updates(req.ID, map[string]any{
"code": strings.TrimSpace(req.Code),
"name": strings.TrimSpace(req.Name),
"description": strings.TrimSpace(req.Description),
"content": strings.TrimSpace(req.Content),
"examples": mustMarshalJSONStringArray(req.Examples),
"allowed_tool_codes": mustMarshalJSONStringArray(req.AllowedToolCodes),
"priority": resolveSkillPriorityForUpdate(req.Priority, item.Priority),
"remark": strings.TrimSpace(req.Remark),
"update_user_id": operator.UserID,
"update_user_name": operator.Username,
"updated_at": time.Now(),
}); err != nil {
if err := services.SkillDefinitionService.UpdateSkillDefinition(req, operator); err != nil {
return web.JsonError(err)
}
return web.JsonSuccess()
@@ -240,75 +186,3 @@ func (c *SkillDefinitionController) PostDebug_run() *web.JsonResult {
}
return web.JsonData(resp)
}
func validateSkillDefinitionRequest(req request.CreateSkillDefinitionRequest) error {
code := strings.TrimSpace(req.Code)
name := strings.TrimSpace(req.Name)
content := strings.TrimSpace(req.Content)
if code == "" {
return errorsx.InvalidParam("Skill 编码不能为空")
}
if name == "" {
return errorsx.InvalidParam("Skill 名称不能为空")
}
if content == "" {
return errorsx.InvalidParam("Content 不能为空")
}
if _, err := normalizeJSONStringArray(req.Examples); err != nil {
return err
}
allowedToolCodes, err := normalizeJSONStringArray(req.AllowedToolCodes)
if err != nil {
return err
}
for _, toolCode := range allowedToolCodes {
if err := services.ToolCatalogService.ValidateMCPToolCode(toolCode); err != nil {
return err
}
}
return nil
}
func normalizeSkillPriority(priority int) int {
if priority < 0 {
return 0
}
return priority
}
func resolveSkillPriorityForUpdate(input, current int) int {
input = normalizeSkillPriority(input)
if input <= 0 {
return current
}
return input
}
func normalizeJSONStringArray(input []string) ([]string, error) {
ret := make([]string, 0, len(input))
seen := make(map[string]struct{})
for _, item := range input {
item = strings.TrimSpace(item)
if item == "" {
continue
}
if _, exists := seen[item]; exists {
continue
}
seen[item] = struct{}{}
ret = append(ret, item)
}
return ret, nil
}
func mustMarshalJSONStringArray(input []string) string {
items, _ := normalizeJSONStringArray(input)
if len(items) == 0 {
return "[]"
}
buf, err := json.Marshal(items)
if err != nil {
return "[]"
}
return string(buf)
}
+5
View File
@@ -950,9 +950,14 @@ type AgentRunLog struct {
UserMessage string `gorm:"type:longtext"`
PlannedAction string `gorm:"type:varchar(30);not null;default:'';index"`
PlannedSkillCode string `gorm:"type:varchar(100);not null;default:'';index"`
PlannedSkillName string `gorm:"type:varchar(100);not null;default:''"`
SkillRouteTrace string `gorm:"type:text"`
PlannedToolCode string `gorm:"type:varchar(200);not null;default:'';index"`
PlanReason string `gorm:"type:varchar(500);not null;default:''"`
InterruptType string `gorm:"type:varchar(50);not null;default:'';index"`
ResumeSource string `gorm:"type:varchar(50);not null;default:'';index"`
FinalAction string `gorm:"type:varchar(30);not null;default:'';index"`
FinalStatus string `gorm:"type:varchar(30);not null;default:'';index"`
ReplyText string `gorm:"type:longtext"`
ErrorMessage string `gorm:"type:text"`
LatencyMs int64 `gorm:"type:bigint;not null;default:0"`
@@ -69,6 +69,7 @@ type MCPToolCatalogResponse struct {
ToolCode string `json:"toolCode"`
ServerCode string `json:"serverCode"`
ToolName string `json:"toolName"`
SourceType string `json:"sourceType"`
Title string `json:"title"`
Description string `json:"description"`
InputSchema any `json:"inputSchema"`
@@ -46,9 +46,14 @@ type AgentRunLogResponse struct {
UserMessage string `json:"userMessage"`
PlannedAction string `json:"plannedAction"`
PlannedSkillCode string `json:"plannedSkillCode"`
PlannedSkillName string `json:"plannedSkillName"`
SkillRouteTrace string `json:"skillRouteTrace"`
PlannedToolCode string `json:"plannedToolCode"`
PlanReason string `json:"planReason"`
InterruptType string `json:"interruptType"`
ResumeSource string `json:"resumeSource"`
FinalAction string `json:"finalAction"`
FinalStatus string `json:"finalStatus"`
ReplyText string `json:"replyText"`
ErrorMessage string `json:"errorMessage"`
LatencyMs int64 `json:"latencyMs"`
+9
View File
@@ -0,0 +1,9 @@
package toolx
const (
BuiltinToolCatalogServerCode = "builtin"
BuiltinCreateTicketConfirmToolCode = "builtin/create_ticket_with_confirmation"
BuiltinCreateTicketConfirmToolName = "create_ticket_with_confirmation"
BuiltinCreateTicketConfirmToolTitle = "创建工单并发起确认"
BuiltinCreateTicketConfirmToolDescription = "当用户明确要求创建工单,且标题和描述已经整理清楚后调用。工具会先向用户确认,确认后才真正创建工单。"
)
+20 -11
View File
@@ -35,23 +35,32 @@ func NormalizeMCPToolRequest(item request.AIAgentMCPToolRequest) (request.AIAgen
toolName := strings.TrimSpace(item.ToolName)
if toolCode != "" {
parsedServerCode, parsedToolName := SplitMCPToolCode(toolCode)
if parsedServerCode == "" || parsedToolName == "" {
return request.AIAgentMCPToolRequest{}, errorsx.InvalidParam("Direct Tool 的 toolCode 格式不合法")
if parsedServerCode != "" && parsedToolName != "" {
if serverCode != "" && !strings.EqualFold(serverCode, parsedServerCode) {
return request.AIAgentMCPToolRequest{}, errorsx.InvalidParam("Direct Tool 的 toolCode 与 serverCode 不一致")
}
if toolName != "" && !strings.EqualFold(toolName, parsedToolName) {
return request.AIAgentMCPToolRequest{}, errorsx.InvalidParam("Direct Tool 的 toolCode 与 toolName 不一致")
}
serverCode = parsedServerCode
toolName = parsedToolName
} else {
serverCode = ""
toolName = ""
}
if serverCode != "" && !strings.EqualFold(serverCode, parsedServerCode) {
return request.AIAgentMCPToolRequest{}, errorsx.InvalidParam("Direct Tool 的 toolCode 与 serverCode 不一致")
}
if toolName != "" && !strings.EqualFold(toolName, parsedToolName) {
return request.AIAgentMCPToolRequest{}, errorsx.InvalidParam("Direct Tool 的 toolCode 与 toolName 不一致")
}
serverCode = parsedServerCode
toolName = parsedToolName
} else {
toolCode = BuildMCPToolCode(serverCode, toolName)
}
if toolCode == "" || serverCode == "" || toolName == "" {
if toolCode == "" {
return request.AIAgentMCPToolRequest{}, errorsx.InvalidParam("Direct Tool 的 toolCode、serverCode 和 toolName 不能为空")
}
if parsedServerCode, parsedToolName := SplitMCPToolCode(toolCode); parsedServerCode != "" && parsedToolName != "" {
serverCode = parsedServerCode
toolName = parsedToolName
}
if serverCode == "" && toolName == "" && strings.Contains(toolCode, "/") && !strings.HasPrefix(toolCode, "builtin/") {
return request.AIAgentMCPToolRequest{}, errorsx.InvalidParam("Direct Tool 的 toolCode 格式不合法")
}
ret := request.AIAgentMCPToolRequest{
ToolCode: toolCode,
ServerCode: serverCode,
+2 -9
View File
@@ -7,7 +7,6 @@ import (
"time"
"cs-agent/internal/models"
"cs-agent/internal/pkg/config"
"cs-agent/internal/pkg/dto"
"cs-agent/internal/pkg/dto/request"
"cs-agent/internal/pkg/enums"
@@ -288,10 +287,6 @@ func (s *aIAgentService) normalizeDirectTools(input []request.AIAgentMCPToolRequ
if len(input) == 0 {
return nil, nil
}
cfg := config.Current()
if !cfg.MCP.Enabled {
return nil, errorsx.InvalidParam("系统未启用 MCP,不能配置 Direct Tool")
}
ret := make([]request.AIAgentMCPToolRequest, 0, len(input))
seen := make(map[string]struct{})
for _, item := range input {
@@ -299,10 +294,8 @@ func (s *aIAgentService) normalizeDirectTools(input []request.AIAgentMCPToolRequ
if err != nil {
return nil, err
}
serverCode := strings.TrimSpace(normalized.ServerCode)
server, ok := cfg.MCP.Servers[serverCode]
if !ok || !server.Enabled {
return nil, errorsx.InvalidParam("Direct Tool 绑定的 MCP 服务不存在或未启用")
if err := ToolCatalogService.ValidateToolCode(normalized.ToolCode); err != nil {
return nil, err
}
key := strings.TrimSpace(normalized.ToolCode)
if _, exists := seen[key]; exists {
@@ -1,7 +1,16 @@
package services
import (
"encoding/json"
"strings"
"time"
"cs-agent/internal/models"
"cs-agent/internal/pkg/dto"
"cs-agent/internal/pkg/dto/request"
"cs-agent/internal/pkg/enums"
"cs-agent/internal/pkg/errorsx"
"cs-agent/internal/pkg/utils"
"cs-agent/internal/repositories"
"github.com/mlogclub/simple/sqls"
@@ -86,3 +95,156 @@ func (s *skillDefinitionService) UpdatePriority(ids []int64) error {
func (s *skillDefinitionService) GetByCode(code string) *models.SkillDefinition {
return repositories.SkillDefinitionRepository.GetByCode(sqls.DB(), code)
}
func (s *skillDefinitionService) CreateSkillDefinition(req request.CreateSkillDefinitionRequest, operator *dto.AuthPrincipal) (*models.SkillDefinition, error) {
if operator == nil {
return nil, errorsx.Unauthorized("未登录或登录已过期")
}
normalized, err := s.normalizeSkillDefinitionRequest(req)
if err != nil {
return nil, err
}
if s.Take("code = ?", normalized.Code) != nil {
return nil, errorsx.InvalidParam("Skill 编码已存在")
}
item := &models.SkillDefinition{
Code: normalized.Code,
Name: normalized.Name,
Description: normalized.Description,
Content: normalized.Content,
Examples: mustMarshalSkillStringArray(normalized.Examples),
AllowedToolCodes: mustMarshalSkillStringArray(normalized.AllowedToolCodes),
Priority: normalized.Priority,
Status: enums.StatusOk,
Remark: normalized.Remark,
AuditFields: utils.BuildAuditFields(operator),
}
if item.Priority <= 0 {
item.Priority = s.NextPriority()
}
if err := repositories.SkillDefinitionRepository.Create(sqls.DB(), item); err != nil {
return nil, err
}
return item, nil
}
func (s *skillDefinitionService) UpdateSkillDefinition(req request.UpdateSkillDefinitionRequest, operator *dto.AuthPrincipal) error {
if operator == nil {
return errorsx.Unauthorized("未登录或登录已过期")
}
if req.ID <= 0 {
return errorsx.InvalidParam("Skill ID 不合法")
}
current := s.Get(req.ID)
if current == nil {
return errorsx.InvalidParam("Skill 不存在")
}
normalized, err := s.normalizeSkillDefinitionRequest(req.CreateSkillDefinitionRequest)
if err != nil {
return err
}
if exists := s.Take("code = ? AND id <> ?", normalized.Code, req.ID); exists != nil {
return errorsx.InvalidParam("Skill 编码已存在")
}
return repositories.SkillDefinitionRepository.Updates(sqls.DB(), req.ID, map[string]any{
"code": normalized.Code,
"name": normalized.Name,
"description": normalized.Description,
"content": normalized.Content,
"examples": mustMarshalSkillStringArray(normalized.Examples),
"allowed_tool_codes": mustMarshalSkillStringArray(normalized.AllowedToolCodes),
"priority": resolveSkillPriorityForService(normalized.Priority, current.Priority),
"remark": normalized.Remark,
"update_user_id": operator.UserID,
"update_user_name": operator.Username,
"updated_at": time.Now(),
})
}
func (s *skillDefinitionService) normalizeSkillDefinitionRequest(req request.CreateSkillDefinitionRequest) (*request.CreateSkillDefinitionRequest, error) {
normalized := &request.CreateSkillDefinitionRequest{
Code: strings.TrimSpace(req.Code),
Name: strings.TrimSpace(req.Name),
Description: strings.TrimSpace(req.Description),
Content: strings.TrimSpace(req.Content),
Priority: normalizeSkillPriorityForService(req.Priority),
Remark: strings.TrimSpace(req.Remark),
}
if normalized.Code == "" {
return nil, errorsx.InvalidParam("Skill 编码不能为空")
}
if normalized.Name == "" {
return nil, errorsx.InvalidParam("Skill 名称不能为空")
}
if normalized.Content == "" {
return nil, errorsx.InvalidParam("Content 不能为空")
}
examples, err := normalizeSkillStringArray(req.Examples)
if err != nil {
return nil, err
}
allowedToolCodes, err := normalizeSkillStringArray(req.AllowedToolCodes)
if err != nil {
return nil, err
}
for _, toolCode := range allowedToolCodes {
if err := ToolCatalogService.ValidateMCPToolCode(toolCode); err != nil {
return nil, err
}
}
normalized.Examples = examples
normalized.AllowedToolCodes = allowedToolCodes
return normalized, nil
}
func normalizeSkillStringArray(input []string) ([]string, error) {
buf, err := json.Marshal(input)
if err != nil {
return nil, errorsx.InvalidParam("JSON 数组格式不合法")
}
var ret []string
if err := json.Unmarshal(buf, &ret); err != nil {
return nil, errorsx.InvalidParam("JSON 数组格式不合法")
}
normalized := make([]string, 0, len(ret))
seen := make(map[string]struct{}, len(ret))
for _, item := range ret {
item = strings.TrimSpace(item)
if item == "" {
continue
}
if _, ok := seen[item]; ok {
continue
}
seen[item] = struct{}{}
normalized = append(normalized, item)
}
return normalized, nil
}
func mustMarshalSkillStringArray(input []string) string {
items, err := normalizeSkillStringArray(input)
if err != nil || len(items) == 0 {
return "[]"
}
buf, err := json.Marshal(items)
if err != nil {
return "[]"
}
return string(buf)
}
func normalizeSkillPriorityForService(priority int) int {
if priority < 0 {
return 0
}
return priority
}
func resolveSkillPriorityForService(input, current int) int {
input = normalizeSkillPriorityForService(input)
if input <= 0 {
return current
}
return input
}
+23 -2
View File
@@ -23,6 +23,7 @@ type MCPToolCatalogItem struct {
ToolCode string
ServerCode string
ToolName string
SourceType string
Title string
Description string
InputSchema any
@@ -46,6 +47,14 @@ func (s *toolCatalogService) ListMCPTools(ctx context.Context) ([]MCPToolCatalog
}
slices.Sort(serverCodes)
ret := make([]MCPToolCatalogItem, 0)
ret = append(ret, MCPToolCatalogItem{
ToolCode: toolx.BuiltinCreateTicketConfirmToolCode,
ServerCode: toolx.BuiltinToolCatalogServerCode,
ToolName: toolx.BuiltinCreateTicketConfirmToolName,
SourceType: toolx.BuiltinToolCatalogServerCode,
Title: toolx.BuiltinCreateTicketConfirmToolTitle,
Description: toolx.BuiltinCreateTicketConfirmToolDescription,
})
for _, serverCode := range serverCodes {
tools, err := mcps.Runtime.ListTools(ctx, serverCode)
if err != nil {
@@ -56,6 +65,7 @@ func (s *toolCatalogService) ListMCPTools(ctx context.Context) ([]MCPToolCatalog
ToolCode: toolx.BuildMCPToolCode(serverCode, item.Name),
ServerCode: serverCode,
ToolName: strings.TrimSpace(item.Name),
SourceType: "mcp",
Title: strings.TrimSpace(item.Title),
Description: strings.TrimSpace(item.Description),
InputSchema: item.InputSchema,
@@ -67,14 +77,25 @@ func (s *toolCatalogService) ListMCPTools(ctx context.Context) ([]MCPToolCatalog
}
func (s *toolCatalogService) ValidateMCPToolCode(toolCode string) error {
return s.ValidateToolCode(toolCode)
}
func (s *toolCatalogService) ValidateToolCode(toolCode string) error {
cfg := config.Current()
if !cfg.MCP.Enabled {
return errorsx.InvalidParam("MCP未启用")
toolCode = strings.TrimSpace(toolCode)
if toolCode == "" {
return errorsx.InvalidParam("toolCode不能为空")
}
if toolCode == toolx.BuiltinCreateTicketConfirmToolCode {
return nil
}
serverCode, toolName := toolx.SplitMCPToolCode(toolCode)
if serverCode == "" || toolName == "" {
return errorsx.InvalidParam("toolCode格式不合法")
}
if !cfg.MCP.Enabled {
return errorsx.InvalidParam("MCP未启用")
}
server, ok := cfg.MCP.Servers[serverCode]
if !ok || !server.Enabled {
return errorsx.InvalidParam("toolCode 绑定的 MCP 服务不存在或未启用")
+25 -8
View File
@@ -229,8 +229,8 @@ export default function DashboardAgentRunLogsPage() {
<TableHead className="w-[180px]"></TableHead>
<TableHead></TableHead>
<TableHead className="w-[120px]"></TableHead>
<TableHead className="w-[180px]">Skill / Tool</TableHead>
<TableHead className="w-[120px]"></TableHead>
<TableHead className="w-[220px]">Skill / Tool</TableHead>
<TableHead className="w-[140px]"></TableHead>
<TableHead className="w-[110px] text-right"></TableHead>
<TableHead className="w-[96px] text-right"></TableHead>
</TableRow>
@@ -265,17 +265,29 @@ export default function DashboardAgentRunLogsPage() {
</TableCell>
<TableCell className="text-sm">
{item.plannedSkillCode || item.plannedToolCode ? (
<Badge variant="outline">
{item.plannedSkillCode || item.plannedToolCode}
</Badge>
<div className="space-y-1">
<Badge variant="outline">
{item.plannedSkillCode || item.plannedToolCode}
</Badge>
{item.plannedSkillName ? (
<div className="line-clamp-1 text-xs text-muted-foreground">
{item.plannedSkillName}
</div>
) : null}
</div>
) : (
"-"
)}
</TableCell>
<TableCell>
<Badge variant={actionBadgeVariant(item.finalAction)}>
{item.finalAction || "-"}
</Badge>
<div className="space-y-1">
<Badge variant={actionBadgeVariant(item.finalAction)}>
{item.finalAction || "-"}
</Badge>
<div className="text-xs text-muted-foreground">
{item.finalStatus || "-"}
</div>
</div>
</TableCell>
<TableCell className="text-right text-sm text-muted-foreground">
{item.latencyMs} ms
@@ -342,14 +354,19 @@ export default function DashboardAgentRunLogsPage() {
lines={[
`plannedAction: ${activeLog.plannedAction || "-"}`,
`plannedSkillCode: ${activeLog.plannedSkillCode || "-"}`,
`plannedSkillName: ${activeLog.plannedSkillName || "-"}`,
`plannedToolCode: ${activeLog.plannedToolCode || "-"}`,
`planReason: ${activeLog.planReason || "-"}`,
`skillRouteTrace: ${activeLog.skillRouteTrace || "-"}`,
]}
/>
<InfoBlock
title="执行结果"
lines={[
`finalAction: ${activeLog.finalAction || "-"}`,
`finalStatus: ${activeLog.finalStatus || "-"}`,
`interruptType: ${activeLog.interruptType || "-"}`,
`resumeSource: ${activeLog.resumeSource || "-"}`,
`latencyMs: ${activeLog.latencyMs} ms`,
`createdAt: ${formatDateTime(activeLog.createdAt)}`,
]}
@@ -59,6 +59,16 @@ import {
import { getEnumOptions } from "@/lib/enums";
import { FieldDescription } from "@base-ui/react";
type DirectToolItem = CreateAIAgentPayload["directTools"][number];
type DirectToolOption = {
value: string;
label: string;
meta: DirectToolItem;
sourceType: string;
groupLabel: string;
};
type EditDialogProps = {
open: boolean;
saving: boolean;
@@ -231,18 +241,16 @@ function EditDialogBody({
const [knowledgeToAdd, setKnowledgeToAdd] = useState("");
const [teamToAdd, setTeamToAdd] = useState("");
const [skillToAdd, setSkillToAdd] = useState("");
const [directToolServerCodeToAdd, setDirectToolServerCodeToAdd] = useState("");
const [directToolGroupToAdd, setDirectToolGroupToAdd] = useState("");
const [directToolToAdd, setDirectToolToAdd] = useState("");
const [aiConfigs, setAIConfigs] = useState<AIConfig[]>([]);
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBase[]>([]);
const [agentTeams, setAgentTeams] = useState<AdminAgentTeam[]>([]);
const [skills, setSkills] = useState<SkillDefinition[]>([]);
const [directTools, setDirectTools] = useState<
CreateAIAgentPayload["directTools"]
>([]);
const [directToolOptions, setDirectToolOptions] = useState<
{ value: string; label: string; meta: CreateAIAgentPayload["directTools"][number] }[]
>([]);
const [directTools, setDirectTools] = useState<DirectToolItem[]>([]);
const [directToolOptions, setDirectToolOptions] = useState<DirectToolOption[]>(
[],
);
const [toolCatalog, setToolCatalog] = useState<MCPToolCatalogItem[]>([]);
useEffect(() => {
@@ -256,7 +264,7 @@ function EditDialogBody({
setKnowledgeToAdd("");
setTeamToAdd("");
setSkillToAdd("");
setDirectToolServerCodeToAdd("");
setDirectToolGroupToAdd("");
setDirectToolToAdd("");
return;
}
@@ -271,7 +279,7 @@ function EditDialogBody({
setKnowledgeToAdd("");
setTeamToAdd("");
setSkillToAdd("");
setDirectToolServerCodeToAdd("");
setDirectToolGroupToAdd("");
setDirectToolToAdd("");
} catch (error) {
toast.error(
@@ -349,6 +357,11 @@ function EditDialogBody({
catalog.map((tool) => ({
value: tool.toolCode,
label: `${tool.title || tool.toolName} · ${tool.toolCode}`,
sourceType: tool.sourceType,
groupLabel:
tool.sourceType === "builtin"
? "内置工具"
: tool.serverCode,
meta: {
toolCode: tool.toolCode,
serverCode: tool.serverCode,
@@ -446,21 +459,21 @@ function EditDialogBody({
() =>
directToolOptions.filter(
(option) =>
option.meta.serverCode === directToolServerCodeToAdd &&
option.groupLabel === directToolGroupToAdd &&
!directTools.some((tool) => tool.toolCode === option.value),
),
[directToolOptions, directToolServerCodeToAdd, directTools],
[directToolOptions, directToolGroupToAdd, directTools],
);
const directToolServerOptions = useMemo(
const directToolGroupOptions = useMemo(
() =>
Array.from(
new Map(
directToolOptions.map((option) => [
option.meta.serverCode,
option.groupLabel,
{
value: option.meta.serverCode,
label: option.meta.serverCode,
value: option.groupLabel,
label: option.groupLabel,
},
]),
).values(),
@@ -468,15 +481,16 @@ function EditDialogBody({
[directToolOptions],
);
const directToolsGroupedByServer = useMemo(() => {
const groups = new Map<
string,
CreateAIAgentPayload["directTools"]
>();
const directToolsGrouped = useMemo(() => {
const groups = new Map<string, DirectToolItem[]>();
for (const tool of directTools) {
const current = groups.get(tool.serverCode) ?? [];
const groupLabel =
tool.serverCode === "builtin" || tool.toolCode.startsWith("builtin/")
? "内置工具"
: tool.serverCode || "未分组";
const current = groups.get(groupLabel) ?? [];
current.push(tool);
groups.set(tool.serverCode, current);
groups.set(groupLabel, current);
}
return Array.from(groups.entries());
}, [directTools]);
@@ -585,7 +599,7 @@ function EditDialogBody({
}
return [...prev, option.meta];
});
setDirectToolServerCodeToAdd(option.meta.serverCode);
setDirectToolGroupToAdd(option.groupLabel);
setDirectToolToAdd("");
}
@@ -852,22 +866,22 @@ function EditDialogBody({
</div>
<div className="rounded-xl border bg-muted/10 p-4">
<div className="mb-1 text-sm font-medium">Direct MCP Tools</div>
<div className="mb-1 text-sm font-medium">Direct Tools</div>
<div className="mb-4 text-xs text-muted-foreground">
MCP Tool Catalog
MCP
</div>
<Field>
<FieldContent className="space-y-3">
<div className="flex items-center gap-2">
<div className="w-52">
<OptionCombobox
value={directToolServerCodeToAdd}
options={directToolServerOptions}
placeholder="选择 MCP Server"
searchPlaceholder="搜索 MCP Server"
emptyText="没有可用的 MCP Server"
value={directToolGroupToAdd}
options={directToolGroupOptions}
placeholder="选择工具分组"
searchPlaceholder="搜索工具分组"
emptyText="没有可用的工具分组"
onChange={(value) => {
setDirectToolServerCodeToAdd(value);
setDirectToolGroupToAdd(value);
setDirectToolToAdd("");
}}
/>
@@ -876,7 +890,7 @@ function EditDialogBody({
<OptionCombobox
value={directToolToAdd}
options={addableDirectToolOptions}
placeholder="选择该 Server 下的 Direct Tool"
placeholder="选择 Direct Tool"
searchPlaceholder="搜索 Direct Tool"
emptyText="没有可添加的 Direct Tool"
onChange={handleAddDirectTool}
@@ -886,7 +900,7 @@ function EditDialogBody({
type="button"
variant="outline"
disabled={
!directToolServerCodeToAdd || !directToolToAdd
!directToolGroupToAdd || !directToolToAdd
}
onClick={() => handleAddDirectTool(directToolToAdd)}
>
@@ -897,16 +911,16 @@ function EditDialogBody({
<div className="space-y-3">
{directTools.length === 0 ? (
<span className="text-sm text-muted-foreground">
Direct Tool Agent 访 MCP Skill
Direct Tool Agent Skill
</span>
) : (
directToolsGroupedByServer.map(([serverCode, tools]) => (
directToolsGrouped.map(([groupLabel, tools]) => (
<div
key={serverCode}
key={groupLabel}
className="rounded-md border p-3"
>
<div className="mb-2 text-xs font-medium text-muted-foreground">
{serverCode}
{groupLabel}
</div>
<div className="flex flex-wrap gap-2">
{tools.map((tool) => {
@@ -921,6 +935,11 @@ function EditDialogBody({
className="gap-1 pr-1"
>
{tool.title || catalogItem?.title || value}
<span className="text-[10px] text-muted-foreground/80">
{catalogItem?.sourceType === "builtin"
? "内置"
: tool.serverCode || "MCP"}
</span>
<Button
type="button"
variant="ghost"
+6
View File
@@ -333,6 +333,7 @@ export type MCPToolCatalogItem = {
toolCode: string
serverCode: string
toolName: string
sourceType: string
title: string
description: string
inputSchema: unknown
@@ -362,9 +363,14 @@ export type AgentRunLog = {
userMessage: string
plannedAction: string
plannedSkillCode: string
plannedSkillName: string
skillRouteTrace: string
plannedToolCode: string
planReason: string
interruptType: string
resumeSource: string
finalAction: string
finalStatus: string
replyText: string
errorMessage: string
latencyMs: number