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
@@ -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,