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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user