diff --git a/internal/ai/runtime/internal/impl/factory/agent_factory.go b/internal/ai/runtime/internal/impl/factory/agent_factory.go index 1ce0af8..31f7aa0 100644 --- a/internal/ai/runtime/internal/impl/factory/agent_factory.go +++ b/internal/ai/runtime/internal/impl/factory/agent_factory.go @@ -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 { diff --git a/internal/ai/runtime/internal/impl/factory/instruction_assembler.go b/internal/ai/runtime/internal/impl/factory/instruction_assembler.go new file mode 100644 index 0000000..859e88a --- /dev/null +++ b/internal/ai/runtime/internal/impl/factory/instruction_assembler.go @@ -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 +} diff --git a/internal/ai/runtime/internal/impl/factory/tool_factory.go b/internal/ai/runtime/internal/impl/factory/tool_factory.go index 82c5fed..d97a138 100644 --- a/internal/ai/runtime/internal/impl/factory/tool_factory.go +++ b/internal/ai/runtime/internal/impl/factory/tool_factory.go @@ -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), diff --git a/internal/ai/runtime/registry/registry.go b/internal/ai/runtime/registry/registry.go index 1b7b21a..fbd3273 100644 --- a/internal/ai/runtime/registry/registry.go +++ b/internal/ai/runtime/registry/registry.go @@ -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 +} diff --git a/internal/ai/runtime/registry/types.go b/internal/ai/runtime/registry/types.go index 96b3bb2..1ef6686 100644 --- a/internal/ai/runtime/registry/types.go +++ b/internal/ai/runtime/registry/types.go @@ -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 { diff --git a/internal/ai/runtime/reply_service.go b/internal/ai/runtime/reply_service.go index 79d8d5d..316e94b 100644 --- a/internal/ai/runtime/reply_service.go +++ b/internal/ai/runtime/reply_service.go @@ -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, diff --git a/internal/ai/runtime/service.go b/internal/ai/runtime/service.go index 3e52068..2822744 100644 --- a/internal/ai/runtime/service.go +++ b/internal/ai/runtime/service.go @@ -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 + } +} diff --git a/internal/ai/runtime/tools/create_ticket_confirm_tool.go b/internal/ai/runtime/tools/create_ticket_confirm_tool.go index 43fe695..fbdae33 100644 --- a/internal/ai/runtime/tools/create_ticket_confirm_tool.go +++ b/internal/ai/runtime/tools/create_ticket_confirm_tool.go @@ -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 { diff --git a/internal/builders/agent_run_log_builder.go b/internal/builders/agent_run_log_builder.go index fd33299..94f40aa 100644 --- a/internal/builders/agent_run_log_builder.go +++ b/internal/builders/agent_run_log_builder.go @@ -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, diff --git a/internal/controllers/console/ai_agent_controller.go b/internal/controllers/console/ai_agent_controller.go index 1be2b9d..cf5c939 100644 --- a/internal/controllers/console/ai_agent_controller.go +++ b/internal/controllers/console/ai_agent_controller.go @@ -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, }) } diff --git a/internal/controllers/console/mcp_controller.go b/internal/controllers/console/mcp_controller.go index fee85df..23e1067 100644 --- a/internal/controllers/console/mcp_controller.go +++ b/internal/controllers/console/mcp_controller.go @@ -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, diff --git a/internal/controllers/console/skill_definition_controller.go b/internal/controllers/console/skill_definition_controller.go index 9ab5e56..d2dfede 100644 --- a/internal/controllers/console/skill_definition_controller.go +++ b/internal/controllers/console/skill_definition_controller.go @@ -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) -} diff --git a/internal/models/models.go b/internal/models/models.go index c906ebc..8491b9d 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -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"` diff --git a/internal/pkg/dto/response/mcp_response.go b/internal/pkg/dto/response/mcp_response.go index 174a0c0..5749205 100644 --- a/internal/pkg/dto/response/mcp_response.go +++ b/internal/pkg/dto/response/mcp_response.go @@ -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"` diff --git a/internal/pkg/dto/response/skill_response.go b/internal/pkg/dto/response/skill_response.go index e28ea8e..1745ede 100644 --- a/internal/pkg/dto/response/skill_response.go +++ b/internal/pkg/dto/response/skill_response.go @@ -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"` diff --git a/internal/pkg/toolx/builtin_tools.go b/internal/pkg/toolx/builtin_tools.go new file mode 100644 index 0000000..5184c45 --- /dev/null +++ b/internal/pkg/toolx/builtin_tools.go @@ -0,0 +1,9 @@ +package toolx + +const ( + BuiltinToolCatalogServerCode = "builtin" + BuiltinCreateTicketConfirmToolCode = "builtin/create_ticket_with_confirmation" + BuiltinCreateTicketConfirmToolName = "create_ticket_with_confirmation" + BuiltinCreateTicketConfirmToolTitle = "创建工单并发起确认" + BuiltinCreateTicketConfirmToolDescription = "当用户明确要求创建工单,且标题和描述已经整理清楚后调用。工具会先向用户确认,确认后才真正创建工单。" +) diff --git a/internal/pkg/toolx/mcp_tool.go b/internal/pkg/toolx/mcp_tool.go index 1742a1c..d955e30 100644 --- a/internal/pkg/toolx/mcp_tool.go +++ b/internal/pkg/toolx/mcp_tool.go @@ -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, diff --git a/internal/services/ai_agent_service.go b/internal/services/ai_agent_service.go index f5bddc0..1bf93cd 100644 --- a/internal/services/ai_agent_service.go +++ b/internal/services/ai_agent_service.go @@ -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 { diff --git a/internal/services/skill_definition_service.go b/internal/services/skill_definition_service.go index 4d85793..3d801b6 100644 --- a/internal/services/skill_definition_service.go +++ b/internal/services/skill_definition_service.go @@ -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 +} diff --git a/internal/services/tool_catalog_service.go b/internal/services/tool_catalog_service.go index e010ca4..d33c00d 100644 --- a/internal/services/tool_catalog_service.go +++ b/internal/services/tool_catalog_service.go @@ -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 服务不存在或未启用") diff --git a/web/app/(console)/agent-run-logs/page.tsx b/web/app/(console)/agent-run-logs/page.tsx index bf319e5..4725f15 100644 --- a/web/app/(console)/agent-run-logs/page.tsx +++ b/web/app/(console)/agent-run-logs/page.tsx @@ -229,8 +229,8 @@ export default function DashboardAgentRunLogsPage() { 时间 用户问题 规划动作 - Skill / Tool - 最终动作 + Skill / Tool + 最终状态 耗时 操作 @@ -265,17 +265,29 @@ export default function DashboardAgentRunLogsPage() { {item.plannedSkillCode || item.plannedToolCode ? ( - - {item.plannedSkillCode || item.plannedToolCode} - +
+ + {item.plannedSkillCode || item.plannedToolCode} + + {item.plannedSkillName ? ( +
+ {item.plannedSkillName} +
+ ) : null} +
) : ( "-" )}
- - {item.finalAction || "-"} - +
+ + {item.finalAction || "-"} + +
+ {item.finalStatus || "-"} +
+
{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 || "-"}`, ]} /> ([]); const [knowledgeBases, setKnowledgeBases] = useState([]); const [agentTeams, setAgentTeams] = useState([]); const [skills, setSkills] = useState([]); - const [directTools, setDirectTools] = useState< - CreateAIAgentPayload["directTools"] - >([]); - const [directToolOptions, setDirectToolOptions] = useState< - { value: string; label: string; meta: CreateAIAgentPayload["directTools"][number] }[] - >([]); + const [directTools, setDirectTools] = useState([]); + const [directToolOptions, setDirectToolOptions] = useState( + [], + ); const [toolCatalog, setToolCatalog] = useState([]); 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(); 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({
-
Direct MCP Tools
+
Direct Tools
- 用于低风险、原子化的实时查询。工具来自统一的 MCP Tool Catalog。 + 用于低风险、原子化的实时查询。可选择 MCP 工具,也可选择系统内置工具。
{ - setDirectToolServerCodeToAdd(value); + setDirectToolGroupToAdd(value); setDirectToolToAdd(""); }} /> @@ -876,7 +890,7 @@ function EditDialogBody({ handleAddDirectTool(directToolToAdd)} > @@ -897,16 +911,16 @@ function EditDialogBody({
{directTools.length === 0 ? ( - 不配置 Direct Tool 时,Agent 不会直接访问 MCP,只能通过 Skill 间接调用。 + 不配置 Direct Tool 时,Agent 不会直接调用外部或内置工具,只会依赖知识库、Skill 和普通回复。 ) : ( - directToolsGroupedByServer.map(([serverCode, tools]) => ( + directToolsGrouped.map(([groupLabel, tools]) => (
- {serverCode} + {groupLabel}
{tools.map((tool) => { @@ -921,6 +935,11 @@ function EditDialogBody({ className="gap-1 pr-1" > {tool.title || catalogItem?.title || value} + + {catalogItem?.sourceType === "builtin" + ? "内置" + : tool.serverCode || "MCP"} +