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:
+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
|
||||
}
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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("未登录或登录已过期")
|
||||
|
||||
Reference in New Issue
Block a user