diff --git a/docs b/docs index 9731deb..b42fb50 160000 --- a/docs +++ b/docs @@ -1 +1 @@ -Subproject commit 9731debd6a7841ba9c51ef61859b34e1729e0bbc +Subproject commit b42fb50bd9440af348d72259a7e395532b109421 diff --git a/internal/ai/application/runtime/prepare_service.go b/internal/ai/application/runtime/prepare_service.go index 6d0430b..8881cb6 100644 --- a/internal/ai/application/runtime/prepare_service.go +++ b/internal/ai/application/runtime/prepare_service.go @@ -1,14 +1,5 @@ package runtime -import ( - "context" - "encoding/json" - "strings" - - "cs-agent/internal/ai/skills" - "cs-agent/internal/models" -) - func newPrepareService(catalog *toolCatalog) *prepareService { return &prepareService{catalog: catalog} } @@ -17,28 +8,6 @@ type prepareService struct { catalog *toolCatalog } -func (s *prepareService) selectSkill(ctx context.Context, req Request) (*models.SkillDefinition, string, string, error) { - result, err := skills.Select(ctx, skills.RuntimeContext{ - AIAgent: req.AIAgent, - AIConfig: req.AIConfig, - UserMessage: strings.TrimSpace(req.UserMessage.Content), - ConversationID: req.Conversation.ID, - ManualSkillCode: strings.TrimSpace(req.ManualSkillCode), - }) - if err != nil { - return nil, "", "", err - } - if result == nil || result.Plan == nil || result.Plan.Skill == nil { - traceData := marshalSkillRouteTrace(result) - reason := "" - if result != nil && result.Plan != nil { - reason = strings.TrimSpace(result.Plan.MatchReason) - } - return nil, reason, traceData, nil - } - return result.Plan.Skill, strings.TrimSpace(result.Plan.MatchReason), marshalSkillRouteTrace(result), nil -} - func (s *prepareService) prepareToolsForRun(req *Request) error { if req == nil || req.ToolSet != nil || s.catalog == nil { return nil @@ -66,22 +35,3 @@ func (s *prepareService) prepareToolsForResume(req *ResumeRequest) error { } return nil } - -func marshalSkillRouteTrace(result *skills.ExecutionResult) string { - if result == nil || result.Plan == nil || result.Plan.RouteTrace == nil { - return "" - } - buf, err := json.Marshal(result.Plan.RouteTrace) - if err != nil { - return "" - } - return string(buf) -} - -func cloneSkillDefinition(item *models.SkillDefinition) *models.SkillDefinition { - if item == nil { - return nil - } - clone := *item - return &clone -} diff --git a/internal/ai/application/runtime/service.go b/internal/ai/application/runtime/service.go index 369704a..6de73bd 100644 --- a/internal/ai/application/runtime/service.go +++ b/internal/ai/application/runtime/service.go @@ -27,39 +27,21 @@ func (s *Service) Run(ctx context.Context, req Request) (*Summary, error) { return nil, nil } req.UserMessage.Content = utils.BuildRuntimeMessageText(req.UserMessage.MessageType, req.UserMessage.Content) - selectedSkill, skillReason, skillTrace, skillErr := s.prepare.selectSkill(ctx, req) - req.SelectedSkill = selectedSkill - req.SkillRouteReason = skillReason - req.SkillRouteTrace = skillTrace - if req.SelectedSkill != nil { - req.SelectedSkill = cloneSkillDefinition(req.SelectedSkill) - } if err := s.prepare.prepareToolsForRun(&req); err != nil { return nil, err } summary, err := s.runtime.ExecuteRun(ctx, executor.RunInput{ - Conversation: req.Conversation, - UserMessage: req.UserMessage, - AIAgent: req.AIAgent, - AIConfig: req.AIConfig, - SelectedSkill: req.SelectedSkill, - SkillRouteReason: req.SkillRouteReason, - SkillRouteTrace: req.SkillRouteTrace, - CheckPointID: req.CheckPointID, - ToolSet: req.ToolSet, + Conversation: req.Conversation, + UserMessage: req.UserMessage, + AIAgent: req.AIAgent, + AIConfig: req.AIConfig, + CheckPointID: req.CheckPointID, + ToolSet: req.ToolSet, }) if err != nil { - ret := toSummary(summary) - if ret != nil && skillErr != nil && ret.PlanReason == "" { - ret.PlanReason = "skill_failed_fallback_runtime" - } - return ret, err + return toSummary(summary), err } - ret := toSummary(summary) - if ret != nil && skillErr != nil && ret.PlanReason == "" { - ret.PlanReason = "skill_failed_fallback_runtime" - } - return ret, nil + return toSummary(summary), nil } func (s *Service) Resume(ctx context.Context, req ResumeRequest) (*Summary, error) { diff --git a/internal/ai/application/runtime/tool_catalog.go b/internal/ai/application/runtime/tool_catalog.go index 6f5ff6f..c72a906 100644 --- a/internal/ai/application/runtime/tool_catalog.go +++ b/internal/ai/application/runtime/tool_catalog.go @@ -41,7 +41,7 @@ func (c *toolCatalog) resolveForRun(req *Request) (*registry.ToolSet, error) { AIAgent: req.AIAgent, AIConfig: req.AIConfig, UserMessage: req.UserMessage, - AllowedToolCodes: c.resolveAllowedToolCodes(req.AIAgent, req.SelectedSkill), + AllowedToolCodes: c.parseAgentAllowedToolCodes(req.AIAgent), }) } diff --git a/internal/ai/application/runtime/types.go b/internal/ai/application/runtime/types.go index f38f1bb..aba9daf 100644 --- a/internal/ai/application/runtime/types.go +++ b/internal/ai/application/runtime/types.go @@ -6,16 +6,12 @@ import ( ) type Request struct { - Conversation models.Conversation - UserMessage models.Message - AIAgent models.AIAgent - AIConfig models.AIConfig - ManualSkillCode string - SelectedSkill *models.SkillDefinition - SkillRouteReason string - SkillRouteTrace string - CheckPointID string - ToolSet *registry.ToolSet + Conversation models.Conversation + UserMessage models.Message + AIAgent models.AIAgent + AIConfig models.AIConfig + CheckPointID string + ToolSet *registry.ToolSet } type ResumeRequest struct { diff --git a/internal/ai/runtime/debug_run.go b/internal/ai/runtime/debug_run.go index 37f2464..f9fcfe7 100644 --- a/internal/ai/runtime/debug_run.go +++ b/internal/ai/runtime/debug_run.go @@ -43,20 +43,15 @@ func DebugRunSkill(ctx context.Context, req request.SkillDebugRunRequest) (*resp Content: strings.TrimSpace(req.UserMessage), } summary, err := Service.Run(ctx, applicationruntime.Request{ - Conversation: *conversation, - UserMessage: message, - AIAgent: *aiAgent, - AIConfig: *aiConfig, - ManualSkillCode: strings.TrimSpace(req.SkillCode), + Conversation: *conversation, + UserMessage: message, + AIAgent: *aiAgent, + AIConfig: *aiConfig, }) if err != nil { return buildSkillDebugRunResponse(req, summary, nil), err } - selectedSkill := svc.SkillDefinitionService.GetByCode(strings.TrimSpace(req.SkillCode)) - if summary == nil || strings.TrimSpace(summary.PlannedSkillCode) == "" { - return nil, errorsx.InvalidParam("Skill 未命中") - } - return buildSkillDebugRunResponse(req, summary, selectedSkill), nil + return buildSkillDebugRunResponse(req, summary, nil), nil } func DebugResumeSkill(ctx context.Context, req request.SkillDebugResumeRequest) (*response.SkillDebugRunResponse, error) { diff --git a/internal/ai/runtime/executor/service.go b/internal/ai/runtime/executor/service.go index 8329e12..5e2b824 100644 --- a/internal/ai/runtime/executor/service.go +++ b/internal/ai/runtime/executor/service.go @@ -43,32 +43,18 @@ func (s *Service) ExecuteRun(ctx context.Context, req RunInput) (*RunResult, err summary.TraceData = collector.Marshal() return summary, err } - tooling := prepareTooling(toolDefs, req.SelectedSkill, req.ToolSet, req.SelectedSkill != nil) + hasVisibleSkills := factory.HasVisibleSkills(req.AIAgent) + tooling := prepareTooling(toolDefs, nil, req.ToolSet, hasVisibleSkills) summary.ToolCodes = append(summary.ToolCodes, tooling.toolCodes...) collector.Data.Input.ToolCodes = append(collector.Data.Input.ToolCodes, summary.ToolCodes...) collector.SetTooling(tooling.staticToolCodes, definitionToolCodes(tooling.definitions), len(tooling.definitions) > 0) collector.Data.Model.Provider = string(req.AIConfig.Provider) collector.Data.Model.Name = req.AIConfig.ModelName - summary.SelectedSkillCode = "" - summary.SelectedSkillName = "" - summary.SkillRouteReason = strings.TrimSpace(req.SkillRouteReason) - summary.SkillRouteTrace = strings.TrimSpace(req.SkillRouteTrace) - if req.SelectedSkill != nil { - summary.SelectedSkillCode = strings.TrimSpace(req.SelectedSkill.Code) - summary.SelectedSkillName = strings.TrimSpace(req.SelectedSkill.Name) - summary.SkillAllowedToolCodes = parseJSONArrayList(req.SelectedSkill.ToolWhitelist) - collector.Data.Skill.Code = summary.SelectedSkillCode - collector.Data.Skill.Name = summary.SelectedSkillName - collector.Data.Skill.AllowedToolCodes = append([]string(nil), summary.SkillAllowedToolCodes...) - } - collector.Data.Skill.RouteReason = summary.SkillRouteReason - collector.Data.Skill.RouteTrace = summary.SkillRouteTrace agent, err := s.agentFactory.BuildCustomerServiceAgent(ctx, factory.BuildCustomerServiceAgentInput{ AIAgent: req.AIAgent, AIConfig: req.AIConfig, - SelectedSkill: req.SelectedSkill, InstructionToolDefinitions: tooling.definitions, DynamicMCPToolDefinitions: tooling.definitions, StaticTools: tooling.staticTools, @@ -114,6 +100,7 @@ func (s *Service) ExecuteRun(ctx context.Context, req RunInput) (*RunResult, err collector.Data.Status = summary.Status collector.Data.Output.ReplyText = summary.ReplyText collector.Data.Output.FinishReason = summary.Status + syncSkillSummaryFromCollector(summary, collector) summary.TraceData = collector.Marshal() return summary, nil } @@ -149,7 +136,8 @@ func (s *Service) ExecuteResume(ctx context.Context, req ResumeInput) (*RunResul summary.TraceData = collector.Marshal() return summary, err } - tooling := prepareTooling(toolDefs, nil, req.ToolSet, false) + hasVisibleSkills := factory.HasVisibleSkills(req.AIAgent) + tooling := prepareTooling(toolDefs, nil, req.ToolSet, hasVisibleSkills) summary.ToolCodes = append(summary.ToolCodes, tooling.toolCodes...) collector.Data.Input.ToolCodes = append(collector.Data.Input.ToolCodes, summary.ToolCodes...) collector.SetTooling(tooling.staticToolCodes, definitionToolCodes(tooling.definitions), len(tooling.definitions) > 0) @@ -211,6 +199,19 @@ func (s *Service) ExecuteResume(ctx context.Context, req ResumeInput) (*RunResul collector.Data.Status = summary.Status collector.Data.Output.ReplyText = summary.ReplyText collector.Data.Output.FinishReason = summary.Status + syncSkillSummaryFromCollector(summary, collector) summary.TraceData = collector.Marshal() return summary, nil } + +func syncSkillSummaryFromCollector(summary *RunResult, collector *callbacks.RuntimeTraceCollector) { + if summary == nil || collector == nil { + return + } + trace := collector.Data.Skill + summary.SelectedSkillCode = strings.TrimSpace(trace.Code) + summary.SelectedSkillName = strings.TrimSpace(trace.Name) + summary.SkillRouteReason = strings.TrimSpace(trace.RouteReason) + summary.SkillRouteTrace = strings.TrimSpace(trace.RouteTrace) + summary.SkillAllowedToolCodes = append([]string(nil), trace.AllowedToolCodes...) +} diff --git a/internal/ai/runtime/executor/types.go b/internal/ai/runtime/executor/types.go index 5d5dec2..29aeebc 100644 --- a/internal/ai/runtime/executor/types.go +++ b/internal/ai/runtime/executor/types.go @@ -6,15 +6,12 @@ import ( ) type RunInput struct { - Conversation models.Conversation - UserMessage models.Message - AIAgent models.AIAgent - AIConfig models.AIConfig - SelectedSkill *models.SkillDefinition - SkillRouteReason string - SkillRouteTrace string - CheckPointID string - ToolSet *registry.ToolSet + Conversation models.Conversation + UserMessage models.Message + AIAgent models.AIAgent + AIConfig models.AIConfig + CheckPointID string + ToolSet *registry.ToolSet } type ResumeInput struct { diff --git a/internal/ai/runtime/instruction/helpers.go b/internal/ai/runtime/instruction/helpers.go index ca9e67d..6ed2039 100644 --- a/internal/ai/runtime/instruction/helpers.go +++ b/internal/ai/runtime/instruction/helpers.go @@ -27,6 +27,10 @@ func BuildSelectedSkillActivationInstruction(skill *models.SkillDefinition) stri } func BuildSelectedSkillDocument(skill *models.SkillDefinition, toolDefinitions []runtimetooling.MCPToolDefinition) string { + return BuildSkillDocument(skill, toolDefinitions) +} + +func BuildSkillDocument(skill *models.SkillDefinition, toolDefinitions []runtimetooling.MCPToolDefinition) string { if skill == nil { return "" } diff --git a/internal/ai/runtime/internal/impl/callbacks/agent_trace_handler.go b/internal/ai/runtime/internal/impl/callbacks/agent_trace_handler.go index 1c7e0f4..4436218 100644 --- a/internal/ai/runtime/internal/impl/callbacks/agent_trace_handler.go +++ b/internal/ai/runtime/internal/impl/callbacks/agent_trace_handler.go @@ -24,8 +24,9 @@ type ToolMetadata struct { type RuntimeTraceHandler struct { *adk.BaseChatModelAgentMiddleware - collector *RuntimeTraceCollector - toolMetadataBy map[string]ToolMetadata + collector *RuntimeTraceCollector + toolMetadataBy map[string]ToolMetadata + skillMetadataBy map[string]SkillMetadata } type graphAnalyzeConversationResult struct { @@ -65,11 +66,12 @@ type toolSearchSearchResult struct { Candidates []toolSearchCandidateResult `json:"candidates"` } -func NewRuntimeTraceHandler(collector *RuntimeTraceCollector, toolMetadataBy map[string]ToolMetadata) *RuntimeTraceHandler { +func NewRuntimeTraceHandler(collector *RuntimeTraceCollector, toolMetadataBy map[string]ToolMetadata, skillMetadataBy map[string]SkillMetadata) *RuntimeTraceHandler { return &RuntimeTraceHandler{ BaseChatModelAgentMiddleware: &adk.BaseChatModelAgentMiddleware{}, collector: collector, toolMetadataBy: toolMetadataBy, + skillMetadataBy: skillMetadataBy, } } @@ -123,10 +125,42 @@ func (h *RuntimeTraceHandler) WrapInvokableToolCall(_ context.Context, endpoint if metadata, ok := h.resolveToolMetadata(item.ToolName); ok && strings.TrimSpace(metadata.ToolCode) == toolx.BuiltinToolSearch.Code { h.collector.AddToolSearchItem(h.buildToolSearchTraceItem(argumentsInJSON, result, err)) } + if metadata, ok := h.resolveToolMetadata(item.ToolName); ok && strings.TrimSpace(metadata.ToolCode) == toolx.BuiltinSkill.Code { + h.tryActivateSkill(argumentsInJSON) + } return result, err }, nil } +func (h *RuntimeTraceHandler) tryActivateSkill(argumentsInJSON string) { + if h == nil || h.collector == nil { + return + } + var args struct { + Skill string `json:"skill"` + } + if err := json.Unmarshal([]byte(strings.TrimSpace(argumentsInJSON)), &args); err != nil { + return + } + code := strings.TrimSpace(args.Skill) + if code == "" { + return + } + meta, ok := h.skillMetadataBy[code] + if !ok { + meta = SkillMetadata{Code: code} + } + buf, err := json.Marshal(map[string]any{ + "source": "eino_skill_tool", + "skill": code, + }) + routeTrace := "" + if err == nil { + routeTrace = string(buf) + } + h.collector.ActivateSkill(meta, "eino_skill_tool", routeTrace) +} + func parseGraphToolOutcome(toolCode string, result string) (recommendedAction, riskLevel string, ticketDraftReady bool) { toolCode = strings.TrimSpace(toolCode) if toolCode == "" || strings.TrimSpace(result) == "" { diff --git a/internal/ai/runtime/internal/impl/callbacks/agent_trace_handler_test.go b/internal/ai/runtime/internal/impl/callbacks/agent_trace_handler_test.go index 591dcfc..c615477 100644 --- a/internal/ai/runtime/internal/impl/callbacks/agent_trace_handler_test.go +++ b/internal/ai/runtime/internal/impl/callbacks/agent_trace_handler_test.go @@ -36,3 +36,32 @@ func TestExtractCandidateToolCodes(t *testing.T) { t.Fatalf("unexpected candidate codes: %#v", got) } } + +func TestTryActivateSkill(t *testing.T) { + collector := NewRuntimeTraceCollector() + handler := &RuntimeTraceHandler{ + collector: collector, + skillMetadataBy: map[string]SkillMetadata{ + "after_sales_escalation_skill": { + Code: "after_sales_escalation_skill", + Name: "售后升级", + AllowedToolCodes: []string{"graph/handoff_to_human"}, + }, + }, + } + + handler.tryActivateSkill(`{"skill":"after_sales_escalation_skill"}`) + + if collector.Data.Skill.Code != "after_sales_escalation_skill" { + t.Fatalf("unexpected skill code: %#v", collector.Data.Skill) + } + if collector.Data.Skill.Name != "售后升级" { + t.Fatalf("unexpected skill name: %#v", collector.Data.Skill) + } + if collector.Data.Skill.RouteReason != "eino_skill_tool" { + t.Fatalf("unexpected route reason: %#v", collector.Data.Skill) + } + if len(collector.Data.Skill.AllowedToolCodes) != 1 || collector.Data.Skill.AllowedToolCodes[0] != "graph/handoff_to_human" { + t.Fatalf("unexpected allowed tools: %#v", collector.Data.Skill.AllowedToolCodes) + } +} diff --git a/internal/ai/runtime/internal/impl/callbacks/runlog_callback.go b/internal/ai/runtime/internal/impl/callbacks/runlog_callback.go index dda3d3a..7d628b0 100644 --- a/internal/ai/runtime/internal/impl/callbacks/runlog_callback.go +++ b/internal/ai/runtime/internal/impl/callbacks/runlog_callback.go @@ -63,6 +63,43 @@ func (c *RuntimeTraceCollector) SetSkillMiddleware(enabled bool, toolName string c.Data.Skill.MiddlewareToolName = toolName } +type SkillMetadata struct { + Code string + Name string + Description string + AllowedToolCodes []string +} + +func (c *RuntimeTraceCollector) SetVisibleSkills(skills map[string]SkillMetadata) { + if c == nil || len(skills) == 0 { + return + } + c.mu.Lock() + defer c.mu.Unlock() + codes := make([]string, 0, len(skills)) + for code := range skills { + if code == "" { + continue + } + codes = append(codes, code) + } + c.Data.Skill.VisibleCodes = append([]string(nil), codes...) +} + +func (c *RuntimeTraceCollector) ActivateSkill(skill SkillMetadata, routeReason string, routeTrace string) { + if c == nil { + return + } + c.mu.Lock() + defer c.mu.Unlock() + c.Data.Skill.Code = skill.Code + c.Data.Skill.Name = skill.Name + c.Data.Skill.Description = skill.Description + c.Data.Skill.AllowedToolCodes = append([]string(nil), skill.AllowedToolCodes...) + c.Data.Skill.RouteReason = routeReason + c.Data.Skill.RouteTrace = routeTrace +} + func (c *RuntimeTraceCollector) SetRetrieverSummary(summary RetrieverTraceSummary) { if c == nil { return diff --git a/internal/ai/runtime/internal/impl/callbacks/trace_callback.go b/internal/ai/runtime/internal/impl/callbacks/trace_callback.go index 1c8cc51..e070c2d 100644 --- a/internal/ai/runtime/internal/impl/callbacks/trace_callback.go +++ b/internal/ai/runtime/internal/impl/callbacks/trace_callback.go @@ -142,11 +142,13 @@ type RuntimeTraceData struct { type SkillTraceData struct { Code string `json:"code,omitempty"` Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` RouteReason string `json:"routeReason,omitempty"` RouteTrace string `json:"routeTrace,omitempty"` AllowedToolCodes []string `json:"allowedToolCodes,omitempty"` MiddlewareEnabled bool `json:"middlewareEnabled,omitempty"` MiddlewareToolName string `json:"middlewareToolName,omitempty"` + VisibleCodes []string `json:"visibleCodes,omitempty"` } type InterruptTraceContext struct { diff --git a/internal/ai/runtime/internal/impl/factory/agent_factory.go b/internal/ai/runtime/internal/impl/factory/agent_factory.go index dee75dd..dc89c38 100644 --- a/internal/ai/runtime/internal/impl/factory/agent_factory.go +++ b/internal/ai/runtime/internal/impl/factory/agent_factory.go @@ -34,8 +34,6 @@ type BuildCustomerServiceAgentInput struct { AIAgent models.AIAgent // AIConfig 为模型配置,决定底层使用哪个 ChatModel。 AIConfig models.AIConfig - // SelectedSkill 为当前命中的技能;为空表示本次运行未命中专项技能。 - SelectedSkill *models.SkillDefinition // InstructionToolDefinitions 用于生成 instruction 中的工具说明。 // 它描述“当前允许模型理解和使用的 MCP 工具范围”。 InstructionToolDefinitions []tooling.MCPToolDefinition @@ -73,11 +71,11 @@ func (f *AgentFactory) BuildCustomerServiceAgent(ctx context.Context, input Buil } allTools := make([]tool.BaseTool, 0, len(input.StaticTools)) allTools = append(allTools, input.StaticTools...) - instructionResult := f.instructionService.Build(input.AIAgent, input.SelectedSkill, input.InstructionToolDefinitions, input.StaticToolCodes) + instructionResult := f.instructionService.Build(input.AIAgent, nil, input.InstructionToolDefinitions, input.StaticToolCodes) handlers := make([]adk.ChatModelAgentMiddleware, 0, 3) if f.handlerService != nil { builtHandlers, err := f.handlerService.Build(ctx, BuildAgentHandlersInput{ - SelectedSkill: input.SelectedSkill, + AIAgent: input.AIAgent, InstructionToolDefinitions: input.InstructionToolDefinitions, DynamicToolDefinitions: input.DynamicMCPToolDefinitions, DynamicTools: dynamicTools, diff --git a/internal/ai/runtime/internal/impl/factory/agent_handler_service.go b/internal/ai/runtime/internal/impl/factory/agent_handler_service.go index 285cc34..8ae25f8 100644 --- a/internal/ai/runtime/internal/impl/factory/agent_handler_service.go +++ b/internal/ai/runtime/internal/impl/factory/agent_handler_service.go @@ -19,7 +19,7 @@ type AgentHandlerService struct { } type BuildAgentHandlersInput struct { - SelectedSkill *models.SkillDefinition + AIAgent models.AIAgent InstructionToolDefinitions []runtimetooling.MCPToolDefinition DynamicToolDefinitions []runtimetooling.MCPToolDefinition DynamicTools []einobasetool.BaseTool @@ -46,20 +46,31 @@ func (s *AgentHandlerService) Build(ctx context.Context, input BuildAgentHandler } handlers = append(handlers, toolSearchHandler) } - if input.SelectedSkill != nil { - skillHandler, err := s.skillMiddleware.Build(ctx, input.SelectedSkill, input.InstructionToolDefinitions) + skillMetadataByCode := buildRuntimeSkillMetadataMap(input.AIAgent) + if len(skillMetadataByCode) > 0 { + skillHandler, err := s.skillMiddleware.Build(ctx, input.AIAgent, input.InstructionToolDefinitions) if err != nil { return nil, err } handlers = append(handlers, skillHandler) } if input.Collector != nil { - toolMetadataBy := buildRuntimeTraceToolMetadata(input.DynamicToolDefinitions, input.StaticToolMetadata, input.SelectedSkill) - if input.SelectedSkill != nil { + toolMetadataBy := buildRuntimeTraceToolMetadata(input.DynamicToolDefinitions, input.StaticToolMetadata, len(skillMetadataByCode) > 0) + traceSkillMetadata := make(map[string]einocallbacks.SkillMetadata, len(skillMetadataByCode)) + for code, item := range skillMetadataByCode { + traceSkillMetadata[code] = einocallbacks.SkillMetadata{ + Code: item.Code, + Name: item.Name, + Description: item.Description, + AllowedToolCodes: append([]string(nil), item.AllowedToolCodes...), + } + } + if len(skillMetadataByCode) > 0 { input.Collector.SetSkillMiddleware(true, toolx.BuiltinSkill.Name) } + input.Collector.SetVisibleSkills(traceSkillMetadata) input.Collector.SetInstructionSummary(input.InstructionSummary) - handlers = append(handlers, einocallbacks.NewRuntimeTraceHandler(input.Collector, toolMetadataBy)) + handlers = append(handlers, einocallbacks.NewRuntimeTraceHandler(input.Collector, toolMetadataBy, traceSkillMetadata)) } return handlers, nil } diff --git a/internal/ai/runtime/internal/impl/factory/skill_middleware_backend.go b/internal/ai/runtime/internal/impl/factory/skill_middleware_backend.go index ece75a6..25e7034 100644 --- a/internal/ai/runtime/internal/impl/factory/skill_middleware_backend.go +++ b/internal/ai/runtime/internal/impl/factory/skill_middleware_backend.go @@ -2,60 +2,186 @@ package factory import ( "context" + "encoding/json" "fmt" "strings" runtimeinstruction "cs-agent/internal/ai/runtime/instruction" runtimetooling "cs-agent/internal/ai/runtime/tooling" "cs-agent/internal/models" + "cs-agent/internal/pkg/enums" + "cs-agent/internal/pkg/utils" + "cs-agent/internal/services" einoskill "github.com/cloudwego/eino/adk/middlewares/skill" ) -type selectedSkillBackend struct { - frontMatter einoskill.FrontMatter - skill einoskill.Skill +type runtimeSkillMetadata struct { + Code string + Name string + Description string + AllowedToolCodes []string } -func newSelectedSkillBackend(selectedSkill *models.SkillDefinition, toolDefinitions []runtimetooling.MCPToolDefinition) (*selectedSkillBackend, error) { - if selectedSkill == nil { - return nil, fmt.Errorf("selected skill is nil") +type databaseSkillBackend struct { + toolDefinitions []runtimetooling.MCPToolDefinition + skillsByCode map[string]models.SkillDefinition + order []string +} + +func newDatabaseSkillBackend(aiAgent models.AIAgent, toolDefinitions []runtimetooling.MCPToolDefinition) (*databaseSkillBackend, error) { + visibleSkills := loadVisibleSkills(aiAgent) + if len(visibleSkills) == 0 { + return nil, fmt.Errorf("no visible skills available") } - skillName := strings.TrimSpace(selectedSkill.Code) - if skillName == "" { - return nil, fmt.Errorf("selected skill code is empty") + ret := &databaseSkillBackend{ + toolDefinitions: append([]runtimetooling.MCPToolDefinition(nil), toolDefinitions...), + skillsByCode: make(map[string]models.SkillDefinition, len(visibleSkills)), + order: make([]string, 0, len(visibleSkills)), } - description := strings.TrimSpace(selectedSkill.Description) - content := runtimeinstruction.BuildSelectedSkillDocument(selectedSkill, toolDefinitions) - return &selectedSkillBackend{ - frontMatter: einoskill.FrontMatter{ - Name: skillName, - Description: description, - }, - skill: einoskill.Skill{ - FrontMatter: einoskill.FrontMatter{ - Name: skillName, - Description: description, - }, - Content: content, + for _, item := range visibleSkills { + code := strings.TrimSpace(item.Code) + if code == "" { + continue + } + ret.skillsByCode[code] = item + ret.order = append(ret.order, code) + } + if len(ret.skillsByCode) == 0 { + return nil, fmt.Errorf("no visible skills available") + } + return ret, nil +} + +func (b *databaseSkillBackend) List(_ context.Context) ([]einoskill.FrontMatter, error) { + if b == nil || len(b.order) == 0 { + return nil, nil + } + ret := make([]einoskill.FrontMatter, 0, len(b.order)) + for _, code := range b.order { + item, ok := b.skillsByCode[code] + if !ok { + continue + } + ret = append(ret, einoskill.FrontMatter{ + Name: strings.TrimSpace(item.Code), + Description: skillListDescription(item), + }) + } + return ret, nil +} + +func (b *databaseSkillBackend) Get(_ context.Context, name string) (einoskill.Skill, error) { + if b == nil { + return einoskill.Skill{}, fmt.Errorf("database skill backend is nil") + } + name = strings.TrimSpace(name) + if name == "" { + return einoskill.Skill{}, fmt.Errorf("skill name is empty") + } + item, ok := b.skillsByCode[name] + if !ok { + return einoskill.Skill{}, fmt.Errorf("skill %q not found", name) + } + return einoskill.Skill{ + FrontMatter: einoskill.FrontMatter{ + Name: strings.TrimSpace(item.Code), + Description: skillListDescription(item), }, + Content: runtimeinstruction.BuildSkillDocument(&item, filterSkillToolDefinitions(b.toolDefinitions, &item)), + BaseDirectory: "", }, nil } -func (b *selectedSkillBackend) List(_ context.Context) ([]einoskill.FrontMatter, error) { - if b == nil { - return nil, nil +func loadVisibleSkills(aiAgent models.AIAgent) []models.SkillDefinition { + ids := utils.SplitInt64s(strings.TrimSpace(aiAgent.SkillIDs)) + if len(ids) == 0 { + return nil } - return []einoskill.FrontMatter{b.frontMatter}, nil + byID := services.SkillDefinitionService.GetByIDs(ids) + if len(byID) == 0 { + return nil + } + ret := make([]models.SkillDefinition, 0, len(ids)) + for _, id := range ids { + item, ok := byID[id] + if !ok || item.Status != enums.StatusOk || strings.TrimSpace(item.Code) == "" { + continue + } + ret = append(ret, item) + } + return ret } -func (b *selectedSkillBackend) Get(_ context.Context, name string) (einoskill.Skill, error) { - if b == nil { - return einoskill.Skill{}, fmt.Errorf("selected skill backend is nil") +func buildRuntimeSkillMetadataMap(aiAgent models.AIAgent) map[string]runtimeSkillMetadata { + visibleSkills := loadVisibleSkills(aiAgent) + if len(visibleSkills) == 0 { + return nil } - name = strings.TrimSpace(name) - if name == "" || strings.EqualFold(name, b.frontMatter.Name) { - return b.skill, nil + ret := make(map[string]runtimeSkillMetadata, len(visibleSkills)) + for _, item := range visibleSkills { + code := strings.TrimSpace(item.Code) + if code == "" { + continue + } + ret[code] = runtimeSkillMetadata{ + Code: code, + Name: strings.TrimSpace(item.Name), + Description: skillListDescription(item), + AllowedToolCodes: parseSkillToolWhitelist(item.ToolWhitelist), + } } - return einoskill.Skill{}, fmt.Errorf("skill %q not found", name) + return ret +} + +func HasVisibleSkills(aiAgent models.AIAgent) bool { + return len(buildRuntimeSkillMetadataMap(aiAgent)) > 0 +} + +func skillListDescription(item models.SkillDefinition) string { + if desc := strings.TrimSpace(item.Description); desc != "" { + return desc + } + if name := strings.TrimSpace(item.Name); name != "" { + return name + } + return strings.TrimSpace(item.Code) +} + +func parseSkillToolWhitelist(raw string) []string { + raw = strings.TrimSpace(raw) + 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 filterSkillToolDefinitions(defs []runtimetooling.MCPToolDefinition, skill *models.SkillDefinition) []runtimetooling.MCPToolDefinition { + allowed := parseSkillToolWhitelist(skill.ToolWhitelist) + if len(allowed) == 0 { + return defs + } + allowedSet := make(map[string]struct{}, len(allowed)) + for _, item := range allowed { + allowedSet[item] = struct{}{} + } + ret := make([]runtimetooling.MCPToolDefinition, 0, len(defs)) + for _, item := range defs { + if _, ok := allowedSet[strings.TrimSpace(item.ToolCode)]; ok { + ret = append(ret, item) + } + } + return ret } diff --git a/internal/ai/runtime/internal/impl/factory/skill_middleware_backend_test.go b/internal/ai/runtime/internal/impl/factory/skill_middleware_backend_test.go new file mode 100644 index 0000000..f7a3ff5 --- /dev/null +++ b/internal/ai/runtime/internal/impl/factory/skill_middleware_backend_test.go @@ -0,0 +1,128 @@ +package factory + +import ( + "context" + "strings" + "testing" + "time" + + runtimetooling "cs-agent/internal/ai/runtime/tooling" + "cs-agent/internal/models" + "cs-agent/internal/pkg/enums" + + "github.com/glebarez/sqlite" + "github.com/mlogclub/simple/sqls" + "gorm.io/gorm" +) + +func TestDatabaseSkillBackendListAndGet(t *testing.T) { + setupSkillBackendTestDB(t) + createSkillDefinitionForTest(t, models.SkillDefinition{ + ID: 1, + Code: "after_sales_escalation_skill", + Name: "售后升级", + Description: "处理转人工和升级诉求", + Instruction: "请优先判断是否需要转人工。", + ToolWhitelist: `["graph/handoff_to_human"]`, + Status: enums.StatusOk, + }) + createSkillDefinitionForTest(t, models.SkillDefinition{ + ID: 2, + Code: "disabled_skill", + Name: "禁用技能", + Description: "不会被暴露", + Instruction: "noop", + Status: enums.StatusDeleted, + }) + + backend, err := newDatabaseSkillBackend(models.AIAgent{SkillIDs: "1,2"}, []runtimetooling.MCPToolDefinition{ + {ToolCode: "graph/handoff_to_human", Title: "转人工确认流程"}, + {ToolCode: "graph/prepare_ticket_draft", Title: "整理工单草稿"}, + }) + if err != nil { + t.Fatalf("newDatabaseSkillBackend returned error: %v", err) + } + + matters, err := backend.List(context.Background()) + if err != nil { + t.Fatalf("List returned error: %v", err) + } + if len(matters) != 1 || matters[0].Name != "after_sales_escalation_skill" { + t.Fatalf("unexpected matters: %#v", matters) + } + + skill, err := backend.Get(context.Background(), "after_sales_escalation_skill") + if err != nil { + t.Fatalf("Get returned error: %v", err) + } + if skill.Name != "after_sales_escalation_skill" { + t.Fatalf("unexpected skill name: %#v", skill) + } + if skill.Content == "" || !containsAll(skill.Content, "处理转人工和升级诉求", "graph/handoff_to_human") { + t.Fatalf("unexpected skill content: %q", skill.Content) + } +} + +func TestHasVisibleSkills(t *testing.T) { + setupSkillBackendTestDB(t) + createSkillDefinitionForTest(t, models.SkillDefinition{ + ID: 3, + Code: "enabled_skill", + Name: "启用技能", + Description: "可见", + Instruction: "noop", + Status: enums.StatusOk, + }) + createSkillDefinitionForTest(t, models.SkillDefinition{ + ID: 4, + Code: "deleted_skill", + Name: "删除技能", + Description: "不可见", + Instruction: "noop", + Status: enums.StatusDeleted, + }) + if !HasVisibleSkills(models.AIAgent{SkillIDs: "3,4"}) { + t.Fatalf("expected visible skills") + } + if HasVisibleSkills(models.AIAgent{SkillIDs: "4"}) { + t.Fatalf("expected no visible skills") + } +} + +func setupSkillBackendTestDB(t *testing.T) { + t.Helper() + db, err := gorm.Open(sqlite.Open("file:skill_backend_test?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite failed: %v", err) + } + if err := db.AutoMigrate(&models.SkillDefinition{}); err != nil { + t.Fatalf("auto migrate skill definition failed: %v", err) + } + if err := db.Exec("DELETE FROM skill_definitions").Error; err != nil { + t.Fatalf("cleanup skill definitions failed: %v", err) + } + sqls.SetDB(db) +} + +func createSkillDefinitionForTest(t *testing.T, item models.SkillDefinition) { + t.Helper() + now := time.Now() + if item.CreatedAt.IsZero() { + item.CreatedAt = now + } + if item.UpdatedAt.IsZero() { + item.UpdatedAt = now + } + if err := sqls.DB().Create(&item).Error; err != nil { + t.Fatalf("create skill definition failed: %v", err) + } +} + +func containsAll(text string, items ...string) bool { + for _, item := range items { + if item != "" && !strings.Contains(text, item) { + return false + } + } + return true +} diff --git a/internal/ai/runtime/internal/impl/factory/skill_middleware_service.go b/internal/ai/runtime/internal/impl/factory/skill_middleware_service.go index b7c8255..1cc9266 100644 --- a/internal/ai/runtime/internal/impl/factory/skill_middleware_service.go +++ b/internal/ai/runtime/internal/impl/factory/skill_middleware_service.go @@ -19,10 +19,10 @@ func NewSkillMiddlewareService() *SkillMiddlewareService { func (s *SkillMiddlewareService) Build( ctx context.Context, - selectedSkill *models.SkillDefinition, + aiAgent models.AIAgent, toolDefinitions []runtimetooling.MCPToolDefinition, ) (adk.ChatModelAgentMiddleware, error) { - backend, err := newSelectedSkillBackend(selectedSkill, toolDefinitions) + backend, err := newDatabaseSkillBackend(aiAgent, toolDefinitions) if err != nil { return nil, err } diff --git a/internal/ai/runtime/internal/impl/factory/tool_helpers.go b/internal/ai/runtime/internal/impl/factory/tool_helpers.go index f04bb3c..083f867 100644 --- a/internal/ai/runtime/internal/impl/factory/tool_helpers.go +++ b/internal/ai/runtime/internal/impl/factory/tool_helpers.go @@ -7,7 +7,6 @@ import ( einocallbacks "cs-agent/internal/ai/runtime/internal/impl/callbacks" "cs-agent/internal/ai/runtime/registry" runtimetooling "cs-agent/internal/ai/runtime/tooling" - "cs-agent/internal/models" "cs-agent/internal/pkg/enums" "cs-agent/internal/pkg/toolx" ) @@ -24,7 +23,7 @@ func buildInstructionTraceSummary(summary runtimeinstruction.AssemblySummary) ei func buildRuntimeTraceToolMetadata( dynamicToolDefinitions []runtimetooling.MCPToolDefinition, staticToolMetadata map[string]registry.ToolMetadata, - selectedSkill *models.SkillDefinition, + includeSkillTool bool, ) map[string]einocallbacks.ToolMetadata { ret := make(map[string]einocallbacks.ToolMetadata, len(dynamicToolDefinitions)+len(staticToolMetadata)+1) for _, item := range dynamicToolDefinitions { @@ -54,7 +53,7 @@ func buildRuntimeTraceToolMetadata( SourceType: metadata.SourceType, } } - if selectedSkill != nil { + if includeSkillTool { resolved := toolx.ResolveToolMetadata(toolx.BuiltinSkill.Code, toolx.BuiltinSkill.Name) ret[toolx.BuiltinSkill.Name] = einocallbacks.ToolMetadata{ ToolCode: resolved.ToolCode, diff --git a/internal/pkg/toolx/builtin_tools.go b/internal/pkg/toolx/builtin_tools.go index 99f45d3..a037ad6 100644 --- a/internal/pkg/toolx/builtin_tools.go +++ b/internal/pkg/toolx/builtin_tools.go @@ -49,7 +49,7 @@ var ( ServerCode: "builtin", Name: "skill", Title: "加载专项技能说明", - Description: "用于加载当前命中的专项技能说明文档。仅在本轮已命中 Skill 时可用,适合将专项处理规则按需注入上下文。", + Description: "用于按需加载当前 Agent 可用的专项技能说明文档,适合在需要专项处理规则时再注入上下文。", SourceType: enums.ToolSourceTypeBuiltin, AutoInjected: true, } diff --git a/internal/services/skill_definition_service.go b/internal/services/skill_definition_service.go index 2cebc96..f6460c6 100644 --- a/internal/services/skill_definition_service.go +++ b/internal/services/skill_definition_service.go @@ -79,6 +79,10 @@ func (s *skillDefinitionService) GetByCode(code string) *models.SkillDefinition return repositories.SkillDefinitionRepository.GetByCode(sqls.DB(), code) } +func (s *skillDefinitionService) GetByIDs(ids []int64) map[int64]models.SkillDefinition { + return repositories.SkillDefinitionRepository.GetByIDs(sqls.DB(), ids) +} + func (s *skillDefinitionService) CreateSkillDefinition(req request.CreateSkillDefinitionRequest, operator *dto.AuthPrincipal) (*models.SkillDefinition, error) { if operator == nil { return nil, errorsx.Unauthorized("未登录或登录已过期")