Init
This commit is contained in:
@@ -0,0 +1,248 @@
|
||||
package skills
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"cs-agent/internal/ai"
|
||||
"cs-agent/internal/ai/mcps"
|
||||
"cs-agent/internal/pkg/enums"
|
||||
"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) {
|
||||
if plan == nil || plan.Skill == nil {
|
||||
return "", nil, nil
|
||||
}
|
||||
trace := &ExecutionTrace{
|
||||
Status: "started",
|
||||
ExecutionMode: string(plan.Skill.ExecutionMode),
|
||||
}
|
||||
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执行模式不支持")
|
||||
}
|
||||
}
|
||||
|
||||
func executePromptOnly(ctx context.Context, plan *ExecutionPlan, runtimeCtx RuntimeContext, trace *ExecutionTrace) (string, error) {
|
||||
if plan == nil || plan.Skill == nil {
|
||||
return "", nil
|
||||
}
|
||||
if plan.AIConfig == nil {
|
||||
return "", errorsx.InvalidParam("Skill 关联的 AI 配置不可用")
|
||||
}
|
||||
systemPrompt := strings.TrimSpace(plan.Skill.Prompt)
|
||||
if systemPrompt == "" {
|
||||
return "", errorsx.InvalidParam("Skill Prompt 不能为空")
|
||||
}
|
||||
userPrompt := strings.TrimSpace(runtimeCtx.UserMessage)
|
||||
if userPrompt == "" {
|
||||
return "", errorsx.InvalidParam("用户消息不能为空")
|
||||
}
|
||||
promptTrace := &PromptTrace{Status: "started"}
|
||||
if trace != nil {
|
||||
trace.Prompt = promptTrace
|
||||
}
|
||||
startedAt := time.Now()
|
||||
result, err := ai.LLM.ChatWithConfig(ctx, plan.AIConfig, systemPrompt, userPrompt)
|
||||
promptTrace.LatencyMs = time.Since(startedAt).Milliseconds()
|
||||
if err != nil {
|
||||
promptTrace.Status = "error"
|
||||
promptTrace.Error = err.Error()
|
||||
if trace != nil {
|
||||
trace.Status = "error"
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
promptTrace.Status = "ok"
|
||||
promptTrace.ModelName = result.ModelName
|
||||
promptTrace.PromptTokens = result.PromptTokens
|
||||
promptTrace.CompletionTokens = result.CompletionTokens
|
||||
if trace != nil {
|
||||
trace.Status = "ok"
|
||||
}
|
||||
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])) + "..."
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package skills
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"cs-agent/internal/models"
|
||||
)
|
||||
|
||||
// BuildRunLog 根据执行计划与运行结果构建 Skill 运行日志。
|
||||
func BuildRunLog(ctx RuntimeContext, plan *ExecutionPlan, trace *ExecutionTrace, err error) *models.SkillRunLog {
|
||||
log := &models.SkillRunLog{
|
||||
ConversationID: ctx.ConversationID,
|
||||
AIAgentID: ctx.AIAgentID,
|
||||
ManualSkillCode: ctx.ManualSkillCode,
|
||||
IntentCode: ctx.IntentCode,
|
||||
UserMessage: ctx.UserMessage,
|
||||
TraceData: buildTraceData(trace),
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
if plan != nil {
|
||||
if plan.AIConfig != nil {
|
||||
log.AIConfigID = plan.AIConfig.ID
|
||||
log.UsedModel = plan.AIConfig.ModelName
|
||||
log.UsedProvider = plan.AIConfig.Provider
|
||||
}
|
||||
if plan.Skill != nil {
|
||||
log.SkillDefinitionID = plan.Skill.ID
|
||||
log.SkillCode = plan.Skill.Code
|
||||
log.Matched = true
|
||||
log.FinalSelected = true
|
||||
log.MatchReason = plan.MatchReason
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
log.ErrorMessage = err.Error()
|
||||
} else if !log.Matched {
|
||||
if plan != nil && plan.MatchReason != "" {
|
||||
log.MatchReason = plan.MatchReason
|
||||
} else {
|
||||
log.MatchReason = "not_matched"
|
||||
}
|
||||
}
|
||||
return log
|
||||
}
|
||||
|
||||
func buildTraceData(trace *ExecutionTrace) string {
|
||||
if trace == nil {
|
||||
return ""
|
||||
}
|
||||
data, err := json.Marshal(trace)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package skills
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cs-agent/internal/ai"
|
||||
"cs-agent/internal/models"
|
||||
"cs-agent/internal/pkg/enums"
|
||||
"cs-agent/internal/pkg/errorsx"
|
||||
"cs-agent/internal/pkg/utils"
|
||||
"cs-agent/internal/repositories"
|
||||
|
||||
"github.com/mlogclub/simple/common/strs"
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
)
|
||||
|
||||
type intentTriggerConfig struct {
|
||||
Intents []string `json:"intents"`
|
||||
}
|
||||
|
||||
// MatchSkill 对单个 SkillDefinition 执行命中判断。
|
||||
func MatchSkill(execCtx context.Context, ctx RuntimeContext, aiAgent *models.AIAgent, aiConfig *models.AIConfig) (*models.SkillDefinition, string, *RouteTrace, error) {
|
||||
if strs.IsNotBlank(ctx.ManualSkillCode) {
|
||||
skill := repositories.SkillDefinitionRepository.GetByCode(sqls.DB(), ctx.ManualSkillCode)
|
||||
if skill == nil || skill.Status != enums.StatusOk {
|
||||
return nil, "", nil, errorsx.InvalidParam("Skill 不存在或未启用")
|
||||
}
|
||||
return skill, "manual_skill_code", &RouteTrace{
|
||||
Status: "manual_selected",
|
||||
SelectedSkillCode: skill.Code,
|
||||
}, nil
|
||||
}
|
||||
|
||||
candidates := loadCandidateSkills(aiAgent)
|
||||
trace := &RouteTrace{
|
||||
Status: "started",
|
||||
CandidateSkillCodes: make([]string, 0, len(candidates)),
|
||||
}
|
||||
for _, item := range candidates {
|
||||
trace.CandidateSkillCodes = append(trace.CandidateSkillCodes, item.Code)
|
||||
}
|
||||
if len(candidates) == 0 {
|
||||
trace.Status = "no_candidate"
|
||||
return nil, "no_enabled_skill_bound", trace, nil
|
||||
}
|
||||
|
||||
intentCode := strings.TrimSpace(ctx.IntentCode)
|
||||
if intentCode != "" {
|
||||
for _, item := range candidates {
|
||||
if strings.EqualFold(strings.TrimSpace(item.Code), intentCode) {
|
||||
trace.Status = "intent_selected"
|
||||
trace.SelectedSkillCode = item.Code
|
||||
return &item, "intent_code", trace, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(candidates) == 1 {
|
||||
trace.Status = "single_candidate"
|
||||
trace.SelectedSkillCode = candidates[0].Code
|
||||
return &candidates[0], "single_candidate", trace, nil
|
||||
}
|
||||
|
||||
selected, routeTrace, err := routeSkillWithLLM(execCtx, aiConfig, ctx.UserMessage, candidates)
|
||||
if routeTrace != nil {
|
||||
trace.Status = routeTrace.Status
|
||||
trace.SelectedSkillCode = routeTrace.SelectedSkillCode
|
||||
trace.RawDecision = routeTrace.RawDecision
|
||||
trace.LatencyMs = routeTrace.LatencyMs
|
||||
trace.Error = routeTrace.Error
|
||||
}
|
||||
if err != nil {
|
||||
if trace.Error == "" {
|
||||
trace.Error = err.Error()
|
||||
}
|
||||
return nil, "route_error", trace, err
|
||||
}
|
||||
if selected == nil {
|
||||
if trace.Status == "started" {
|
||||
trace.Status = "not_matched"
|
||||
}
|
||||
return nil, "route_none", trace, nil
|
||||
}
|
||||
return selected, "llm_route", trace, nil
|
||||
}
|
||||
|
||||
func loadCandidateSkills(aiAgent *models.AIAgent) []models.SkillDefinition {
|
||||
if aiAgent == nil {
|
||||
return nil
|
||||
}
|
||||
skillIDs := utils.SplitInt64s(aiAgent.SkillIDs)
|
||||
if len(skillIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
ret := make([]models.SkillDefinition, 0, len(skillIDs))
|
||||
for _, id := range skillIDs {
|
||||
skill := repositories.SkillDefinitionRepository.Get(sqls.DB(), id)
|
||||
if skill == nil || skill.Status != enums.StatusOk {
|
||||
continue
|
||||
}
|
||||
ret = append(ret, *skill)
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func routeSkillWithLLM(ctx context.Context, aiConfig *models.AIConfig, userMessage string, candidates []models.SkillDefinition) (*models.SkillDefinition, *RouteTrace, error) {
|
||||
trace := &RouteTrace{Status: "started"}
|
||||
if aiConfig == nil {
|
||||
trace.Status = "config_error"
|
||||
trace.Error = "ai config is nil"
|
||||
return nil, trace, errorsx.InvalidParam("Skill 路由依赖的 AI 配置不可用")
|
||||
}
|
||||
if len(candidates) == 0 {
|
||||
trace.Status = "no_candidate"
|
||||
return nil, trace, nil
|
||||
}
|
||||
userMessage = strings.TrimSpace(userMessage)
|
||||
if userMessage == "" {
|
||||
trace.Status = "empty_user_message"
|
||||
return nil, trace, nil
|
||||
}
|
||||
systemPrompt := "你是客服技能路由器。你只能在候选 Skill 中选择一个最合适的 skillCode,或者返回 NONE。只有当用户问题与 Skill 的职责边界明确匹配时才选择;如果不明确、信息不足、多个 Skill 都不够确定,就返回 NONE。输出只能是 skillCode 或 NONE,不能输出其他内容。"
|
||||
userPrompt := buildSkillRoutePrompt(userMessage, candidates)
|
||||
startedAt := time.Now()
|
||||
result, err := ai.LLM.ChatWithConfig(ctx, aiConfig, systemPrompt, userPrompt)
|
||||
trace.LatencyMs = time.Since(startedAt).Milliseconds()
|
||||
if err != nil {
|
||||
trace.Status = "route_error"
|
||||
trace.Error = err.Error()
|
||||
return nil, trace, err
|
||||
}
|
||||
decision := normalizeRouteDecision(result.Content)
|
||||
trace.RawDecision = strings.TrimSpace(result.Content)
|
||||
if decision == "" || decision == "NONE" {
|
||||
trace.Status = "not_matched"
|
||||
return nil, trace, nil
|
||||
}
|
||||
for _, item := range candidates {
|
||||
if strings.EqualFold(item.Code, decision) {
|
||||
trace.Status = "llm_selected"
|
||||
trace.SelectedSkillCode = item.Code
|
||||
return &item, trace, nil
|
||||
}
|
||||
}
|
||||
trace.Status = "invalid_decision"
|
||||
trace.Error = fmt.Sprintf("invalid route decision: %s", decision)
|
||||
return nil, trace, nil
|
||||
}
|
||||
|
||||
func buildSkillRoutePrompt(userMessage string, candidates []models.SkillDefinition) string {
|
||||
lines := make([]string, 0, len(candidates)+4)
|
||||
lines = append(lines, "用户问题:")
|
||||
lines = append(lines, strings.TrimSpace(userMessage))
|
||||
lines = append(lines, "")
|
||||
lines = append(lines, "候选 Skills:")
|
||||
for _, item := range candidates {
|
||||
lines = append(lines, fmt.Sprintf("- skillCode=%s; name=%s; description=%s", strings.TrimSpace(item.Code), strings.TrimSpace(item.Name), strings.TrimSpace(item.Description)))
|
||||
}
|
||||
lines = append(lines, "")
|
||||
lines = append(lines, "请只输出一个 skillCode 或 NONE。")
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func normalizeRouteDecision(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
raw = strings.Trim(raw, "`")
|
||||
raw = strings.TrimSpace(raw)
|
||||
if idx := strings.Index(raw, "\n"); idx >= 0 {
|
||||
raw = raw[:idx]
|
||||
}
|
||||
raw = strings.TrimSpace(raw)
|
||||
raw = strings.Trim(raw, "\"'")
|
||||
if strings.EqualFold(raw, "NONE") {
|
||||
return "NONE"
|
||||
}
|
||||
return raw
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package skills
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"cs-agent/internal/models"
|
||||
"cs-agent/internal/pkg/errorsx"
|
||||
"cs-agent/internal/repositories"
|
||||
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
)
|
||||
|
||||
// BuildExecutionPlan 构建当前请求的 Skill 执行计划。
|
||||
func BuildExecutionPlan(execCtx context.Context, ctx RuntimeContext) (*ExecutionPlan, error) {
|
||||
if ctx.AIAgentID <= 0 {
|
||||
return nil, errorsx.InvalidParam("AIAgentID不能为空")
|
||||
}
|
||||
|
||||
aiAgent := repositories.AIAgentRepository.Get(sqls.DB(), ctx.AIAgentID)
|
||||
if aiAgent == nil {
|
||||
return nil, errorsx.InvalidParam("AI Agent不存在")
|
||||
}
|
||||
aiConfig := repositories.AIConfigRepository.Get(sqls.DB(), aiAgent.AIConfigID)
|
||||
if aiConfig == nil {
|
||||
return nil, errorsx.InvalidParam("AI Agent关联的AI配置不存在")
|
||||
}
|
||||
|
||||
skill, matchReason, routeTrace, err := MatchSkill(execCtx, ctx, aiAgent, aiConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &ExecutionPlan{
|
||||
AIAgent: aiAgent,
|
||||
AIConfig: aiConfig,
|
||||
Skill: skill,
|
||||
MatchReason: strings.TrimSpace(matchReason),
|
||||
RouteTrace: routeTrace,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// WriteRunLog 写入 Skill 运行日志。
|
||||
func WriteRunLog(log *models.SkillRunLog) error {
|
||||
if log == nil {
|
||||
return nil
|
||||
}
|
||||
return repositories.SkillRunLogRepository.Create(sqls.DB(), log)
|
||||
}
|
||||
|
||||
// Execute 执行一次 Skill 运行,当前阶段仅支持 prompt_only 风格的手动 Skill。
|
||||
func Execute(ctx context.Context, runtimeCtx RuntimeContext) (*ExecutionResult, error) {
|
||||
plan, err := BuildExecutionPlan(ctx, runtimeCtx)
|
||||
if err != nil {
|
||||
trace := &ExecutionTrace{Status: "plan_error"}
|
||||
log := BuildRunLog(runtimeCtx, nil, trace, err)
|
||||
_ = WriteRunLog(log)
|
||||
return nil, err
|
||||
}
|
||||
if plan == nil || plan.Skill == nil {
|
||||
trace := &ExecutionTrace{Status: "noop"}
|
||||
if plan != nil {
|
||||
trace.MatchReason = strings.TrimSpace(plan.MatchReason)
|
||||
trace.Route = plan.RouteTrace
|
||||
}
|
||||
log := BuildRunLog(runtimeCtx, plan, trace, nil)
|
||||
_ = WriteRunLog(log)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
replyText, trace, err := executeByPlan(ctx, plan, runtimeCtx)
|
||||
if trace != nil {
|
||||
trace.MatchReason = strings.TrimSpace(plan.MatchReason)
|
||||
if trace.Route == nil {
|
||||
trace.Route = plan.RouteTrace
|
||||
}
|
||||
}
|
||||
log := BuildRunLog(runtimeCtx, plan, trace, err)
|
||||
if strings.TrimSpace(replyText) != "" && strings.TrimSpace(log.MatchReason) == "" {
|
||||
log.MatchReason = string(plan.Skill.ExecutionMode)
|
||||
}
|
||||
if writeErr := WriteRunLog(log); writeErr != nil && err == nil {
|
||||
err = writeErr
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ExecutionResult{
|
||||
Plan: plan,
|
||||
ReplyText: strings.TrimSpace(replyText),
|
||||
RunLog: log,
|
||||
Trace: trace,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package skills
|
||||
|
||||
import "cs-agent/internal/models"
|
||||
|
||||
// RuntimeContext 表示一次 Skill 运行的输入上下文。
|
||||
type RuntimeContext struct {
|
||||
AIAgentID int64 // AIAgentID 为当前请求所属的 AI Agent ID,必填。
|
||||
UserMessage string // UserMessage 为当前用户输入。
|
||||
ConversationID int64 // ConversationID 为当前会话 ID,无会话上下文时为 0。
|
||||
ManualSkillCode string // ManualSkillCode 为显式指定的 Skill 编码。
|
||||
IntentCode string // IntentCode 为上游识别出的意图编码。
|
||||
}
|
||||
|
||||
// ExecutionPlan 表示 Skill Runtime 计算出的最终执行计划。
|
||||
type ExecutionPlan struct {
|
||||
AIAgent *models.AIAgent // AIAgent 为本次请求所属的 AI Agent。
|
||||
AIConfig *models.AIConfig // AIConfig 为本次请求实际使用的模型配置。
|
||||
Skill *models.SkillDefinition // Skill 为最终命中的 Skill,未命中时为空。
|
||||
MatchReason string // MatchReason 为命中原因。
|
||||
RouteTrace *RouteTrace // RouteTrace 为匹配阶段的路由追踪。
|
||||
}
|
||||
|
||||
// ExecutionResult 表示一次 Skill 执行的最终结果。
|
||||
type ExecutionResult struct {
|
||||
Plan *ExecutionPlan
|
||||
ReplyText string
|
||||
RunLog *models.SkillRunLog
|
||||
Trace *ExecutionTrace
|
||||
}
|
||||
|
||||
type ExecutionTrace struct {
|
||||
Status string `json:"status"`
|
||||
MatchReason string `json:"matchReason,omitempty"`
|
||||
Route *RouteTrace `json:"route,omitempty"`
|
||||
ExecutionMode string `json:"executionMode,omitempty"`
|
||||
Prompt *PromptTrace `json:"prompt,omitempty"`
|
||||
MCP *MCPExecutionTrace `json:"mcp,omitempty"`
|
||||
}
|
||||
|
||||
type RouteTrace struct {
|
||||
Status string `json:"status"`
|
||||
CandidateSkillCodes []string `json:"candidateSkillCodes,omitempty"`
|
||||
SelectedSkillCode string `json:"selectedSkillCode,omitempty"`
|
||||
RawDecision string `json:"rawDecision,omitempty"`
|
||||
LatencyMs int64 `json:"latencyMs,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type PromptTrace struct {
|
||||
Status string `json:"status"`
|
||||
LatencyMs int64 `json:"latencyMs,omitempty"`
|
||||
ModelName string `json:"modelName,omitempty"`
|
||||
PromptTokens int `json:"promptTokens,omitempty"`
|
||||
CompletionTokens int `json:"completionTokens,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type MCPExecutionTrace struct {
|
||||
Status string `json:"status"`
|
||||
ServerCode string `json:"serverCode,omitempty"`
|
||||
ToolName string `json:"toolName,omitempty"`
|
||||
Arguments map[string]any `json:"arguments,omitempty"`
|
||||
LatencyMs int64 `json:"latencyMs,omitempty"`
|
||||
IsError bool `json:"isError,omitempty"`
|
||||
ContentItemCount int `json:"contentItemCount,omitempty"`
|
||||
HasStructuredContent bool `json:"hasStructuredContent,omitempty"`
|
||||
ResultPreview string `json:"resultPreview,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
SummaryPrompt *PromptTrace `json:"summaryPrompt,omitempty"`
|
||||
}
|
||||
Reference in New Issue
Block a user