feat: implement runtime service with tool catalog and skill selection logic
This commit is contained in:
@@ -0,0 +1,89 @@
|
|||||||
|
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}
|
||||||
|
}
|
||||||
|
|
||||||
|
type prepareService struct {
|
||||||
|
catalog *toolCatalog
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *prepareService) selectSkill(ctx context.Context, req Request) (*models.SkillDefinition, string, string, error) {
|
||||||
|
if req.AIAgent == nil || req.AIConfig == nil || req.UserMessage == nil || req.Conversation == nil {
|
||||||
|
return nil, "", "", nil
|
||||||
|
}
|
||||||
|
result, err := skills.Select(ctx, skills.RuntimeContext{
|
||||||
|
AIAgentID: req.AIAgent.ID,
|
||||||
|
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
|
||||||
|
}
|
||||||
|
toolSet, err := s.catalog.resolveForRun(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if toolSet != nil {
|
||||||
|
req.ToolSet = toolSet
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *prepareService) prepareToolsForResume(req *ResumeRequest) error {
|
||||||
|
if req == nil || req.ToolSet != nil || s.catalog == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
toolSet, err := s.catalog.resolveForResume(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if toolSet != nil {
|
||||||
|
req.ToolSet = toolSet
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -3,29 +3,80 @@ package runtime
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
runtimeapp "cs-agent/internal/ai/runtime/app"
|
runtimeexecutor "cs-agent/internal/ai/runtime/executor"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
app *runtimeapp.Service
|
runtime *runtimeexecutor.Service
|
||||||
|
catalog *toolCatalog
|
||||||
|
prepare *prepareService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewService() *Service {
|
func NewService() *Service {
|
||||||
|
catalog := newToolCatalog()
|
||||||
return &Service{
|
return &Service{
|
||||||
app: runtimeapp.NewService(),
|
runtime: runtimeexecutor.NewService(),
|
||||||
|
catalog: catalog,
|
||||||
|
prepare: newPrepareService(catalog),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) Run(ctx context.Context, req Request) (*Summary, error) {
|
func (s *Service) Run(ctx context.Context, req Request) (*Summary, error) {
|
||||||
if s == nil || s.app == nil {
|
if s == nil || s.runtime == nil || s.prepare == nil {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
return s.app.Run(ctx, req)
|
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, runtimeexecutor.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,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
ret := toSummary(summary)
|
||||||
|
if ret != nil && skillErr != nil && ret.PlanReason == "" {
|
||||||
|
ret.PlanReason = "skill_failed_fallback_runtime"
|
||||||
|
}
|
||||||
|
return ret, err
|
||||||
|
}
|
||||||
|
ret := toSummary(summary)
|
||||||
|
if ret != nil && skillErr != nil && ret.PlanReason == "" {
|
||||||
|
ret.PlanReason = "skill_failed_fallback_runtime"
|
||||||
|
}
|
||||||
|
return ret, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) Resume(ctx context.Context, req ResumeRequest) (*Summary, error) {
|
func (s *Service) Resume(ctx context.Context, req ResumeRequest) (*Summary, error) {
|
||||||
if s == nil || s.app == nil {
|
if s == nil || s.runtime == nil || s.prepare == nil {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
return s.app.Resume(ctx, req)
|
if err := s.prepare.prepareToolsForResume(&req); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
summary, err := s.runtime.ExecuteResume(ctx, runtimeexecutor.ResumeInput{
|
||||||
|
Conversation: req.Conversation,
|
||||||
|
AIAgent: req.AIAgent,
|
||||||
|
AIConfig: req.AIConfig,
|
||||||
|
CheckPointID: req.CheckPointID,
|
||||||
|
ResumeData: req.ResumeData,
|
||||||
|
ToolSet: req.ToolSet,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return toSummary(summary), err
|
||||||
|
}
|
||||||
|
return toSummary(summary), nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package runtime
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
runtimeexecutor "cs-agent/internal/ai/runtime/executor"
|
||||||
|
)
|
||||||
|
|
||||||
|
func toSummary(summary *runtimeexecutor.RunResult) *Summary {
|
||||||
|
if summary == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ret := &Summary{
|
||||||
|
RunID: summary.RunID,
|
||||||
|
Status: summary.Status,
|
||||||
|
ReplyText: summary.ReplyText,
|
||||||
|
PlannedSkillCode: strings.TrimSpace(summary.SelectedSkillCode),
|
||||||
|
PlannedSkillName: strings.TrimSpace(summary.SelectedSkillName),
|
||||||
|
PlanReason: strings.TrimSpace(summary.SkillRouteReason),
|
||||||
|
SkillRouteTrace: strings.TrimSpace(summary.SkillRouteTrace),
|
||||||
|
SkillAllowedToolCodes: append([]string(nil), summary.SkillAllowedToolCodes...),
|
||||||
|
ModelName: summary.ModelName,
|
||||||
|
PromptTokens: summary.PromptTokens,
|
||||||
|
CompletionTokens: summary.CompletionTokens,
|
||||||
|
HistoryMessageCount: summary.HistoryMessageCount,
|
||||||
|
RetrieverCount: summary.RetrieverCount,
|
||||||
|
ToolCallCount: summary.ToolCallCount,
|
||||||
|
ToolCodes: append([]string(nil), summary.ToolCodes...),
|
||||||
|
InvokedToolCodes: append([]string(nil), summary.InvokedToolCodes...),
|
||||||
|
CheckPointID: summary.CheckPointID,
|
||||||
|
Interrupted: summary.Interrupted,
|
||||||
|
TraceData: summary.TraceData,
|
||||||
|
ErrorMessage: summary.ErrorMessage,
|
||||||
|
}
|
||||||
|
if len(summary.Interrupts) > 0 {
|
||||||
|
ret.Interrupts = make([]InterruptContextSummary, 0, len(summary.Interrupts))
|
||||||
|
for _, item := range summary.Interrupts {
|
||||||
|
ret.Interrupts = append(ret.Interrupts, InterruptContextSummary{
|
||||||
|
Type: item.Type,
|
||||||
|
ID: item.ID,
|
||||||
|
InfoPreview: item.InfoPreview,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ret
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package runtime
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"cs-agent/internal/ai/runtime/registry"
|
||||||
|
"cs-agent/internal/ai/runtime/tools"
|
||||||
|
"cs-agent/internal/models"
|
||||||
|
"cs-agent/internal/pkg/toolx"
|
||||||
|
)
|
||||||
|
|
||||||
|
type toolCatalog struct {
|
||||||
|
registry *registry.Registry
|
||||||
|
}
|
||||||
|
|
||||||
|
func newToolCatalog() *toolCatalog {
|
||||||
|
return &toolCatalog{
|
||||||
|
registry: registry.NewRegistry(
|
||||||
|
tools.NewTriageServiceRequestTool(),
|
||||||
|
tools.NewAnalyzeConversationTool(),
|
||||||
|
tools.NewPrepareTicketDraftTool(),
|
||||||
|
tools.NewCreateTicketGraphTool(),
|
||||||
|
tools.NewHandoffGraphTool(),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *toolCatalog) resolveForRun(req *Request) (*registry.ToolSet, error) {
|
||||||
|
if req == nil || req.ToolSet != nil || c == nil || c.registry == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return c.registry.Resolve(registry.Context{
|
||||||
|
Conversation: req.Conversation,
|
||||||
|
AIAgent: req.AIAgent,
|
||||||
|
AIConfig: req.AIConfig,
|
||||||
|
UserMessage: req.UserMessage,
|
||||||
|
AllowedToolCodes: c.resolveAllowedToolCodes(req.AIAgent, req.SelectedSkill),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *toolCatalog) resolveForResume(req *ResumeRequest) (*registry.ToolSet, error) {
|
||||||
|
if req == nil || req.ToolSet != nil || c == nil || c.registry == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return c.registry.Resolve(registry.Context{
|
||||||
|
Conversation: req.Conversation,
|
||||||
|
AIAgent: req.AIAgent,
|
||||||
|
AIConfig: req.AIConfig,
|
||||||
|
AllowedToolCodes: c.parseAgentAllowedToolCodes(req.AIAgent),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *toolCatalog) parseSkillAllowedToolCodes(skill *models.SkillDefinition) []string {
|
||||||
|
if skill == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
raw := strings.TrimSpace(skill.ToolWhitelist)
|
||||||
|
if raw == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var items []string
|
||||||
|
if err := json.Unmarshal([]byte(raw), &items); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return normalizeAllowedToolCodes(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *toolCatalog) parseAgentAllowedToolCodes(aiAgent *models.AIAgent) []string {
|
||||||
|
if aiAgent == nil || strings.TrimSpace(aiAgent.AllowedMCPTools) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
items, err := toolx.ParseAgentMCPToolsJSON(aiAgent.AllowedMCPTools)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ret := make([]string, 0, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
ret = append(ret, item.ToolCode)
|
||||||
|
}
|
||||||
|
return normalizeAllowedToolCodes(ret)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *toolCatalog) resolveAllowedToolCodes(aiAgent *models.AIAgent, skill *models.SkillDefinition) []string {
|
||||||
|
agentAllowed := c.parseAgentAllowedToolCodes(aiAgent)
|
||||||
|
skillAllowed := c.parseSkillAllowedToolCodes(skill)
|
||||||
|
switch {
|
||||||
|
case len(agentAllowed) == 0:
|
||||||
|
return skillAllowed
|
||||||
|
case len(skillAllowed) == 0:
|
||||||
|
return agentAllowed
|
||||||
|
default:
|
||||||
|
skillSet := make(map[string]struct{}, len(skillAllowed))
|
||||||
|
for _, item := range skillAllowed {
|
||||||
|
skillSet[item] = struct{}{}
|
||||||
|
}
|
||||||
|
ret := make([]string, 0, len(agentAllowed))
|
||||||
|
for _, item := range agentAllowed {
|
||||||
|
if _, ok := skillSet[item]; ok {
|
||||||
|
ret = append(ret, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeAllowedToolCodes(items []string) []string {
|
||||||
|
if len(items) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ret := make([]string, 0, len(items))
|
||||||
|
seen := make(map[string]struct{}, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
item = toolx.NormalizeToolCodeAlias(strings.TrimSpace(item))
|
||||||
|
if item == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[item]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[item] = struct{}{}
|
||||||
|
ret = append(ret, item)
|
||||||
|
}
|
||||||
|
return ret
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package runtime
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"cs-agent/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNormalizeAllowedToolCodes(t *testing.T) {
|
||||||
|
ret := normalizeAllowedToolCodes([]string{
|
||||||
|
" ",
|
||||||
|
"graph/create_ticket_with_confirmation",
|
||||||
|
"builtin/create_ticket_with_confirmation",
|
||||||
|
"graph/handoff_to_human",
|
||||||
|
"graph/handoff_to_human",
|
||||||
|
})
|
||||||
|
if len(ret) != 2 {
|
||||||
|
t.Fatalf("expected 2 tool codes, got %d: %#v", len(ret), ret)
|
||||||
|
}
|
||||||
|
if ret[0] != "graph/create_ticket_with_confirmation" {
|
||||||
|
t.Fatalf("unexpected first tool code: %s", ret[0])
|
||||||
|
}
|
||||||
|
if ret[1] != "graph/handoff_to_human" {
|
||||||
|
t.Fatalf("unexpected second tool code: %s", ret[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToolCatalogResolveAllowedToolCodes(t *testing.T) {
|
||||||
|
catalog := newToolCatalog()
|
||||||
|
agent := &models.AIAgent{
|
||||||
|
AllowedMCPTools: `[{"toolCode":"graph/create_ticket_with_confirmation"},{"toolCode":"graph/handoff_to_human"}]`,
|
||||||
|
}
|
||||||
|
skill := &models.SkillDefinition{
|
||||||
|
ToolWhitelist: `["builtin/create_ticket_with_confirmation","graph/prepare_ticket_draft"]`,
|
||||||
|
}
|
||||||
|
ret := catalog.resolveAllowedToolCodes(agent, skill)
|
||||||
|
if len(ret) != 1 {
|
||||||
|
t.Fatalf("expected 1 tool code, got %d: %#v", len(ret), ret)
|
||||||
|
}
|
||||||
|
if ret[0] != "graph/create_ticket_with_confirmation" {
|
||||||
|
t.Fatalf("unexpected tool code: %s", ret[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToolCatalogResolveAllowedToolCodesFallsBackWhenSkillEmpty(t *testing.T) {
|
||||||
|
catalog := newToolCatalog()
|
||||||
|
agent := &models.AIAgent{
|
||||||
|
AllowedMCPTools: `[{"toolCode":"graph/create_ticket_with_confirmation"},{"toolCode":"graph/handoff_to_human"}]`,
|
||||||
|
}
|
||||||
|
ret := catalog.resolveAllowedToolCodes(agent, nil)
|
||||||
|
if len(ret) != 2 {
|
||||||
|
t.Fatalf("expected 2 tool codes, got %d: %#v", len(ret), ret)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package executor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
internalexecutor "cs-agent/internal/ai/runtime/internal/executor"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
inner *internalexecutor.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService() *Service {
|
||||||
|
return &Service{
|
||||||
|
inner: internalexecutor.NewService(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ExecuteRun(ctx context.Context, req RunInput) (*RunResult, error) {
|
||||||
|
if s == nil || s.inner == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return s.inner.ExecuteRun(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ExecuteResume(ctx context.Context, req ResumeInput) (*RunResult, error) {
|
||||||
|
if s == nil || s.inner == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return s.inner.ExecuteResume(ctx, req)
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package executor
|
||||||
|
|
||||||
|
import internalexecutor "cs-agent/internal/ai/runtime/internal/executor"
|
||||||
|
|
||||||
|
// TODO 为什么要定义类型别名?
|
||||||
|
type RunInput = internalexecutor.RunInput
|
||||||
|
type ResumeInput = internalexecutor.ResumeInput
|
||||||
|
type InterruptContextSummary = internalexecutor.InterruptContextSummary
|
||||||
|
type RunResult = internalexecutor.RunResult
|
||||||
Reference in New Issue
Block a user