Refactor skill handling and improve runtime trace capabilities

- Removed the selectSkill method from prepareService and adjusted related logic in the Run method of Service.
- Updated tool catalog to parse agent allowed tool codes directly.
- Simplified Request and RunInput structures by removing unnecessary fields.
- Enhanced the RuntimeTraceCollector to manage skill activation and visibility.
- Introduced a new databaseSkillBackend to manage skill definitions and their metadata.
- Added tests for skill backend functionalities to ensure correct behavior.
- Updated various factory methods to accommodate changes in skill handling.
- Improved documentation and descriptions for better clarity.
This commit is contained in:
mlogclub
2026-04-19 09:57:34 +08:00
parent ad83d1dfda
commit 42077c1aab
21 changed files with 469 additions and 176 deletions
+1 -1
Submodule docs updated: 9731debd6a...b42fb50bd9
@@ -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
}
+8 -26
View File
@@ -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) {
@@ -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),
})
}
+6 -10
View File
@@ -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 {
+5 -10
View File
@@ -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) {
+18 -17
View File
@@ -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...)
}
+6 -9
View File
@@ -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 {
@@ -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 ""
}
@@ -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) == "" {
@@ -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)
}
}
@@ -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
@@ -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 {
@@ -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,
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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,
+1 -1
View File
@@ -49,7 +49,7 @@ var (
ServerCode: "builtin",
Name: "skill",
Title: "加载专项技能说明",
Description: "用于加载当前命中的专项技能说明文档。仅在本轮已命中 Skill 时可用,适合将专项处理规则按需注入上下文。",
Description: "用于按需加载当前 Agent 可用的专项技能说明文档,适合在需要专项处理规则时再注入上下文。",
SourceType: enums.ToolSourceTypeBuiltin,
AutoInjected: true,
}
@@ -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("未登录或登录已过期")