Refactor skill execution and definition handling to support content-based execution and enhance skill definition structure

This commit is contained in:
mlogclub
2026-04-09 11:42:40 +08:00
parent eeb579d944
commit eb9cd78646
7 changed files with 136 additions and 287 deletions
+6 -192
View File
@@ -1,57 +1,36 @@
package skills package skills
import ( import (
"bytes"
"context" "context"
"encoding/json"
"fmt"
"strings" "strings"
"text/template"
"time" "time"
"cs-agent/internal/ai" "cs-agent/internal/ai"
"cs-agent/internal/ai/mcps"
"cs-agent/internal/pkg/enums"
"cs-agent/internal/pkg/errorsx" "cs-agent/internal/pkg/errorsx"
) )
type mcpToolExecutionConfig struct {
ServerCode string `json:"serverCode"`
ToolName string `json:"toolName"`
Arguments map[string]string `json:"arguments"`
}
func executeByPlan(ctx context.Context, plan *ExecutionPlan, runtimeCtx RuntimeContext) (string, *ExecutionTrace, error) { func executeByPlan(ctx context.Context, plan *ExecutionPlan, runtimeCtx RuntimeContext) (string, *ExecutionTrace, error) {
if plan == nil || plan.Skill == nil { if plan == nil || plan.Skill == nil {
return "", nil, nil return "", nil, nil
} }
trace := &ExecutionTrace{ trace := &ExecutionTrace{
Status: "started", Status: "started",
ExecutionMode: string(plan.Skill.ExecutionMode), ExecutionMode: "content",
}
switch plan.Skill.ExecutionMode {
case "", enums.SkillExecutionModePromptOnly:
replyText, err := executePromptOnly(ctx, plan, runtimeCtx, trace)
return replyText, trace, err
case enums.SkillExecutionModeMCPTool:
replyText, err := executeMCPTool(ctx, plan, runtimeCtx, trace)
return replyText, trace, err
default:
trace.Status = "invalid_execution_mode"
return "", trace, errorsx.InvalidParam("Skill执行模式不支持")
} }
replyText, err := executeContent(ctx, plan, runtimeCtx, trace)
return replyText, trace, err
} }
func executePromptOnly(ctx context.Context, plan *ExecutionPlan, runtimeCtx RuntimeContext, trace *ExecutionTrace) (string, error) { func executeContent(ctx context.Context, plan *ExecutionPlan, runtimeCtx RuntimeContext, trace *ExecutionTrace) (string, error) {
if plan == nil || plan.Skill == nil { if plan == nil || plan.Skill == nil {
return "", nil return "", nil
} }
if plan.AIConfig == nil { if plan.AIConfig == nil {
return "", errorsx.InvalidParam("Skill 关联的 AI 配置不可用") return "", errorsx.InvalidParam("Skill 关联的 AI 配置不可用")
} }
systemPrompt := strings.TrimSpace(plan.Skill.Prompt) systemPrompt := strings.TrimSpace(plan.Skill.Content)
if systemPrompt == "" { if systemPrompt == "" {
return "", errorsx.InvalidParam("Skill Prompt 不能为空") return "", errorsx.InvalidParam("Skill Content 不能为空")
} }
userPrompt := strings.TrimSpace(runtimeCtx.UserMessage) userPrompt := strings.TrimSpace(runtimeCtx.UserMessage)
if userPrompt == "" { if userPrompt == "" {
@@ -81,168 +60,3 @@ func executePromptOnly(ctx context.Context, plan *ExecutionPlan, runtimeCtx Runt
} }
return strings.TrimSpace(result.Content), nil return strings.TrimSpace(result.Content), nil
} }
func executeMCPTool(ctx context.Context, plan *ExecutionPlan, runtimeCtx RuntimeContext, trace *ExecutionTrace) (string, error) {
cfg, err := parseMCPToolExecutionConfig(plan.Skill.ExecutionConfig)
if err != nil {
if trace != nil {
trace.Status = "config_error"
}
return "", err
}
arguments, err := buildToolArguments(cfg.Arguments, runtimeCtx)
if err != nil {
if trace != nil {
trace.Status = "argument_error"
}
return "", err
}
mcpTrace := &MCPExecutionTrace{
Status: "started",
ServerCode: cfg.ServerCode,
ToolName: cfg.ToolName,
Arguments: arguments,
}
if trace != nil {
trace.MCP = mcpTrace
}
toolStartedAt := time.Now()
toolResult, err := mcps.Runtime.CallTool(ctx, cfg.ServerCode, cfg.ToolName, arguments)
mcpTrace.LatencyMs = time.Since(toolStartedAt).Milliseconds()
if err != nil {
mcpTrace.Status = "error"
mcpTrace.Error = err.Error()
if trace != nil {
trace.Status = "error"
}
return "", err
}
mcpTrace.Status = "ok"
mcpTrace.IsError = toolResult.IsError
mcpTrace.ContentItemCount = len(toolResult.Content)
mcpTrace.HasStructuredContent = toolResult.StructuredContent != nil
toolSummary := buildToolSummary(toolResult)
mcpTrace.ResultPreview = truncateTraceText(toolSummary, 500)
if strings.TrimSpace(toolSummary) == "" {
if trace != nil {
trace.Status = "empty_tool_result"
}
return "", errorsx.InvalidParam("MCP工具未返回有效结果")
}
systemPrompt := strings.TrimSpace(plan.Skill.Prompt)
if systemPrompt == "" {
systemPrompt = "你是客服技能助手。请依据工具结果准确回答用户问题,不要编造工具结果中不存在的事实。"
}
userPrompt := fmt.Sprintf("用户问题:%s\n\n工具结果:\n%s", strings.TrimSpace(runtimeCtx.UserMessage), toolSummary)
summaryTrace := &PromptTrace{Status: "started"}
mcpTrace.SummaryPrompt = summaryTrace
summaryStartedAt := time.Now()
result, err := ai.LLM.ChatWithConfig(ctx, plan.AIConfig, systemPrompt, userPrompt)
summaryTrace.LatencyMs = time.Since(summaryStartedAt).Milliseconds()
if err != nil {
summaryTrace.Status = "error"
summaryTrace.Error = err.Error()
if trace != nil {
trace.Status = "error"
}
return "", err
}
summaryTrace.Status = "ok"
summaryTrace.ModelName = result.ModelName
summaryTrace.PromptTokens = result.PromptTokens
summaryTrace.CompletionTokens = result.CompletionTokens
if trace != nil {
trace.Status = "ok"
}
return strings.TrimSpace(result.Content), nil
}
func parseMCPToolExecutionConfig(raw string) (*mcpToolExecutionConfig, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, errorsx.InvalidParam("ExecutionConfig不能为空")
}
cfg := &mcpToolExecutionConfig{}
if err := json.Unmarshal([]byte(raw), cfg); err != nil {
return nil, errorsx.InvalidParam("ExecutionConfig格式不合法")
}
if strings.TrimSpace(cfg.ServerCode) == "" {
return nil, errorsx.InvalidParam("ExecutionConfig.serverCode不能为空")
}
if strings.TrimSpace(cfg.ToolName) == "" {
return nil, errorsx.InvalidParam("ExecutionConfig.toolName不能为空")
}
return cfg, nil
}
func buildToolArguments(templateArgs map[string]string, runtimeCtx RuntimeContext) (map[string]any, error) {
if len(templateArgs) == 0 {
return map[string]any{
"query": strings.TrimSpace(runtimeCtx.UserMessage),
}, nil
}
data := map[string]any{
"userMessage": strings.TrimSpace(runtimeCtx.UserMessage),
"conversationId": runtimeCtx.ConversationID,
"aiAgentId": runtimeCtx.AIAgentID,
"manualSkillCode": strings.TrimSpace(runtimeCtx.ManualSkillCode),
"intentCode": strings.TrimSpace(runtimeCtx.IntentCode),
}
ret := make(map[string]any, len(templateArgs))
for key, value := range templateArgs {
rendered, err := renderTemplate(value, data)
if err != nil {
return nil, errorsx.InvalidParam("ExecutionConfig.arguments模板不合法")
}
ret[key] = rendered
}
return ret, nil
}
func renderTemplate(raw string, data map[string]any) (string, error) {
tpl, err := template.New("skill_arg").Option("missingkey=zero").Parse(raw)
if err != nil {
return "", err
}
var buf bytes.Buffer
if err := tpl.Execute(&buf, data); err != nil {
return "", err
}
return strings.TrimSpace(buf.String()), nil
}
func buildToolSummary(result *mcps.ToolCallResult) string {
if result == nil {
return ""
}
lines := make([]string, 0, len(result.Content)+2)
if result.StructuredContent != nil {
if data, err := json.Marshal(result.StructuredContent); err == nil {
lines = append(lines, string(data))
}
}
for _, item := range result.Content {
if strings.TrimSpace(item.Text) != "" {
lines = append(lines, strings.TrimSpace(item.Text))
continue
}
if item.Data != nil {
if data, err := json.Marshal(item.Data); err == nil {
lines = append(lines, string(data))
}
}
}
return strings.TrimSpace(strings.Join(lines, "\n"))
}
func truncateTraceText(raw string, limit int) string {
raw = strings.TrimSpace(raw)
if raw == "" || limit <= 0 {
return raw
}
runes := []rune(raw)
if len(runes) <= limit {
return raw
}
return strings.TrimSpace(string(runes[:limit])) + "..."
}
+1 -1
View File
@@ -77,7 +77,7 @@ func Execute(ctx context.Context, runtimeCtx RuntimeContext) (*ExecutionResult,
} }
log := BuildRunLog(runtimeCtx, plan, trace, err) log := BuildRunLog(runtimeCtx, plan, trace, err)
if strings.TrimSpace(replyText) != "" && strings.TrimSpace(log.MatchReason) == "" { if strings.TrimSpace(replyText) != "" && strings.TrimSpace(log.MatchReason) == "" {
log.MatchReason = string(plan.Skill.ExecutionMode) log.MatchReason = "content"
} }
if writeErr := WriteRunLog(log); writeErr != nil && err == nil { if writeErr := WriteRunLog(log); writeErr != nil && err == nil {
err = writeErr err = writeErr
+25 -16
View File
@@ -1,29 +1,38 @@
package builders package builders
import ( import (
"encoding/json"
"cs-agent/internal/models" "cs-agent/internal/models"
"cs-agent/internal/pkg/dto/response" "cs-agent/internal/pkg/dto/response"
"cs-agent/internal/pkg/enums" "cs-agent/internal/pkg/enums"
) )
func BuildSkillDefinitionResponse(item *models.SkillDefinition) response.SkillDefinitionResponse { func BuildSkillDefinitionResponse(item *models.SkillDefinition) response.SkillDefinitionResponse {
examples := make([]string, 0)
if raw := item.Examples; raw != "" {
_ = json.Unmarshal([]byte(raw), &examples)
}
allowedToolCodes := make([]string, 0)
if raw := item.AllowedToolCodes; raw != "" {
_ = json.Unmarshal([]byte(raw), &allowedToolCodes)
}
return response.SkillDefinitionResponse{ return response.SkillDefinitionResponse{
ID: item.ID, ID: item.ID,
Code: item.Code, Code: item.Code,
Name: item.Name, Name: item.Name,
Description: item.Description, Description: item.Description,
Prompt: item.Prompt, Content: item.Content,
ExecutionMode: string(item.ExecutionMode), Examples: examples,
ExecutionModeName: enums.GetSkillExecutionModeLabel(item.ExecutionMode), AllowedToolCodes: allowedToolCodes,
ExecutionConfig: item.ExecutionConfig, Priority: item.Priority,
Priority: item.Priority, Status: int(item.Status),
Status: int(item.Status), StatusName: getSkillStatusName(item.Status),
StatusName: getSkillStatusName(item.Status), Remark: item.Remark,
Remark: item.Remark, CreatedAt: item.CreatedAt,
CreatedAt: item.CreatedAt, UpdatedAt: item.UpdatedAt,
UpdatedAt: item.UpdatedAt, CreateUserName: item.CreateUserName,
CreateUserName: item.CreateUserName, UpdateUserName: item.UpdateUserName,
UpdateUserName: item.UpdateUserName,
} }
} }
@@ -88,16 +88,19 @@ func (c *SkillDefinitionController) PostCreate() *web.JsonResult {
} }
item := &models.SkillDefinition{ item := &models.SkillDefinition{
Code: strings.TrimSpace(req.Code), Code: strings.TrimSpace(req.Code),
Name: strings.TrimSpace(req.Name), Name: strings.TrimSpace(req.Name),
Description: strings.TrimSpace(req.Description), Description: strings.TrimSpace(req.Description),
Prompt: strings.TrimSpace(req.Prompt), Content: strings.TrimSpace(req.Content),
ExecutionMode: normalizeExecutionMode(req.ExecutionMode), Examples: mustMarshalJSONStringArray(req.Examples),
ExecutionConfig: normalizeExecutionConfig(req.ExecutionConfig), AllowedToolCodes: mustMarshalJSONStringArray(req.AllowedToolCodes),
Priority: 0, Priority: normalizeSkillPriority(req.Priority),
Status: enums.StatusOk, Status: enums.StatusOk,
Remark: strings.TrimSpace(req.Remark), Remark: strings.TrimSpace(req.Remark),
AuditFields: utils.BuildAuditFields(operator), AuditFields: utils.BuildAuditFields(operator),
}
if item.Priority <= 0 {
item.Priority = services.SkillDefinitionService.NextPriority()
} }
if err := services.SkillDefinitionService.Create(item); err != nil { if err := services.SkillDefinitionService.Create(item); err != nil {
return web.JsonError(err) return web.JsonError(err)
@@ -132,16 +135,17 @@ func (c *SkillDefinitionController) PostUpdate() *web.JsonResult {
} }
if err := services.SkillDefinitionService.Updates(req.ID, map[string]any{ if err := services.SkillDefinitionService.Updates(req.ID, map[string]any{
"code": strings.TrimSpace(req.Code), "code": strings.TrimSpace(req.Code),
"name": strings.TrimSpace(req.Name), "name": strings.TrimSpace(req.Name),
"description": strings.TrimSpace(req.Description), "description": strings.TrimSpace(req.Description),
"prompt": strings.TrimSpace(req.Prompt), "content": strings.TrimSpace(req.Content),
"execution_mode": normalizeExecutionMode(req.ExecutionMode), "examples": mustMarshalJSONStringArray(req.Examples),
"execution_config": normalizeExecutionConfig(req.ExecutionConfig), "allowed_tool_codes": mustMarshalJSONStringArray(req.AllowedToolCodes),
"remark": strings.TrimSpace(req.Remark), "priority": resolveSkillPriorityForUpdate(req.Priority, item.Priority),
"update_user_id": operator.UserID, "remark": strings.TrimSpace(req.Remark),
"update_user_name": operator.Username, "update_user_id": operator.UserID,
"updated_at": time.Now(), "update_user_name": operator.Username,
"updated_at": time.Now(),
}); err != nil { }); err != nil {
return web.JsonError(err) return web.JsonError(err)
} }
@@ -240,41 +244,65 @@ func (c *SkillDefinitionController) PostDebug_run() *web.JsonResult {
func validateSkillDefinitionRequest(req request.CreateSkillDefinitionRequest) error { func validateSkillDefinitionRequest(req request.CreateSkillDefinitionRequest) error {
code := strings.TrimSpace(req.Code) code := strings.TrimSpace(req.Code)
name := strings.TrimSpace(req.Name) name := strings.TrimSpace(req.Name)
prompt := strings.TrimSpace(req.Prompt) content := strings.TrimSpace(req.Content)
if code == "" { if code == "" {
return errorsx.InvalidParam("Skill 编码不能为空") return errorsx.InvalidParam("Skill 编码不能为空")
} }
if name == "" { if name == "" {
return errorsx.InvalidParam("Skill 名称不能为空") return errorsx.InvalidParam("Skill 名称不能为空")
} }
mode := normalizeExecutionMode(req.ExecutionMode) if content == "" {
if prompt == "" { return errorsx.InvalidParam("Content 不能为空")
return errorsx.InvalidParam("Prompt 不能为空")
} }
switch mode { if _, err := normalizeJSONStringArray(req.Examples); err != nil {
case enums.SkillExecutionModePromptOnly: return err
case enums.SkillExecutionModeMCPTool: }
configText := strings.TrimSpace(req.ExecutionConfig) if _, err := normalizeJSONStringArray(req.AllowedToolCodes); err != nil {
if configText == "" { return err
return errorsx.InvalidParam("MCP工具模式必须填写ExecutionConfig")
}
var payload map[string]any
if err := json.Unmarshal([]byte(configText), &payload); err != nil {
return errorsx.InvalidParam("ExecutionConfig 必须是合法JSON")
}
default:
return errorsx.InvalidParam("Skill 执行模式不合法")
} }
return nil return nil
} }
func normalizeExecutionMode(mode enums.SkillExecutionMode) enums.SkillExecutionMode { func normalizeSkillPriority(priority int) int {
if mode == "" { if priority < 0 {
return enums.SkillExecutionModePromptOnly return 0
} }
return mode return priority
} }
func normalizeExecutionConfig(raw string) string { func resolveSkillPriorityForUpdate(input, current int) int {
return strings.TrimSpace(raw) 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)
} }
+10 -10
View File
@@ -906,16 +906,16 @@ type KnowledgeFeedback struct {
// SkillDefinition 表示可由后台配置并参与运行时路由的 Skill 定义。 // SkillDefinition 表示可由后台配置并参与运行时路由的 Skill 定义。
type SkillDefinition struct { type SkillDefinition struct {
ID int64 `gorm:"primaryKey;autoIncrement"` // ID 为 Skill 主键。 ID int64 `gorm:"primaryKey;autoIncrement"` // ID 为 Skill 主键。
Code string `gorm:"type:varchar(100);not null;default:'';uniqueIndex"` // Code 为 Skill 的稳定唯一编码,供程序内部引用和路由判断使用,例如 refund_skill。 Code string `gorm:"type:varchar(100);not null;default:'';uniqueIndex"` // Code 为 Skill 的稳定唯一编码,供程序内部引用和路由判断使用,例如 refund_skill。
Name string `gorm:"type:varchar(100);not null;default:'';index"` // Name 为 Skill 的展示名称,用于后台列表、配置页和人工选择场景。 Name string `gorm:"type:varchar(100);not null;default:'';index"` // Name 为 Skill 的展示名称,用于后台列表、配置页和人工选择场景。
Description string `gorm:"type:varchar(255);not null;default:''"` // Description 为 Skill 的简要说明,用于描述该 Skill 的适用场景和职责边界。 Description string `gorm:"type:varchar(255);not null;default:''"` // Description 为 Skill 的简要说明,用于描述该 Skill 的适用场景和职责边界。
Prompt string `gorm:"type:longtext"` // Prompt 为 Skill 的核心提示词,在命中后注入模型上下文参与执行 Content string `gorm:"type:longtext"` // Content 为 Skill 的主体文档,使用 Markdown 编写,供 Agent 理解任务目标、步骤和工具使用要求
ExecutionMode enums.SkillExecutionMode `gorm:"type:varchar(30);not null;default:'prompt_only'"` // ExecutionMode 为 Skill 执行模式 Examples string `gorm:"type:text"` // Examples 为示例问法 JSON 数组字符串
ExecutionConfig string `gorm:"type:text"` // ExecutionConfig 为 Skill 执行配置JSON AllowedToolCodes string `gorm:"type:text"` // AllowedToolCodes 为允许使用的工具编码 JSON 数组字符串
Priority int `gorm:"type:int;not null;default:0;index"` // Priority 为 Skill 命中冲突时的优先级,数值越大优先级越高。 Priority int `gorm:"type:int;not null;default:0;index"` // Priority 为 Skill 命中冲突时的优先级,数值越大优先级越高。
Status enums.Status `gorm:"type:int;not null;default:0;index"` // Status 为 Skill 当前状态,使用全局通用状态:0启用 1禁用 2删除。 Status enums.Status `gorm:"type:int;not null;default:0;index"` // Status 为 Skill 当前状态,使用全局通用状态:0启用 1禁用 2删除。
Remark string `gorm:"type:text"` // Remark 为后台备注,用于记录配置说明、维护信息或内部协作信息。 Remark string `gorm:"type:text"` // Remark 为后台备注,用于记录配置说明、维护信息或内部协作信息。
AuditFields AuditFields
} }
+8 -9
View File
@@ -1,7 +1,5 @@
package request package request
import "cs-agent/internal/pkg/enums"
type SkillDefinitionListRequest struct { type SkillDefinitionListRequest struct {
Name string `json:"name"` Name string `json:"name"`
Code string `json:"code"` Code string `json:"code"`
@@ -9,13 +7,14 @@ type SkillDefinitionListRequest struct {
} }
type CreateSkillDefinitionRequest struct { type CreateSkillDefinitionRequest struct {
Code string `json:"code"` Code string `json:"code"`
Name string `json:"name"` Name string `json:"name"`
Description string `json:"description"` Description string `json:"description"`
Prompt string `json:"prompt"` Content string `json:"content"`
ExecutionMode enums.SkillExecutionMode `json:"executionMode"` Examples []string `json:"examples"`
ExecutionConfig string `json:"executionConfig"` AllowedToolCodes []string `json:"allowedToolCodes"`
Remark string `json:"remark"` Priority int `json:"priority"`
Remark string `json:"remark"`
} }
type UpdateSkillDefinitionRequest struct { type UpdateSkillDefinitionRequest struct {
+15 -16
View File
@@ -3,22 +3,21 @@ package response
import "time" import "time"
type SkillDefinitionResponse struct { type SkillDefinitionResponse struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Code string `json:"code"` Code string `json:"code"`
Name string `json:"name"` Name string `json:"name"`
Description string `json:"description"` Description string `json:"description"`
Prompt string `json:"prompt"` Content string `json:"content"`
ExecutionMode string `json:"executionMode"` Examples []string `json:"examples"`
ExecutionModeName string `json:"executionModeName"` AllowedToolCodes []string `json:"allowedToolCodes"`
ExecutionConfig string `json:"executionConfig"` Priority int `json:"priority"`
Priority int `json:"priority"` Status int `json:"status"`
Status int `json:"status"` StatusName string `json:"statusName"`
StatusName string `json:"statusName"` Remark string `json:"remark"`
Remark string `json:"remark"` CreatedAt time.Time `json:"createdAt"`
CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"`
UpdatedAt time.Time `json:"updatedAt"` CreateUserName string `json:"createUserName"`
CreateUserName string `json:"createUserName"` UpdateUserName string `json:"updateUserName"`
UpdateUserName string `json:"updateUserName"`
} }
type SkillDebugRunResponse struct { type SkillDebugRunResponse struct {