feat: add AI workflow editor
This commit is contained in:
@@ -24,6 +24,7 @@ func NewService() *Service {
|
||||
|
||||
func (s *Service) Run(ctx context.Context, req Request) (*Summary, error) {
|
||||
req.UserMessage.Content = utils.BuildRuntimeMessageText(req.UserMessage.MessageType, req.UserMessage.Content)
|
||||
req.AIAgent = applyWorkflowInstruction(req.AIAgent)
|
||||
toolSet, err := s.prepare.prepareToolsForRun(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -44,6 +45,7 @@ func (s *Service) Run(ctx context.Context, req Request) (*Summary, error) {
|
||||
}
|
||||
|
||||
func (s *Service) Resume(ctx context.Context, req ResumeRequest) (*Summary, error) {
|
||||
req.AIAgent = applyWorkflowInstruction(req.AIAgent)
|
||||
toolSet, err := s.prepare.prepareToolsForResume(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -82,6 +82,9 @@ func (c *toolCatalog) parseAgentAllowedToolCodes(aiAgent models.AIAgent) []strin
|
||||
ret = append(ret, graphTools...)
|
||||
}
|
||||
}
|
||||
if result, ok := resolveAgentWorkflow(aiAgent); ok {
|
||||
ret = append(ret, result.ToolCodes...)
|
||||
}
|
||||
return toolx.NormalizeToolCodes(ret)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"agent-desk/internal/ai/workflow/dsl"
|
||||
workflowregistry "agent-desk/internal/ai/workflow/registry"
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
"agent-desk/internal/pkg/toolx"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestNormalizeAllowedToolCodes(t *testing.T) {
|
||||
@@ -60,3 +69,91 @@ func TestBuildRuntimeStaticTools(t *testing.T) {
|
||||
t.Fatalf("expected %d runtime static tools, got %d", len(toolx.ListRuntimeStaticToolSpecs()), len(ret))
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolCatalogIncludesPublishedWorkflowGraphTools(t *testing.T) {
|
||||
setupWorkflowRuntimeTestDB(t)
|
||||
version := createWorkflowRuntimeTestVersion(t, dsl.Definition{
|
||||
SchemaVersion: 1,
|
||||
EntryNodeID: "start",
|
||||
Nodes: []dsl.Node{
|
||||
{ID: "start", Type: workflowregistry.NodeTypeStart, Name: "Start"},
|
||||
{ID: "draft", Type: workflowregistry.NodeTypePrepareTicketDraft, Name: "Draft Ticket"},
|
||||
{ID: "create", Type: workflowregistry.NodeTypeCreateTicket, Name: "Create Ticket"},
|
||||
{ID: "handoff", Type: workflowregistry.NodeTypeHandoffToHuman, Name: "Handoff"},
|
||||
},
|
||||
})
|
||||
|
||||
catalog := newToolCatalog()
|
||||
ret := catalog.parseAgentAllowedToolCodes(models.AIAgent{
|
||||
RuntimeMode: enums.AIAgentRuntimeModeWorkflow,
|
||||
WorkflowVersionID: version.ID,
|
||||
})
|
||||
|
||||
assertContainsToolCode(t, ret, toolx.GraphPrepareTicketDraft.Code)
|
||||
assertContainsToolCode(t, ret, toolx.GraphCreateTicketConfirm.Code)
|
||||
assertContainsToolCode(t, ret, toolx.GraphHandoffConversation.Code)
|
||||
}
|
||||
|
||||
func TestApplyWorkflowInstructionAppendsPublishedWorkflow(t *testing.T) {
|
||||
setupWorkflowRuntimeTestDB(t)
|
||||
version := createWorkflowRuntimeTestVersion(t, dsl.Definition{
|
||||
SchemaVersion: 1,
|
||||
EntryNodeID: "start",
|
||||
Nodes: []dsl.Node{
|
||||
{ID: "start", Type: workflowregistry.NodeTypeStart, Name: "Start"},
|
||||
{ID: "handoff", Type: workflowregistry.NodeTypeHandoffToHuman, Name: "Handoff"},
|
||||
},
|
||||
})
|
||||
|
||||
agent := applyWorkflowInstruction(models.AIAgent{
|
||||
SystemPrompt: "Base prompt.",
|
||||
RuntimeMode: enums.AIAgentRuntimeModeWorkflow,
|
||||
WorkflowVersionID: version.ID,
|
||||
})
|
||||
if agent.SystemPrompt == "Base prompt." {
|
||||
t.Fatalf("expected workflow appendix to be appended")
|
||||
}
|
||||
if !strings.Contains(agent.SystemPrompt, "Published customer-service workflow") {
|
||||
t.Fatalf("missing workflow appendix: %s", agent.SystemPrompt)
|
||||
}
|
||||
}
|
||||
|
||||
func setupWorkflowRuntimeTestDB(t *testing.T) {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&models.AIWorkflowVersion{}); err != nil {
|
||||
t.Fatalf("auto migrate: %v", err)
|
||||
}
|
||||
sqls.SetDB(db)
|
||||
}
|
||||
|
||||
func createWorkflowRuntimeTestVersion(t *testing.T, def dsl.Definition) *models.AIWorkflowVersion {
|
||||
t.Helper()
|
||||
definition, err := json.Marshal(def)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal definition: %v", err)
|
||||
}
|
||||
version := &models.AIWorkflowVersion{
|
||||
WorkflowID: 1,
|
||||
Version: 1,
|
||||
Status: enums.StatusOk,
|
||||
Definition: string(definition),
|
||||
}
|
||||
if err := sqls.DB().Create(version).Error; err != nil {
|
||||
t.Fatalf("create workflow version: %v", err)
|
||||
}
|
||||
return version
|
||||
}
|
||||
|
||||
func assertContainsToolCode(t *testing.T, items []string, want string) {
|
||||
t.Helper()
|
||||
for _, item := range items {
|
||||
if item == want {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("expected tool code %s in %#v", want, items)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"agent-desk/internal/ai/workflow/compiler"
|
||||
"agent-desk/internal/ai/workflow/dsl"
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
"agent-desk/internal/repositories"
|
||||
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
)
|
||||
|
||||
func resolveAgentWorkflow(aiAgent models.AIAgent) (compiler.Result, bool) {
|
||||
if aiAgent.RuntimeMode != enums.AIAgentRuntimeModeWorkflow || aiAgent.WorkflowVersionID <= 0 {
|
||||
return compiler.Result{}, false
|
||||
}
|
||||
version := repositories.AIWorkflowVersionRepository.Get(sqls.DB(), aiAgent.WorkflowVersionID)
|
||||
if version == nil || version.Status != enums.StatusOk {
|
||||
return compiler.Result{}, false
|
||||
}
|
||||
var def dsl.Definition
|
||||
if err := json.Unmarshal([]byte(version.Definition), &def); err != nil {
|
||||
return compiler.Result{}, false
|
||||
}
|
||||
return compiler.Compile(def), true
|
||||
}
|
||||
|
||||
func applyWorkflowInstruction(aiAgent models.AIAgent) models.AIAgent {
|
||||
result, ok := resolveAgentWorkflow(aiAgent)
|
||||
if !ok || strings.TrimSpace(result.Appendix) == "" {
|
||||
return aiAgent
|
||||
}
|
||||
prompt := strings.TrimSpace(aiAgent.SystemPrompt)
|
||||
appendix := strings.TrimSpace(result.Appendix)
|
||||
if prompt == "" {
|
||||
aiAgent.SystemPrompt = appendix
|
||||
return aiAgent
|
||||
}
|
||||
aiAgent.SystemPrompt = prompt + "\n\n" + appendix
|
||||
return aiAgent
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package compiler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"agent-desk/internal/ai/workflow/dsl"
|
||||
workflowregistry "agent-desk/internal/ai/workflow/registry"
|
||||
"agent-desk/internal/pkg/toolx"
|
||||
)
|
||||
|
||||
type Result struct {
|
||||
ToolCodes []string
|
||||
Appendix string
|
||||
}
|
||||
|
||||
func Compile(def dsl.Definition) Result {
|
||||
toolCodes := make([]string, 0)
|
||||
lines := make([]string, 0, len(def.Nodes)+2)
|
||||
if strings.TrimSpace(def.EntryNodeID) != "" {
|
||||
lines = append(lines, fmt.Sprintf("Workflow entry node: %s.", strings.TrimSpace(def.EntryNodeID)))
|
||||
}
|
||||
for _, node := range def.Nodes {
|
||||
nodeType := strings.TrimSpace(node.Type)
|
||||
if code := graphToolCodeForNodeType(nodeType); code != "" {
|
||||
toolCodes = append(toolCodes, code)
|
||||
}
|
||||
nodeName := strings.TrimSpace(node.Name)
|
||||
if nodeName == "" {
|
||||
nodeName = strings.TrimSpace(node.ID)
|
||||
}
|
||||
if nodeName == "" {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("- %s: %s", nodeName, nodeType))
|
||||
}
|
||||
appendix := ""
|
||||
if len(lines) > 0 {
|
||||
appendix = "Published customer-service workflow:\n" + strings.Join(lines, "\n")
|
||||
}
|
||||
return Result{
|
||||
ToolCodes: toolx.NormalizeToolCodes(toolCodes),
|
||||
Appendix: appendix,
|
||||
}
|
||||
}
|
||||
|
||||
func graphToolCodeForNodeType(nodeType string) string {
|
||||
switch strings.TrimSpace(nodeType) {
|
||||
case workflowregistry.NodeTypeAnalyzeConversation:
|
||||
return toolx.GraphAnalyzeConversation.Code
|
||||
case workflowregistry.NodeTypePrepareTicketDraft:
|
||||
return toolx.GraphPrepareTicketDraft.Code
|
||||
case workflowregistry.NodeTypeCreateTicket:
|
||||
return toolx.GraphCreateTicketConfirm.Code
|
||||
case workflowregistry.NodeTypeHandoffToHuman:
|
||||
return toolx.GraphHandoffConversation.Code
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package compiler
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"agent-desk/internal/ai/workflow/dsl"
|
||||
workflowregistry "agent-desk/internal/ai/workflow/registry"
|
||||
"agent-desk/internal/pkg/toolx"
|
||||
)
|
||||
|
||||
func TestCompileMapsWorkflowNodesToGraphTools(t *testing.T) {
|
||||
result := Compile(dsl.Definition{
|
||||
EntryNodeID: "start",
|
||||
Nodes: []dsl.Node{
|
||||
{ID: "start", Type: workflowregistry.NodeTypeStart, Name: "Start"},
|
||||
{ID: "analyze", Type: workflowregistry.NodeTypeAnalyzeConversation, Name: "Analyze"},
|
||||
{ID: "draft", Type: workflowregistry.NodeTypePrepareTicketDraft, Name: "Draft"},
|
||||
{ID: "create", Type: workflowregistry.NodeTypeCreateTicket, Name: "Create"},
|
||||
{ID: "handoff", Type: workflowregistry.NodeTypeHandoffToHuman, Name: "Handoff"},
|
||||
},
|
||||
})
|
||||
want := []string{
|
||||
toolx.GraphAnalyzeConversation.Code,
|
||||
toolx.GraphPrepareTicketDraft.Code,
|
||||
toolx.GraphCreateTicketConfirm.Code,
|
||||
toolx.GraphHandoffConversation.Code,
|
||||
}
|
||||
if len(result.ToolCodes) != len(want) {
|
||||
t.Fatalf("expected %d tool codes, got %d: %#v", len(want), len(result.ToolCodes), result.ToolCodes)
|
||||
}
|
||||
for i, item := range want {
|
||||
if result.ToolCodes[i] != item {
|
||||
t.Fatalf("tool code[%d] = %s, want %s", i, result.ToolCodes[i], item)
|
||||
}
|
||||
}
|
||||
if result.Appendix == "" {
|
||||
t.Fatalf("expected workflow appendix")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user