diff --git a/internal/ai/application/runtime/prepare_service.go b/internal/ai/application/runtime/prepare_service.go new file mode 100644 index 0000000..07522c1 --- /dev/null +++ b/internal/ai/application/runtime/prepare_service.go @@ -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 +} diff --git a/internal/ai/application/runtime/service.go b/internal/ai/application/runtime/service.go index ed52e66..dad46e9 100644 --- a/internal/ai/application/runtime/service.go +++ b/internal/ai/application/runtime/service.go @@ -3,29 +3,80 @@ package runtime import ( "context" - runtimeapp "cs-agent/internal/ai/runtime/app" + runtimeexecutor "cs-agent/internal/ai/runtime/executor" ) type Service struct { - app *runtimeapp.Service + runtime *runtimeexecutor.Service + catalog *toolCatalog + prepare *prepareService } func NewService() *Service { + catalog := newToolCatalog() return &Service{ - app: runtimeapp.NewService(), + runtime: runtimeexecutor.NewService(), + catalog: catalog, + prepare: newPrepareService(catalog), } } 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 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) { - if s == nil || s.app == nil { + if s == nil || s.runtime == nil || s.prepare == 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 } diff --git a/internal/ai/application/runtime/summary_builder.go b/internal/ai/application/runtime/summary_builder.go new file mode 100644 index 0000000..8ce9003 --- /dev/null +++ b/internal/ai/application/runtime/summary_builder.go @@ -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 +} diff --git a/internal/ai/application/runtime/tool_catalog.go b/internal/ai/application/runtime/tool_catalog.go new file mode 100644 index 0000000..3557a26 --- /dev/null +++ b/internal/ai/application/runtime/tool_catalog.go @@ -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 +} diff --git a/internal/ai/application/runtime/tool_catalog_test.go b/internal/ai/application/runtime/tool_catalog_test.go new file mode 100644 index 0000000..4c5d913 --- /dev/null +++ b/internal/ai/application/runtime/tool_catalog_test.go @@ -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) + } +} diff --git a/internal/ai/runtime/executor/service.go b/internal/ai/runtime/executor/service.go new file mode 100644 index 0000000..d168387 --- /dev/null +++ b/internal/ai/runtime/executor/service.go @@ -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) +} diff --git a/internal/ai/runtime/executor/types.go b/internal/ai/runtime/executor/types.go new file mode 100644 index 0000000..2321543 --- /dev/null +++ b/internal/ai/runtime/executor/types.go @@ -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