feat: Enhance AI Agent and Channel Management
- Updated labels in the AI Agents dashboard for clarity, changing "流程状态" to "Playbook 状态" and "未发布流程" to "未发布 Playbook". - Introduced AI Agent rollout percentage management in channel editing, allowing users to set and rollback rollout percentages. - Added new API endpoints for rolling back AI Agent rollout and fetching agent run metrics. - Implemented new UI components for displaying agent run details, including status, duration, and input/output tokens. - Enhanced type definitions for AdminChannel and AIAgent to include rollout percentages and runtime modes. - Updated navigation to include a section for agent runs. - Added new translations for agent run features in both English and Chinese.
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"agent-desk/internal/pkg/enums"
|
||||
"agent-desk/internal/pkg/errorsx"
|
||||
svc "agent-desk/internal/services"
|
||||
)
|
||||
|
||||
// ApplicationRunInput identifies the persisted inputs for an Agent reply.
|
||||
// Loading these records here keeps channels and debug adapters independent of
|
||||
// individual engine requirements.
|
||||
type ApplicationRunInput struct {
|
||||
ConversationID int64
|
||||
MessageID int64
|
||||
AIAgentID int64
|
||||
}
|
||||
|
||||
type ApplicationResumeInput struct {
|
||||
ApplicationRunInput
|
||||
CheckPointID string
|
||||
ResumeData map[string]string
|
||||
}
|
||||
|
||||
// AgentApplicationService is the single application boundary before engine
|
||||
// dispatch. It owns persisted input loading and relationship validation; the
|
||||
// selected Engine remains responsible only for runtime execution.
|
||||
type AgentApplicationService struct {
|
||||
runtime *Service
|
||||
}
|
||||
|
||||
var DefaultAgentApplicationService = NewAgentApplicationService()
|
||||
|
||||
func NewAgentApplicationService() *AgentApplicationService {
|
||||
return &AgentApplicationService{runtime: NewService()}
|
||||
}
|
||||
|
||||
func (s *AgentApplicationService) Run(ctx context.Context, input ApplicationRunInput) (*RunResult, error) {
|
||||
req, err := s.loadRequest(input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.RunPrepared(ctx, req)
|
||||
}
|
||||
|
||||
// RunPrepared is for isolated adapters such as the dashboard debug session.
|
||||
// Callers are responsible for constructing an ephemeral or already-validated
|
||||
// request; no persistence side effects are introduced by this boundary.
|
||||
func (s *AgentApplicationService) RunPrepared(ctx context.Context, req RunInput) (*RunResult, error) {
|
||||
return s.runtime.Run(ctx, req)
|
||||
}
|
||||
|
||||
func (s *AgentApplicationService) Resume(ctx context.Context, input ApplicationResumeInput) (*RunResult, error) {
|
||||
req, err := s.loadRequest(input.ApplicationRunInput)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
checkPointID := strings.TrimSpace(input.CheckPointID)
|
||||
interrupt := svc.ConversationInterruptService.GetByCheckPointID(checkPointID)
|
||||
if interrupt == nil || interrupt.ConversationID != req.Conversation.ID {
|
||||
return nil, errorsx.InvalidParam("pending conversation interrupt does not exist")
|
||||
}
|
||||
if interrupt.AIAgentID > 0 && interrupt.AIAgentID != req.AIAgent.ID {
|
||||
return nil, errorsx.InvalidParam("interrupt does not belong to agent")
|
||||
}
|
||||
return s.ResumePrepared(ctx, ResumeInput{
|
||||
Conversation: req.Conversation,
|
||||
UserMessage: req.UserMessage,
|
||||
AIAgent: req.AIAgent,
|
||||
AIConfig: req.AIConfig,
|
||||
CheckPointID: checkPointID,
|
||||
ResumeData: input.ResumeData,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *AgentApplicationService) ResumePrepared(ctx context.Context, req ResumeInput) (*RunResult, error) {
|
||||
return s.runtime.Resume(ctx, req)
|
||||
}
|
||||
|
||||
func (s *AgentApplicationService) loadRequest(input ApplicationRunInput) (RunInput, error) {
|
||||
if input.ConversationID <= 0 || input.MessageID <= 0 || input.AIAgentID <= 0 {
|
||||
return RunInput{}, errorsx.InvalidParam("conversation, message and agent are required")
|
||||
}
|
||||
conversation := svc.ConversationService.Get(input.ConversationID)
|
||||
if conversation == nil {
|
||||
return RunInput{}, errorsx.InvalidParam("conversation does not exist")
|
||||
}
|
||||
message := svc.MessageService.Get(input.MessageID)
|
||||
if message == nil || message.ConversationID != conversation.ID {
|
||||
return RunInput{}, errorsx.InvalidParam("message does not belong to conversation")
|
||||
}
|
||||
agent := svc.AIAgentService.Get(input.AIAgentID)
|
||||
if agent == nil || agent.Status != enums.StatusOk {
|
||||
return RunInput{}, errorsx.InvalidParam("ai agent is unavailable")
|
||||
}
|
||||
if conversation.AIAgentID > 0 && conversation.AIAgentID != agent.ID {
|
||||
return RunInput{}, errorsx.InvalidParam("agent does not belong to conversation")
|
||||
}
|
||||
config := svc.AIConfigService.Get(agent.AIConfigID)
|
||||
if config == nil || config.Status != enums.StatusOk {
|
||||
return RunInput{}, errorsx.InvalidParam("ai config is unavailable")
|
||||
}
|
||||
return RunInput{Conversation: *conversation, UserMessage: *message, AIAgent: *agent, AIConfig: *config}, nil
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestAgentApplicationServiceLoadsConsistentPersistedRequest(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&models.AIConfig{}, &models.AIAgent{}, &models.Conversation{}, &models.Message{}); err != nil {
|
||||
t.Fatalf("auto migrate: %v", err)
|
||||
}
|
||||
sqls.SetDB(db)
|
||||
config := &models.AIConfig{Status: enums.StatusOk, ModelName: "test-model"}
|
||||
if err := db.Create(config).Error; err != nil {
|
||||
t.Fatalf("create config: %v", err)
|
||||
}
|
||||
agent := &models.AIAgent{Name: "agent", Status: enums.StatusOk, AIConfigID: config.ID}
|
||||
if err := db.Create(agent).Error; err != nil {
|
||||
t.Fatalf("create agent: %v", err)
|
||||
}
|
||||
conversation := &models.Conversation{AIAgentID: agent.ID}
|
||||
if err := db.Create(conversation).Error; err != nil {
|
||||
t.Fatalf("create conversation: %v", err)
|
||||
}
|
||||
message := &models.Message{ConversationID: conversation.ID, SenderType: enums.IMSenderTypeCustomer, MessageType: enums.IMMessageTypeText, Content: "hello"}
|
||||
if err := db.Create(message).Error; err != nil {
|
||||
t.Fatalf("create message: %v", err)
|
||||
}
|
||||
req, err := NewAgentApplicationService().loadRequest(ApplicationRunInput{ConversationID: conversation.ID, MessageID: message.ID, AIAgentID: agent.ID})
|
||||
if err != nil {
|
||||
t.Fatalf("loadRequest: %v", err)
|
||||
}
|
||||
if req.Conversation.ID != conversation.ID || req.UserMessage.ID != message.ID || req.AIAgent.ID != agent.ID || req.AIConfig.ID != config.ID {
|
||||
t.Fatalf("unexpected request: %#v", req)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentApplicationServiceRejectsMismatchedMessage(t *testing.T) {
|
||||
service := NewAgentApplicationService()
|
||||
if _, err := service.loadRequest(ApplicationRunInput{ConversationID: 1, MessageID: 0, AIAgentID: 1}); err == nil {
|
||||
t.Fatal("expected invalid identifiers error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,688 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
ai "agent-desk/internal/ai"
|
||||
"agent-desk/internal/ai/runtime/instruction"
|
||||
"agent-desk/internal/ai/runtime/readtools"
|
||||
"agent-desk/internal/ai/runtime/retrievers"
|
||||
runtimetooling "agent-desk/internal/ai/runtime/tooling"
|
||||
"agent-desk/internal/ai/skills"
|
||||
aitooling "agent-desk/internal/ai/tooling"
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
"agent-desk/internal/pkg/errorsx"
|
||||
"agent-desk/internal/pkg/toolx"
|
||||
"agent-desk/internal/pkg/utils"
|
||||
svc "agent-desk/internal/services"
|
||||
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
)
|
||||
|
||||
// AutonomousEngine is the low-risk, no-flow runtime. It uses bounded model
|
||||
// turns and exposes configured MCP tools only through the shared Tool Registry.
|
||||
type AutonomousEngine struct {
|
||||
chat func(context.Context, models.AIConfig, string, string) (*ai.ChatCompletionResult, error)
|
||||
history func(int64, int) []models.Message
|
||||
retrieve func(context.Context, models.AIAgent, string) (string, int, error)
|
||||
skillSelect func(context.Context, skills.RuntimeContext) (*skills.ExecutionResult, error)
|
||||
toolChat func(context.Context, models.AIConfig, string, string, []ai.ToolDefinition, int, ai.ToolCallExecutor) (*ai.ToolLoopResult, error)
|
||||
}
|
||||
|
||||
func NewAutonomousEngine() *AutonomousEngine {
|
||||
return &AutonomousEngine{
|
||||
chat: ai.LLM.ChatWithConfig,
|
||||
history: func(conversationID int64, limit int) []models.Message {
|
||||
items, _, _ := svc.MessageService.FindByConversationIDCursor(conversationID, 0, limit, "", "")
|
||||
return items
|
||||
},
|
||||
retrieve: retrieveAutonomousKnowledge,
|
||||
skillSelect: skills.RuntimeService.Select,
|
||||
toolChat: ai.LLM.ChatWithTools,
|
||||
}
|
||||
}
|
||||
|
||||
func newAutonomousEngineWithChat(chat func(context.Context, models.AIConfig, string, string) (*ai.ChatCompletionResult, error)) *AutonomousEngine {
|
||||
return &AutonomousEngine{chat: chat}
|
||||
}
|
||||
|
||||
func (e *AutonomousEngine) Code() string {
|
||||
return EngineCodeAutonomous
|
||||
}
|
||||
|
||||
func (e *AutonomousEngine) Run(ctx context.Context, req RunInput) (*RunResult, error) {
|
||||
startedAt := time.Now()
|
||||
req.UserMessage.Content = utils.BuildRuntimeMessageText(req.UserMessage.MessageType, req.UserMessage.Content)
|
||||
snapshot, err := svc.AgentRevisionService.ResolvePublishedSnapshot(req.AIAgent, req.AIConfig)
|
||||
if err != nil {
|
||||
_, _ = writeAutonomousRun(req, startedAt, nil, "", 0, 0, nil, autonomousSkillContext{}, autonomousResponsePolicy{}, nil, err)
|
||||
return nil, err
|
||||
}
|
||||
req.AIAgent = snapshot.Agent
|
||||
req.AIConfig = snapshot.AIConfig
|
||||
skillContext := e.selectSkill(ctx, req)
|
||||
knowledgeContext, retrieverCount, retrieveErr := e.retrieveKnowledge(ctx, req.AIAgent, req.UserMessage.Content)
|
||||
responsePolicy := evaluateAutonomousResponsePolicy(req.AIAgent, knowledgeContext, retrieveErr)
|
||||
systemPrompt := buildAutonomousSystemPrompt(req.AIAgent, len(utils.SplitInt64s(req.AIAgent.KnowledgeIDs)) > 0, knowledgeContext, retrieveErr)
|
||||
if skillInstruction := strings.TrimSpace(instruction.BuildSkillDocument(skillContext.Skill, nil)); skillInstruction != "" {
|
||||
systemPrompt += "\n\nSkill instructions:\n" + skillInstruction
|
||||
}
|
||||
userPrompt, historyCount := e.buildUserPrompt(req)
|
||||
if knowledgeContext != "" {
|
||||
userPrompt += "\n\nKnowledge evidence:\n" + knowledgeContext
|
||||
}
|
||||
var toolCalls []svc.EngineToolCallInput
|
||||
var result *ai.ChatCompletionResult
|
||||
agentAllowedTools := autonomousAllowedMCPToolCodes(req.AIAgent.AllowedMCPTools)
|
||||
toolPolicy := parseAutonomousToolPolicy(req.AIAgent.ToolPolicy)
|
||||
allowedTools := agentAllowedTools
|
||||
if skillContext.Skill != nil {
|
||||
allowedTools = intersectAutonomousToolCodes(agentAllowedTools, skillContext.AllowedToolCodes)
|
||||
}
|
||||
if req.Debug {
|
||||
// Dashboard debug runs may inspect model and retrieval behavior but must
|
||||
// not invoke direct MCP tools against production integrations.
|
||||
allowedTools = nil
|
||||
}
|
||||
if responsePolicy.Enforced {
|
||||
result = &ai.ChatCompletionResult{Content: responsePolicy.ReplyText, ModelName: req.AIConfig.ModelName}
|
||||
} else if len(allowedTools) > 0 && e.toolChat != nil {
|
||||
loopResult, loopErr := e.toolChat(ctx, req.AIConfig, systemPrompt, userPrompt, []ai.ToolDefinition{autonomousToolSearchDefinition()}, req.AIAgent.MaxSteps, e.toolSearchExecutor(req.Conversation, req.AIAgent, agentAllowedTools, skillContext.AllowedToolCodes, toolPolicy, &toolCalls))
|
||||
if loopErr != nil {
|
||||
if len(toolCalls) == 0 {
|
||||
err := loopErr
|
||||
_, _ = writeAutonomousRun(req, startedAt, nil, userPrompt, historyCount, retrieverCount, retrieveErr, skillContext, responsePolicy, toolCalls, err)
|
||||
return nil, err
|
||||
}
|
||||
responsePolicy = autonomousToolFailurePolicy(req.AIAgent, "tool_loop_error")
|
||||
result = &ai.ChatCompletionResult{Content: responsePolicy.ReplyText, ModelName: req.AIConfig.ModelName}
|
||||
}
|
||||
if result == nil && loopResult != nil {
|
||||
result = &loopResult.ChatCompletionResult
|
||||
}
|
||||
if autonomousHasConsecutiveToolFailures(toolCalls, 2) {
|
||||
responsePolicy = autonomousToolFailurePolicy(req.AIAgent, "tool_consecutive_failures")
|
||||
result = &ai.ChatCompletionResult{Content: responsePolicy.ReplyText, ModelName: req.AIConfig.ModelName}
|
||||
}
|
||||
} else {
|
||||
result, err = e.chat(ctx, req.AIConfig, systemPrompt, userPrompt)
|
||||
}
|
||||
if err != nil {
|
||||
_, _ = writeAutonomousRun(req, startedAt, nil, userPrompt, historyCount, retrieverCount, retrieveErr, skillContext, responsePolicy, toolCalls, err)
|
||||
return nil, err
|
||||
}
|
||||
if result == nil || strings.TrimSpace(result.Content) == "" {
|
||||
err = errorsx.InvalidParam("autonomous engine returned an empty reply")
|
||||
_, _ = writeAutonomousRun(req, startedAt, nil, userPrompt, historyCount, retrieverCount, retrieveErr, skillContext, responsePolicy, toolCalls, err)
|
||||
return nil, err
|
||||
}
|
||||
result.Content, err = aitooling.NormalizeCustomerReply(result.Content)
|
||||
if err != nil {
|
||||
_, _ = writeAutonomousRun(req, startedAt, nil, userPrompt, historyCount, retrieverCount, retrieveErr, skillContext, responsePolicy, toolCalls, err)
|
||||
return nil, err
|
||||
}
|
||||
runID, recordErr := writeAutonomousRun(req, startedAt, result, userPrompt, historyCount, retrieverCount, retrieveErr, skillContext, responsePolicy, toolCalls, nil)
|
||||
if recordErr != nil {
|
||||
return nil, recordErr
|
||||
}
|
||||
trace, _ := json.Marshal(map[string]any{
|
||||
"engine": EngineCodeAutonomous,
|
||||
"mode": autonomousExecutionMode(allowedTools),
|
||||
"historyMessageCount": historyCount,
|
||||
"retrieverCount": retrieverCount,
|
||||
"skillID": skillContext.SkillID(),
|
||||
"skillRouteError": skillContext.ErrorMessage,
|
||||
"responsePolicyAction": responsePolicy.Action,
|
||||
"debug": req.Debug,
|
||||
})
|
||||
return &Summary{
|
||||
Status: "completed",
|
||||
ReplyText: strings.TrimSpace(result.Content),
|
||||
ModelName: result.ModelName,
|
||||
PromptTokens: result.PromptTokens,
|
||||
CompletionTokens: result.CompletionTokens,
|
||||
HistoryMessageCount: historyCount,
|
||||
RetrieverCount: retrieverCount,
|
||||
PlannedSkillID: skillContext.SkillID(),
|
||||
PlannedSkillName: skillContext.SkillName(),
|
||||
PlanReason: skillContext.MatchReason,
|
||||
SkillRouteTrace: skillContext.TraceData,
|
||||
SkillAllowedToolCodes: append([]string(nil), skillContext.AllowedToolCodes...),
|
||||
AgentRunID: runID,
|
||||
HandoffRequested: responsePolicy.RequestHandoff && !req.Debug,
|
||||
TraceData: string(trace),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (e *AutonomousEngine) buildUserPrompt(req Request) (string, int) {
|
||||
limit := req.AIAgent.ContextWindow
|
||||
if limit <= 0 {
|
||||
limit = 12
|
||||
}
|
||||
if limit > 20 {
|
||||
limit = 20
|
||||
}
|
||||
items := []models.Message(nil)
|
||||
if e.history != nil && req.Conversation.ID > 0 {
|
||||
// The triggering customer message is already persisted in most reply
|
||||
// paths. Fetch one extra item so it does not consume history capacity.
|
||||
items = e.history(req.Conversation.ID, limit+1)
|
||||
}
|
||||
lines := make([]string, 0, len(items)+2)
|
||||
for _, item := range items {
|
||||
if item.ID == req.UserMessage.ID || strings.TrimSpace(item.Content) == "" {
|
||||
continue
|
||||
}
|
||||
role := autonomousMessageRole(item)
|
||||
if role == "" {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, role+": "+utils.BuildRuntimeMessageText(item.MessageType, item.Content))
|
||||
}
|
||||
if len(lines) > limit {
|
||||
lines = lines[len(lines)-limit:]
|
||||
}
|
||||
current := strings.TrimSpace(req.UserMessage.Content)
|
||||
customerContext := buildAutonomousCustomerContext(req.Conversation)
|
||||
if len(lines) == 0 && customerContext == "" {
|
||||
return current, 0
|
||||
}
|
||||
parts := make([]string, 0, 3)
|
||||
if customerContext != "" {
|
||||
parts = append(parts, "Customer context:\n"+customerContext)
|
||||
}
|
||||
if len(lines) > 0 {
|
||||
parts = append(parts, "Conversation history:\n"+strings.Join(lines, "\n"))
|
||||
}
|
||||
parts = append(parts, "Current customer message:\n"+current)
|
||||
return strings.Join(parts, "\n\n"), len(lines)
|
||||
}
|
||||
|
||||
func buildAutonomousCustomerContext(conversation models.Conversation) string {
|
||||
parts := make([]string, 0, 2)
|
||||
if name := strings.TrimSpace(conversation.CustomerName); name != "" {
|
||||
parts = append(parts, "Customer: "+name)
|
||||
}
|
||||
if summary := strings.TrimSpace(conversation.LastMessageSummary); summary != "" {
|
||||
parts = append(parts, "Recent summary: "+summary)
|
||||
}
|
||||
return strings.Join(parts, "\n")
|
||||
}
|
||||
|
||||
func autonomousMessageRole(message models.Message) string {
|
||||
switch message.SenderType {
|
||||
case "customer":
|
||||
return "Customer"
|
||||
case "ai", "agent":
|
||||
return "Assistant"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func (e *AutonomousEngine) Resume(ctx context.Context, req ResumeInput) (*RunResult, error) {
|
||||
return nil, errorsx.InvalidParam("autonomous agent has no resumable checkpoint")
|
||||
}
|
||||
|
||||
func (e *AutonomousEngine) retrieveKnowledge(ctx context.Context, agent models.AIAgent, query string) (string, int, error) {
|
||||
if e.retrieve == nil || len(utils.SplitInt64s(agent.KnowledgeIDs)) == 0 {
|
||||
return "", 0, nil
|
||||
}
|
||||
return e.retrieve(ctx, agent, query)
|
||||
}
|
||||
|
||||
type autonomousSkillContext struct {
|
||||
Skill *models.SkillDefinition
|
||||
MatchReason string
|
||||
TraceData string
|
||||
ErrorMessage string
|
||||
AllowedToolCodes []string
|
||||
}
|
||||
|
||||
type autonomousResponsePolicy struct {
|
||||
Enforced bool
|
||||
Action string
|
||||
Reason string
|
||||
ReplyText string
|
||||
RequestHandoff bool
|
||||
}
|
||||
|
||||
func evaluateAutonomousResponsePolicy(agent models.AIAgent, knowledgeContext string, retrieveErr error) autonomousResponsePolicy {
|
||||
if len(utils.SplitInt64s(agent.KnowledgeIDs)) == 0 || strings.TrimSpace(knowledgeContext) != "" && retrieveErr == nil {
|
||||
return autonomousResponsePolicy{}
|
||||
}
|
||||
if retrieveErr != nil {
|
||||
return autonomousKnowledgeFallbackPolicy(agent, "knowledge_retrieve_error")
|
||||
}
|
||||
return autonomousKnowledgeFallbackPolicy(agent, "knowledge_evidence_missing")
|
||||
}
|
||||
|
||||
func autonomousKnowledgeFallbackPolicy(agent models.AIAgent, reason string) autonomousResponsePolicy {
|
||||
if agent.FallbackMode == enums.AIAgentFallbackModeHandoff {
|
||||
return autonomousResponsePolicy{
|
||||
Enforced: true, Action: "handoff", Reason: reason, RequestHandoff: true,
|
||||
ReplyText: autonomousKnowledgeFallbackReply(agent),
|
||||
}
|
||||
}
|
||||
return autonomousResponsePolicy{
|
||||
Enforced: true, Action: "clarify", Reason: reason,
|
||||
ReplyText: autonomousKnowledgeFallbackReply(agent),
|
||||
}
|
||||
}
|
||||
|
||||
func autonomousToolFailurePolicy(agent models.AIAgent, reason string) autonomousResponsePolicy {
|
||||
if agent.FallbackMode == enums.AIAgentFallbackModeHandoff {
|
||||
return autonomousResponsePolicy{
|
||||
Enforced: true, Action: "handoff", Reason: reason, RequestHandoff: true,
|
||||
ReplyText: autonomousToolFailureReply(agent),
|
||||
}
|
||||
}
|
||||
return autonomousResponsePolicy{
|
||||
Enforced: true, Action: "clarify", Reason: reason,
|
||||
ReplyText: autonomousToolFailureReply(agent),
|
||||
}
|
||||
}
|
||||
|
||||
func autonomousToolFailureReply(agent models.AIAgent) string {
|
||||
if reply := strings.TrimSpace(agent.FallbackMessage); reply != "" {
|
||||
return reply
|
||||
}
|
||||
if agent.FallbackMode == enums.AIAgentFallbackModeHandoff {
|
||||
return "暂时无法完成所需查询,正在为你转接人工客服。"
|
||||
}
|
||||
return "暂时无法完成所需查询,请补充更具体的信息后再试一次。"
|
||||
}
|
||||
|
||||
func autonomousHasConsecutiveToolFailures(calls []svc.EngineToolCallInput, minimum int) bool {
|
||||
if minimum <= 0 {
|
||||
return false
|
||||
}
|
||||
failures := 0
|
||||
for index := len(calls) - 1; index >= 0; index-- {
|
||||
if calls[index].Status != "failed" {
|
||||
break
|
||||
}
|
||||
failures++
|
||||
}
|
||||
return failures >= minimum
|
||||
}
|
||||
|
||||
func autonomousKnowledgeFallbackReply(agent models.AIAgent) string {
|
||||
if reply := strings.TrimSpace(agent.FallbackMessage); reply != "" {
|
||||
return reply
|
||||
}
|
||||
if agent.FallbackMode == 0 || agent.FallbackMode == enums.AIAgentFallbackModeSuggestRetry {
|
||||
return "当前知识库里没有找到足够明确的信息,你可以换个更具体的问法再试一次。"
|
||||
}
|
||||
if agent.FallbackMode == enums.AIAgentFallbackModeHandoff {
|
||||
return "当前知识库没有足够明确的信息,正在为你转接人工客服。"
|
||||
}
|
||||
return "当前知识库暂无明确信息。"
|
||||
}
|
||||
|
||||
func (c autonomousSkillContext) SkillID() int64 {
|
||||
if c.Skill == nil {
|
||||
return 0
|
||||
}
|
||||
return c.Skill.ID
|
||||
}
|
||||
|
||||
func (c autonomousSkillContext) SkillName() string {
|
||||
if c.Skill == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(c.Skill.Name)
|
||||
}
|
||||
|
||||
func (e *AutonomousEngine) selectSkill(ctx context.Context, req Request) autonomousSkillContext {
|
||||
if e.skillSelect == nil || len(utils.SplitInt64s(req.AIAgent.SkillIDs)) == 0 {
|
||||
return autonomousSkillContext{}
|
||||
}
|
||||
result, err := e.skillSelect(ctx, skills.RuntimeContext{
|
||||
AIAgent: req.AIAgent, AIConfig: req.AIConfig, UserMessage: req.UserMessage.Content, ConversationID: req.Conversation.ID,
|
||||
})
|
||||
ret := autonomousSkillContext{}
|
||||
if err != nil {
|
||||
ret.ErrorMessage = err.Error()
|
||||
return ret
|
||||
}
|
||||
if result == nil || result.Plan == nil {
|
||||
return ret
|
||||
}
|
||||
ret.Skill = result.Plan.Skill
|
||||
ret.MatchReason = strings.TrimSpace(result.Plan.MatchReason)
|
||||
if result.Trace != nil {
|
||||
data, _ := json.Marshal(result.Trace)
|
||||
ret.TraceData = string(data)
|
||||
}
|
||||
if ret.Skill != nil {
|
||||
ret.AllowedToolCodes = parseSkillToolWhitelist(ret.Skill.ToolWhitelist)
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func parseSkillToolWhitelist(raw string) []string {
|
||||
var items []string
|
||||
if json.Unmarshal([]byte(strings.TrimSpace(raw)), &items) != nil {
|
||||
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 _, exists := seen[item]; exists {
|
||||
continue
|
||||
}
|
||||
seen[item] = struct{}{}
|
||||
ret = append(ret, item)
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func intersectAutonomousToolCodes(agentAllowed, skillAllowed []string) []string {
|
||||
if len(agentAllowed) == 0 || len(skillAllowed) == 0 {
|
||||
return nil
|
||||
}
|
||||
allowed := make(map[string]struct{}, len(skillAllowed))
|
||||
for _, item := range skillAllowed {
|
||||
allowed[toolx.NormalizeToolCodeAlias(strings.TrimSpace(item))] = struct{}{}
|
||||
}
|
||||
ret := make([]string, 0, len(agentAllowed))
|
||||
for _, item := range agentAllowed {
|
||||
item = toolx.NormalizeToolCodeAlias(strings.TrimSpace(item))
|
||||
if _, ok := allowed[item]; ok {
|
||||
ret = append(ret, item)
|
||||
}
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
type autonomousDirectTool struct {
|
||||
ToolCode string `json:"toolCode"`
|
||||
}
|
||||
|
||||
type autonomousToolSearchRequest struct {
|
||||
ToolCode string `json:"toolCode"`
|
||||
Arguments map[string]any `json:"arguments"`
|
||||
}
|
||||
|
||||
type autonomousToolPolicy struct {
|
||||
MaxTotalCalls int `json:"maxTotalCalls"`
|
||||
MaxArgumentBytes int `json:"maxArgumentBytes"`
|
||||
AllowedRiskLevels []string `json:"allowedRiskLevels"`
|
||||
}
|
||||
|
||||
func parseAutonomousToolPolicy(raw string) autonomousToolPolicy {
|
||||
policy := autonomousToolPolicy{MaxTotalCalls: 3, MaxArgumentBytes: 32 * 1024}
|
||||
if json.Unmarshal([]byte(strings.TrimSpace(raw)), &policy) != nil {
|
||||
return policy
|
||||
}
|
||||
if policy.MaxTotalCalls <= 0 || policy.MaxTotalCalls > 8 {
|
||||
policy.MaxTotalCalls = 3
|
||||
}
|
||||
if policy.MaxArgumentBytes <= 0 || policy.MaxArgumentBytes > 64*1024 {
|
||||
policy.MaxArgumentBytes = 32 * 1024
|
||||
}
|
||||
return policy
|
||||
}
|
||||
|
||||
func autonomousAllowedMCPToolCodes(raw string) []string {
|
||||
var items []autonomousDirectTool
|
||||
if json.Unmarshal([]byte(strings.TrimSpace(raw)), &items) != nil {
|
||||
return nil
|
||||
}
|
||||
ret := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
if code := strings.TrimSpace(item.ToolCode); code != "" {
|
||||
ret = append(ret, code)
|
||||
}
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func autonomousToolSearchDefinition() ai.ToolDefinition {
|
||||
return ai.ToolDefinition{
|
||||
Name: "tool_search",
|
||||
Description: "Use a configured read-only tool only when it is needed to answer the customer. Pass the exact allowed toolCode and an arguments object.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"toolCode": map[string]any{"type": "string"},
|
||||
"arguments": map[string]any{"type": "object"},
|
||||
},
|
||||
"required": []string{"toolCode", "arguments"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (e *AutonomousEngine) toolSearchExecutor(conversation models.Conversation, agent models.AIAgent, allowedCodes, skillAllowedCodes []string, toolPolicy autonomousToolPolicy, records *[]svc.EngineToolCallInput) ai.ToolCallExecutor {
|
||||
return func(ctx context.Context, call ai.ToolCall) (string, error) {
|
||||
startedAt := time.Now()
|
||||
if call.Name != "tool_search" {
|
||||
return "", fmt.Errorf("unsupported autonomous tool: %s", call.Name)
|
||||
}
|
||||
var req autonomousToolSearchRequest
|
||||
if err := json.Unmarshal([]byte(call.Arguments), &req); err != nil {
|
||||
return "", fmt.Errorf("invalid tool_search arguments: %w", err)
|
||||
}
|
||||
policy := aitooling.Policy{
|
||||
AllowedToolCodes: allowedCodes, SkillAllowedToolCodes: skillAllowedCodes, AllowedRiskLevels: toolPolicy.AllowedRiskLevels,
|
||||
CallCount: autonomousToolCallCount(*records, req.ToolCode),
|
||||
TotalCallCount: len(*records),
|
||||
MaxTotalCalls: toolPolicy.MaxTotalCalls,
|
||||
MaxArgumentBytes: toolPolicy.MaxArgumentBytes,
|
||||
Confirmed: true, // The Agent's persisted allow-list is the administrator approval boundary.
|
||||
}
|
||||
definition, resultPreview, err := executeAutonomousReadTool(ctx, conversation, agent, strings.TrimSpace(req.ToolCode), req.Arguments, policy)
|
||||
if err != nil && definition.Code == "" {
|
||||
mcpDefinition, result, mcpErr := aitooling.DefaultMCPExecutor.Execute(ctx, strings.TrimSpace(req.ToolCode), req.Arguments, policy)
|
||||
definition, err = mcpDefinition, mcpErr
|
||||
resultPreview = runtimetooling.BuildReducedToolResultSummary(result)
|
||||
}
|
||||
durationMS := int(time.Since(startedAt).Milliseconds())
|
||||
record := svc.EngineToolCallInput{
|
||||
ToolCode: strings.TrimSpace(req.ToolCode), Status: "completed", ArgumentsPreview: aitooling.SanitizePreview(call.Arguments), DurationMS: durationMS,
|
||||
}
|
||||
if definition.Code != "" {
|
||||
record.ToolCode = definition.Code
|
||||
record.RiskLevel = definition.RiskLevel
|
||||
record.RequireConfirm = definition.RequireConfirmation
|
||||
}
|
||||
if err != nil {
|
||||
record.Status = "failed"
|
||||
record.ErrorMessage = err.Error()
|
||||
*records = append(*records, record)
|
||||
return "", err
|
||||
}
|
||||
record.ResultPreview = aitooling.SanitizePreview(resultPreview)
|
||||
*records = append(*records, record)
|
||||
return record.ResultPreview, nil
|
||||
}
|
||||
}
|
||||
|
||||
func executeAutonomousReadTool(ctx context.Context, conversation models.Conversation, agent models.AIAgent, toolCode string, arguments map[string]any, policy aitooling.Policy) (aitooling.Definition, string, error) {
|
||||
toolCode = toolx.NormalizeToolCodeAlias(strings.TrimSpace(toolCode))
|
||||
if toolCode != toolx.BuiltinConversationContext.Code && toolCode != toolx.BuiltinKnowledgeRetrieve.Code && toolCode != toolx.GraphTriageServiceRequest.Code && toolCode != toolx.GraphAnalyzeConversation.Code && toolCode != toolx.GraphPrepareTicketDraft.Code {
|
||||
return aitooling.Definition{}, "", fmt.Errorf("tool is not a built-in read tool")
|
||||
}
|
||||
if toolCode == toolx.GraphTriageServiceRequest.Code || toolCode == toolx.GraphAnalyzeConversation.Code || toolCode == toolx.GraphPrepareTicketDraft.Code {
|
||||
return readtools.ExecuteGraphTool(ctx, conversation, toolCode, arguments, policy)
|
||||
}
|
||||
definition, err := aitooling.DefaultRegistry.Resolve(toolCode)
|
||||
if err != nil {
|
||||
return aitooling.Definition{}, "", err
|
||||
}
|
||||
if err := aitooling.DefaultRegistry.Authorize(definition, policy); err != nil {
|
||||
return definition, "", err
|
||||
}
|
||||
if definition.TimeoutMS > 0 {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(ctx, time.Duration(definition.TimeoutMS)*time.Millisecond)
|
||||
defer cancel()
|
||||
}
|
||||
if toolCode == toolx.BuiltinKnowledgeRetrieve.Code {
|
||||
query, _ := arguments["query"].(string)
|
||||
contextText, count, err := retrieveAutonomousKnowledge(ctx, agent, query)
|
||||
if err != nil {
|
||||
return definition, "", err
|
||||
}
|
||||
result, err := json.Marshal(map[string]any{"query": strings.TrimSpace(query), "resultCount": count, "context": contextText})
|
||||
return definition, string(result), err
|
||||
}
|
||||
result, err := json.Marshal(map[string]any{
|
||||
"conversationId": conversation.ID,
|
||||
"customerName": strings.TrimSpace(conversation.CustomerName),
|
||||
"lastMessageSummary": strings.TrimSpace(conversation.LastMessageSummary),
|
||||
"currentAssigneeId": conversation.CurrentAssigneeID,
|
||||
"recentMessages": autonomousToolConversationMessages(conversation.ID),
|
||||
})
|
||||
if err != nil {
|
||||
return definition, "", err
|
||||
}
|
||||
return definition, string(result), nil
|
||||
}
|
||||
|
||||
func autonomousToolConversationMessages(conversationID int64) []map[string]string {
|
||||
if conversationID <= 0 {
|
||||
return []map[string]string{}
|
||||
}
|
||||
items, _, _ := svc.MessageService.FindByConversationIDCursor(conversationID, 0, 6, "", "")
|
||||
ret := make([]map[string]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
role := autonomousMessageRole(item)
|
||||
content := strings.TrimSpace(utils.BuildRuntimeMessageText(item.MessageType, item.Content))
|
||||
if role == "" || content == "" {
|
||||
continue
|
||||
}
|
||||
if runes := []rune(content); len(runes) > 240 {
|
||||
content = string(runes[:240]) + "..."
|
||||
}
|
||||
ret = append(ret, map[string]string{"role": role, "content": content})
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func autonomousToolCallCount(records []svc.EngineToolCallInput, toolCode string) int {
|
||||
toolCode = toolx.NormalizeToolCodeAlias(strings.TrimSpace(toolCode))
|
||||
count := 0
|
||||
for _, item := range records {
|
||||
if toolx.NormalizeToolCodeAlias(strings.TrimSpace(item.ToolCode)) == toolCode {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func retrieveAutonomousKnowledge(ctx context.Context, agent models.AIAgent, query string) (string, int, error) {
|
||||
retrieved, err := retrievers.NewKnowledgeRetriever(agent, utils.SplitInt64s(agent.KnowledgeIDs)).RetrieveContext(ctx, query)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
if retrieved == nil {
|
||||
return "", 0, nil
|
||||
}
|
||||
return strings.TrimSpace(retrieved.ContextText), len(retrieved.ContextResults), nil
|
||||
}
|
||||
|
||||
func buildAutonomousSystemPrompt(agent models.AIAgent, hasKnowledgeBase bool, knowledgeContext string, retrieveErr error) string {
|
||||
prompt := strings.TrimSpace(agent.SystemPrompt)
|
||||
if prompt == "" {
|
||||
prompt = "You are a customer service assistant. Answer accurately, ask for clarification when evidence is insufficient, and do not invent facts."
|
||||
}
|
||||
if hasKnowledgeBase && strings.TrimSpace(knowledgeContext) == "" {
|
||||
prompt += "\n\nNo supporting knowledge was retrieved. Do not invent an answer; ask a focused clarification question or offer human handoff."
|
||||
}
|
||||
if retrieveErr != nil {
|
||||
prompt += "\n\nKnowledge retrieval is temporarily unavailable. Do not claim to have verified any policy or factual detail."
|
||||
}
|
||||
return prompt
|
||||
}
|
||||
|
||||
func writeAutonomousRun(req Request, startedAt time.Time, result *ai.ChatCompletionResult, inputPreview string, historyCount int, retrieverCount int, retrieveErr error, skillContext autonomousSkillContext, responsePolicy autonomousResponsePolicy, toolCalls []svc.EngineToolCallInput, cause error) (int64, error) {
|
||||
endedAt := time.Now()
|
||||
status := "completed"
|
||||
errorMessage := ""
|
||||
outputPreview := ""
|
||||
promptTokens := 0
|
||||
completionTokens := 0
|
||||
if cause != nil {
|
||||
status = "failed"
|
||||
errorMessage = cause.Error()
|
||||
} else if result != nil {
|
||||
outputPreview = strings.TrimSpace(result.Content)
|
||||
promptTokens = result.PromptTokens
|
||||
completionTokens = result.CompletionTokens
|
||||
}
|
||||
trace, _ := json.Marshal(map[string]any{"engine": EngineCodeAutonomous, "mode": autonomousExecutionMode(autonomousAllowedMCPToolCodes(req.AIAgent.AllowedMCPTools)), "status": status, "historyMessageCount": historyCount, "retrieverCount": retrieverCount})
|
||||
var runID int64
|
||||
err := sqls.WithTransaction(func(tx *sqls.TxContext) error {
|
||||
var recordErr error
|
||||
runID, recordErr = svc.AgentRunService.RecordEngineRun(tx.Tx, svc.EngineAgentRunInput{
|
||||
ConversationID: req.Conversation.ID, AIAgentID: req.AIAgent.ID, AgentRevisionID: req.AIAgent.PublishedRevisionID,
|
||||
SourceMessageID: req.UserMessage.ID, EngineCode: EngineCodeAutonomous, Status: status,
|
||||
PromptTokens: promptTokens, CompletionTokens: completionTokens, StartedAt: startedAt, EndedAt: &endedAt,
|
||||
ErrorMessage: errorMessage, TraceData: string(trace), StepType: "model", StepCode: "chat_completion",
|
||||
StepInputPreview: strings.TrimSpace(inputPreview), StepOutputPreview: outputPreview,
|
||||
AdditionalSteps: autonomousAdditionalSteps(req, retrieverCount, retrieveErr, skillContext, responsePolicy),
|
||||
ToolCalls: toolCalls,
|
||||
})
|
||||
return recordErr
|
||||
})
|
||||
return runID, err
|
||||
}
|
||||
|
||||
func autonomousExecutionMode(allowedTools []string) string {
|
||||
if len(allowedTools) > 0 {
|
||||
return "tool_calling_loop"
|
||||
}
|
||||
return "single_model_turn"
|
||||
}
|
||||
|
||||
func autonomousAdditionalSteps(req Request, retrieverCount int, retrieveErr error, skillContext autonomousSkillContext, responsePolicy autonomousResponsePolicy) []svc.EngineStepInput {
|
||||
steps := make([]svc.EngineStepInput, 0, 3)
|
||||
if len(utils.SplitInt64s(req.AIAgent.SkillIDs)) > 0 {
|
||||
status := "completed"
|
||||
if skillContext.ErrorMessage != "" {
|
||||
status = "failed"
|
||||
}
|
||||
steps = append(steps, svc.EngineStepInput{
|
||||
StepType: "skill_route", StepCode: "skill_select", Status: status,
|
||||
InputPreview: strings.TrimSpace(req.UserMessage.Content), OutputPreview: "selected skill: " + skillContext.SkillName(),
|
||||
ErrorMessage: skillContext.ErrorMessage,
|
||||
})
|
||||
}
|
||||
if len(utils.SplitInt64s(req.AIAgent.KnowledgeIDs)) > 0 {
|
||||
status := "completed"
|
||||
errorMessage := ""
|
||||
if retrieveErr != nil {
|
||||
status = "failed"
|
||||
errorMessage = retrieveErr.Error()
|
||||
}
|
||||
steps = append(steps, svc.EngineStepInput{
|
||||
StepType: "knowledge", StepCode: "knowledge_retrieve", Status: status,
|
||||
InputPreview: strings.TrimSpace(req.UserMessage.Content), OutputPreview: "retrieved context items: " + strconv.Itoa(retrieverCount), ErrorMessage: errorMessage,
|
||||
})
|
||||
}
|
||||
if responsePolicy.Enforced {
|
||||
policyCode := "knowledge_evidence"
|
||||
if strings.HasPrefix(responsePolicy.Reason, "tool_") {
|
||||
policyCode = "tool_failure"
|
||||
}
|
||||
steps = append(steps, svc.EngineStepInput{
|
||||
StepType: "policy", StepCode: policyCode, Status: "completed",
|
||||
InputPreview: responsePolicy.Reason, OutputPreview: responsePolicy.Action,
|
||||
})
|
||||
}
|
||||
return steps
|
||||
}
|
||||
|
||||
var _ Engine = (*AutonomousEngine)(nil)
|
||||
@@ -0,0 +1,102 @@
|
||||
package einoexperiment
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
applicationruntime "agent-desk/internal/ai/application/runtime"
|
||||
"agent-desk/internal/ai/runtime/graphs"
|
||||
)
|
||||
|
||||
const confirmationInterruptType = "human_confirm"
|
||||
|
||||
// ConfirmationRequest represents a high-risk Eino tool action that must pause
|
||||
// at AgentDesk's existing conversation-interrupt boundary.
|
||||
type ConfirmationRequest struct {
|
||||
InterruptID string
|
||||
ToolCode string
|
||||
Prompt string
|
||||
Arguments map[string]any
|
||||
}
|
||||
|
||||
type confirmationCheckpoint struct {
|
||||
Version int `json:"version"`
|
||||
Engine string `json:"engine"`
|
||||
InterruptID string `json:"interruptId"`
|
||||
ToolCode string `json:"toolCode"`
|
||||
Arguments map[string]any `json:"arguments"`
|
||||
}
|
||||
|
||||
// BuildConfirmationResult returns the generic interrupted result consumed by
|
||||
// replyInterruptService. That service persists ConversationInterrupt from the
|
||||
// result, so this package remains independent of database writes.
|
||||
func BuildConfirmationResult(input applicationruntime.RunInput, request ConfirmationRequest) (*applicationruntime.RunResult, error) {
|
||||
interruptID := strings.TrimSpace(request.InterruptID)
|
||||
if interruptID == "" {
|
||||
interruptID = "eino_confirm"
|
||||
}
|
||||
toolCode := strings.TrimSpace(request.ToolCode)
|
||||
if toolCode == "" {
|
||||
return nil, fmt.Errorf("confirmation tool code is required")
|
||||
}
|
||||
prompt := strings.TrimSpace(request.Prompt)
|
||||
if prompt == "" {
|
||||
return nil, fmt.Errorf("confirmation prompt is required")
|
||||
}
|
||||
checkpointData, err := json.Marshal(confirmationCheckpoint{
|
||||
Version: 1, Engine: "eino", InterruptID: interruptID, ToolCode: toolCode, Arguments: cloneConfirmationArguments(request.Arguments),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode Eino confirmation checkpoint: %w", err)
|
||||
}
|
||||
return &applicationruntime.RunResult{
|
||||
Status: "interrupted",
|
||||
Interrupted: true,
|
||||
CheckPointID: confirmationCheckpointID(input, interruptID, toolCode, checkpointData),
|
||||
CheckPointData: string(checkpointData),
|
||||
Interrupts: []applicationruntime.InterruptContextSummary{{
|
||||
Type: confirmationInterruptType, ID: interruptID, InfoPreview: string(mustMarshalConfirmationPrompt(prompt)),
|
||||
}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ResumeConfirmation reads the generic ResumeInput populated by the existing
|
||||
// AgentApplicationService and validates that it belongs to this checkpoint.
|
||||
func ResumeConfirmation(checkPointData string, input applicationruntime.ResumeInput) (string, confirmationCheckpoint, error) {
|
||||
checkpoint := confirmationCheckpoint{}
|
||||
if err := json.Unmarshal([]byte(strings.TrimSpace(checkPointData)), &checkpoint); err != nil {
|
||||
return "", checkpoint, fmt.Errorf("decode Eino confirmation checkpoint: %w", err)
|
||||
}
|
||||
if checkpoint.Version != 1 || checkpoint.Engine != "eino" || strings.TrimSpace(checkpoint.InterruptID) == "" || strings.TrimSpace(checkpoint.ToolCode) == "" {
|
||||
return "", checkpoint, fmt.Errorf("invalid Eino confirmation checkpoint")
|
||||
}
|
||||
decision := graphs.ParseConfirmationDecision(strings.TrimSpace(input.ResumeData[checkpoint.InterruptID]))
|
||||
if decision == "" {
|
||||
return "", checkpoint, fmt.Errorf("Eino confirmation decision is required")
|
||||
}
|
||||
return string(decision), checkpoint, nil
|
||||
}
|
||||
|
||||
func confirmationCheckpointID(input applicationruntime.RunInput, interruptID, toolCode string, data []byte) string {
|
||||
digest := sha256.Sum256(append([]byte(strings.TrimSpace(toolCode)+":"+strings.TrimSpace(interruptID)+":"), data...))
|
||||
return fmt.Sprintf("eino:%d:%d:%s", input.Conversation.ID, input.UserMessage.ID, hex.EncodeToString(digest[:8]))
|
||||
}
|
||||
|
||||
func cloneConfirmationArguments(input map[string]any) map[string]any {
|
||||
if len(input) == 0 {
|
||||
return map[string]any{}
|
||||
}
|
||||
ret := make(map[string]any, len(input))
|
||||
for key, value := range input {
|
||||
ret[key] = value
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func mustMarshalConfirmationPrompt(prompt string) []byte {
|
||||
data, _ := json.Marshal(map[string]string{"message": prompt})
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package einoexperiment
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
aitooling "agent-desk/internal/ai/tooling"
|
||||
|
||||
einotool "github.com/cloudwego/eino/components/tool"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
// ToolHandler is the adapter point from an approved Eino experiment tool to
|
||||
// AgentDesk business services. Production handlers must still call services,
|
||||
// never repositories.
|
||||
type ToolHandler func(ctx context.Context, arguments map[string]any) (string, error)
|
||||
|
||||
// ToolTrace is emitted for every guarded invocation. A future Engine adapter
|
||||
// can translate it into AgentRun tool-call audit records without coupling this
|
||||
// experiment package to the service layer.
|
||||
type ToolTrace struct {
|
||||
ToolCode string
|
||||
Arguments map[string]any
|
||||
Status string
|
||||
Result string
|
||||
Err error
|
||||
Duration time.Duration
|
||||
}
|
||||
|
||||
type ToolTraceHook func(ToolTrace)
|
||||
|
||||
// GuardedTool adapts an Eino InvokableTool to the shared ToolPolicyGuard. It is
|
||||
// deliberately generic so Tool Registry semantics are checked before a tool
|
||||
// handler is invoked.
|
||||
type GuardedTool struct {
|
||||
InfoDefinition *schema.ToolInfo
|
||||
Definition aitooling.Definition
|
||||
Policy aitooling.Policy
|
||||
Handler ToolHandler
|
||||
Trace ToolTraceHook
|
||||
}
|
||||
|
||||
var _ einotool.InvokableTool = (*GuardedTool)(nil)
|
||||
|
||||
func (t *GuardedTool) Info(context.Context) (*schema.ToolInfo, error) {
|
||||
if t == nil || t.InfoDefinition == nil {
|
||||
return nil, fmt.Errorf("eino experiment tool info is required")
|
||||
}
|
||||
return t.InfoDefinition, nil
|
||||
}
|
||||
|
||||
func (t *GuardedTool) InvokableRun(ctx context.Context, argumentsInJSON string, _ ...einotool.Option) (string, error) {
|
||||
if t == nil || t.Handler == nil {
|
||||
return "", fmt.Errorf("eino experiment tool handler is required")
|
||||
}
|
||||
startedAt := time.Now()
|
||||
arguments := map[string]any{}
|
||||
if err := json.Unmarshal([]byte(argumentsInJSON), &arguments); err != nil {
|
||||
t.emitTrace(arguments, "failed", "", err, startedAt)
|
||||
return "", fmt.Errorf("decode tool arguments: %w", err)
|
||||
}
|
||||
if err := aitooling.DefaultPolicyGuard.Authorize(aitooling.Invocation{
|
||||
Definition: t.Definition,
|
||||
Arguments: arguments,
|
||||
Policy: t.Policy,
|
||||
}); err != nil {
|
||||
t.emitTrace(arguments, "failed", "", err, startedAt)
|
||||
return "", err
|
||||
}
|
||||
if t.Definition.TimeoutMS > 0 {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(ctx, time.Duration(t.Definition.TimeoutMS)*time.Millisecond)
|
||||
defer cancel()
|
||||
}
|
||||
result, err := t.Handler(ctx, arguments)
|
||||
status := "completed"
|
||||
if err != nil {
|
||||
status = "failed"
|
||||
}
|
||||
t.emitTrace(arguments, status, result, err, startedAt)
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (t *GuardedTool) emitTrace(arguments map[string]any, status, result string, err error, startedAt time.Time) {
|
||||
if t == nil || t.Trace == nil {
|
||||
return
|
||||
}
|
||||
t.Trace(ToolTrace{
|
||||
ToolCode: t.Definition.Code, Arguments: arguments, Status: status, Result: result, Err: err,
|
||||
Duration: time.Since(startedAt),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package einoexperiment
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"agent-desk/internal/ai/mcps"
|
||||
runtimetooling "agent-desk/internal/ai/runtime/tooling"
|
||||
aitooling "agent-desk/internal/ai/tooling"
|
||||
)
|
||||
|
||||
// MCPToolExecutor is the narrow execution boundary used by the Eino
|
||||
// experiment. The production MCP executor remains responsible for dynamic
|
||||
// registry resolution, policy enforcement, timeout, and transport lifecycle.
|
||||
type MCPToolExecutor interface {
|
||||
Execute(context.Context, string, map[string]any, aitooling.Policy) (aitooling.Definition, *mcps.ToolCallResult, error)
|
||||
}
|
||||
|
||||
// NewMCPToolHandler adapts a dynamically discovered MCP tool to GuardedTool.
|
||||
// Callers must still configure GuardedTool.Definition and Policy so its
|
||||
// pre-handler guard provides a deterministic rejection before MCP transport.
|
||||
func NewMCPToolHandler(executor MCPToolExecutor, toolCode string, policy aitooling.Policy) ToolHandler {
|
||||
return func(ctx context.Context, arguments map[string]any) (string, error) {
|
||||
if executor == nil {
|
||||
return "", fmt.Errorf("eino experiment MCP executor is required")
|
||||
}
|
||||
definition, result, err := executor.Execute(ctx, strings.TrimSpace(toolCode), arguments, policy)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if definition.Code == "" {
|
||||
return "", fmt.Errorf("MCP executor returned an empty tool definition")
|
||||
}
|
||||
return runtimetooling.BuildReducedToolResultSummary(result), nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// Package einoexperiment contains an isolated Eino ReAct verification path.
|
||||
// It must not be registered in the production Agent Engine registry.
|
||||
package einoexperiment
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"agent-desk/internal/models"
|
||||
|
||||
einoopenai "github.com/cloudwego/eino-ext/components/model/openai"
|
||||
"github.com/cloudwego/eino/components/model"
|
||||
"github.com/cloudwego/eino/components/tool"
|
||||
"github.com/cloudwego/eino/compose"
|
||||
"github.com/cloudwego/eino/flow/agent/react"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
// ReActConfig keeps the experiment dependency-injected. The caller owns model
|
||||
// construction, connection reuse, and all production configuration decisions.
|
||||
type ReActConfig struct {
|
||||
Model model.ToolCallingChatModel
|
||||
Tools []tool.BaseTool
|
||||
MaxSteps int
|
||||
}
|
||||
|
||||
// NewOpenAICompatibleModel adapts an existing AgentDesk AI configuration to
|
||||
// Eino's OpenAI-compatible chat model. It is intentionally not wired into any
|
||||
// production Engine; the experiment owns the adoption decision.
|
||||
func NewOpenAICompatibleModel(ctx context.Context, config models.AIConfig) (model.ToolCallingChatModel, error) {
|
||||
if strings.TrimSpace(config.APIKey) == "" || strings.TrimSpace(config.BaseURL) == "" || strings.TrimSpace(config.ModelName) == "" {
|
||||
return nil, fmt.Errorf("ai config base URL, API key, and model name are required")
|
||||
}
|
||||
modelConfig := &einoopenai.ChatModelConfig{
|
||||
APIKey: strings.TrimSpace(config.APIKey),
|
||||
BaseURL: strings.TrimSpace(config.BaseURL),
|
||||
Model: strings.TrimSpace(config.ModelName),
|
||||
}
|
||||
if config.TimeoutMS > 0 {
|
||||
modelConfig.Timeout = time.Duration(config.TimeoutMS) * time.Millisecond
|
||||
}
|
||||
if config.MaxOutputTokens > 0 {
|
||||
maxTokens := config.MaxOutputTokens
|
||||
modelConfig.MaxCompletionTokens = &maxTokens
|
||||
}
|
||||
return einoopenai.NewChatModel(ctx, modelConfig)
|
||||
}
|
||||
|
||||
// NewReAct creates an Eino ReAct agent without registering it with AgentDesk's
|
||||
// runtime. It is deliberately suitable only for technical verification.
|
||||
func NewReAct(ctx context.Context, config ReActConfig) (*react.Agent, error) {
|
||||
if config.Model == nil {
|
||||
return nil, fmt.Errorf("eino experiment model is required")
|
||||
}
|
||||
maxSteps := config.MaxSteps
|
||||
if maxSteps <= 0 {
|
||||
maxSteps = 5
|
||||
}
|
||||
return react.NewAgent(ctx, &react.AgentConfig{
|
||||
ToolCallingModel: config.Model,
|
||||
ToolsConfig: compose.ToolsNodeConfig{Tools: config.Tools},
|
||||
MaxStep: maxSteps,
|
||||
})
|
||||
}
|
||||
|
||||
// Run performs one non-streaming experiment. Context cancellation is passed
|
||||
// directly to Eino and the injected model/tools.
|
||||
func Run(ctx context.Context, config ReActConfig, input []*schema.Message) (*schema.Message, error) {
|
||||
agent, err := NewReAct(ctx, config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return agent.Generate(ctx, input)
|
||||
}
|
||||
|
||||
// Stream performs one streaming experiment. The caller must close the returned
|
||||
// reader after consuming it.
|
||||
func Stream(ctx context.Context, config ReActConfig, input []*schema.Message) (*schema.StreamReader[*schema.Message], error) {
|
||||
agent, err := NewReAct(ctx, config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return agent.Stream(ctx, input)
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
package einoexperiment
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
applicationruntime "agent-desk/internal/ai/application/runtime"
|
||||
"agent-desk/internal/ai/mcps"
|
||||
"agent-desk/internal/ai/runtime/graphs"
|
||||
aitooling "agent-desk/internal/ai/tooling"
|
||||
"agent-desk/internal/models"
|
||||
|
||||
"github.com/cloudwego/eino/components/model"
|
||||
"github.com/cloudwego/eino/components/tool"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
type scriptedToolCallingModel struct {
|
||||
responses []*schema.Message
|
||||
calls int
|
||||
err error
|
||||
block bool
|
||||
lastInput []*schema.Message
|
||||
}
|
||||
|
||||
type fakeMCPToolExecutor struct {
|
||||
toolCode string
|
||||
arguments map[string]any
|
||||
policy aitooling.Policy
|
||||
result *mcps.ToolCallResult
|
||||
err error
|
||||
}
|
||||
|
||||
type concurrentToolCallingModel struct {
|
||||
calls atomic.Int32
|
||||
}
|
||||
|
||||
var _ model.ToolCallingChatModel = (*concurrentToolCallingModel)(nil)
|
||||
|
||||
func (m *concurrentToolCallingModel) Generate(ctx context.Context, _ []*schema.Message, _ ...model.Option) (*schema.Message, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.calls.Add(1)
|
||||
return schema.AssistantMessage("并发调用完成。", nil), nil
|
||||
}
|
||||
|
||||
func (m *concurrentToolCallingModel) Stream(ctx context.Context, input []*schema.Message, opts ...model.Option) (*schema.StreamReader[*schema.Message], error) {
|
||||
message, err := m.Generate(ctx, input, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return schema.StreamReaderFromArray([]*schema.Message{message}), nil
|
||||
}
|
||||
|
||||
func (m *concurrentToolCallingModel) WithTools(_ []*schema.ToolInfo) (model.ToolCallingChatModel, error) {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (e *fakeMCPToolExecutor) Execute(_ context.Context, toolCode string, arguments map[string]any, policy aitooling.Policy) (aitooling.Definition, *mcps.ToolCallResult, error) {
|
||||
e.toolCode = toolCode
|
||||
e.arguments = arguments
|
||||
e.policy = policy
|
||||
return aitooling.Definition{Code: toolCode, RiskLevel: aitooling.RiskLevelSensitive}, e.result, e.err
|
||||
}
|
||||
|
||||
var _ model.ToolCallingChatModel = (*scriptedToolCallingModel)(nil)
|
||||
|
||||
func (m *scriptedToolCallingModel) Generate(ctx context.Context, input []*schema.Message, _ ...model.Option) (*schema.Message, error) {
|
||||
m.lastInput = append([]*schema.Message(nil), input...)
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if m.block {
|
||||
<-ctx.Done()
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
if m.err != nil {
|
||||
return nil, m.err
|
||||
}
|
||||
if m.calls >= len(m.responses) {
|
||||
return nil, errors.New("unexpected model call")
|
||||
}
|
||||
result := m.responses[m.calls]
|
||||
m.calls++
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *scriptedToolCallingModel) Stream(ctx context.Context, input []*schema.Message, opts ...model.Option) (*schema.StreamReader[*schema.Message], error) {
|
||||
message, err := m.Generate(ctx, input, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return schema.StreamReaderFromArray([]*schema.Message{message}), nil
|
||||
}
|
||||
|
||||
func (m *scriptedToolCallingModel) WithTools(_ []*schema.ToolInfo) (model.ToolCallingChatModel, error) {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func TestRunExecutesGuardedToolThenReturnsFinalAnswer(t *testing.T) {
|
||||
called := false
|
||||
guardedTool := &GuardedTool{
|
||||
InfoDefinition: &schema.ToolInfo{Name: "customer_lookup", Desc: "Read customer data"},
|
||||
Definition: aitooling.Definition{Code: "builtin/customer_lookup", RiskLevel: aitooling.RiskLevelRead},
|
||||
Policy: aitooling.Policy{AllowedToolCodes: []string{"builtin/customer_lookup"}},
|
||||
Handler: func(_ context.Context, arguments map[string]any) (string, error) {
|
||||
called = arguments["customerId"] == "42"
|
||||
return "customer: Ada", nil
|
||||
},
|
||||
}
|
||||
model := &scriptedToolCallingModel{responses: []*schema.Message{
|
||||
schema.AssistantMessage("", []schema.ToolCall{{ID: "call-1", Type: "function", Function: schema.FunctionCall{Name: "customer_lookup", Arguments: `{"customerId":"42"}`}}}),
|
||||
schema.AssistantMessage("已找到客户资料。", nil),
|
||||
}}
|
||||
|
||||
result, err := Run(context.Background(), ReActConfig{Model: model, Tools: []tool.BaseTool{guardedTool}, MaxSteps: 4}, []*schema.Message{schema.UserMessage("查询客户")})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if !called || result == nil || result.Content != "已找到客户资料。" || model.calls != 2 {
|
||||
t.Fatalf("unexpected ReAct result: called=%t result=%#v modelCalls=%d", called, result, model.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunInjectsProvidedConversationContext(t *testing.T) {
|
||||
model := &scriptedToolCallingModel{responses: []*schema.Message{schema.AssistantMessage("已理解上下文。", nil)}}
|
||||
input := []*schema.Message{
|
||||
schema.SystemMessage("你是客服助手,优先引用知识库。"),
|
||||
schema.UserMessage("我的订单状态如何?"),
|
||||
}
|
||||
if _, err := Run(context.Background(), ReActConfig{Model: model}, input); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if len(model.lastInput) != len(input) || model.lastInput[0].Content != input[0].Content || model.lastInput[1].Content != input[1].Content {
|
||||
t.Fatalf("conversation context was not passed to model: %#v", model.lastInput)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewOpenAICompatibleModelValidatesExistingAIConfig(t *testing.T) {
|
||||
if _, err := NewOpenAICompatibleModel(context.Background(), models.AIConfig{}); err == nil {
|
||||
t.Fatal("expected incomplete AI config error")
|
||||
}
|
||||
configured, err := NewOpenAICompatibleModel(context.Background(), models.AIConfig{
|
||||
BaseURL: "https://api.example.test/v1", APIKey: "test-key", ModelName: "test-model", TimeoutMS: 1200, MaxOutputTokens: 256,
|
||||
})
|
||||
if err != nil || configured == nil {
|
||||
t.Fatalf("expected OpenAI-compatible model adapter, model=%#v err=%v", configured, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuardedToolRejectsDisallowedPolicyBeforeHandler(t *testing.T) {
|
||||
called := false
|
||||
guardedTool := &GuardedTool{
|
||||
InfoDefinition: &schema.ToolInfo{Name: "restricted_lookup", Desc: "Read restricted data"},
|
||||
Definition: aitooling.Definition{Code: "builtin/restricted_lookup", RiskLevel: aitooling.RiskLevelRead},
|
||||
Policy: aitooling.Policy{AllowedToolCodes: []string{"builtin/customer_lookup"}},
|
||||
Handler: func(context.Context, map[string]any) (string, error) {
|
||||
called = true
|
||||
return "unexpected", nil
|
||||
},
|
||||
}
|
||||
if _, err := guardedTool.InvokableRun(context.Background(), `{}`); err == nil {
|
||||
t.Fatal("expected policy rejection")
|
||||
}
|
||||
if called {
|
||||
t.Fatal("handler must not run after policy rejection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPropagatesCancellation(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
model := &scriptedToolCallingModel{responses: []*schema.Message{schema.AssistantMessage("unused", nil)}}
|
||||
if _, err := Run(ctx, ReActConfig{Model: model}, []*schema.Message{schema.UserMessage("查询")}); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("expected cancellation, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPropagatesDeadlineDuringModelCall(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
|
||||
defer cancel()
|
||||
model := &scriptedToolCallingModel{block: true}
|
||||
if _, err := Run(ctx, ReActConfig{Model: model}, []*schema.Message{schema.UserMessage("查询")}); !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("expected deadline propagation, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPropagatesModelFailure(t *testing.T) {
|
||||
modelErr := errors.New("model unavailable")
|
||||
model := &scriptedToolCallingModel{err: modelErr}
|
||||
if _, err := Run(context.Background(), ReActConfig{Model: model}, []*schema.Message{schema.UserMessage("查询")}); !errors.Is(err, modelErr) {
|
||||
t.Fatalf("expected model error propagation, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPropagatesToolFailure(t *testing.T) {
|
||||
toolErr := errors.New("customer service unavailable")
|
||||
guardedTool := &GuardedTool{
|
||||
InfoDefinition: &schema.ToolInfo{Name: "failing_lookup", Desc: "Read customer data"},
|
||||
Definition: aitooling.Definition{Code: "builtin/failing_lookup", RiskLevel: aitooling.RiskLevelRead},
|
||||
Policy: aitooling.Policy{AllowedToolCodes: []string{"builtin/failing_lookup"}},
|
||||
Handler: func(context.Context, map[string]any) (string, error) {
|
||||
return "", toolErr
|
||||
},
|
||||
}
|
||||
model := &scriptedToolCallingModel{responses: []*schema.Message{
|
||||
schema.AssistantMessage("", []schema.ToolCall{{ID: "call-1", Type: "function", Function: schema.FunctionCall{Name: "failing_lookup", Arguments: `{}`}}}),
|
||||
}}
|
||||
if _, err := Run(context.Background(), ReActConfig{Model: model, Tools: []tool.BaseTool{guardedTool}}, []*schema.Message{schema.UserMessage("查询")}); !errors.Is(err, toolErr) {
|
||||
t.Fatalf("expected tool error propagation, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuardedToolEnforcesTimeout(t *testing.T) {
|
||||
guardedTool := &GuardedTool{
|
||||
InfoDefinition: &schema.ToolInfo{Name: "slow_lookup", Desc: "Read customer data"},
|
||||
Definition: aitooling.Definition{Code: "builtin/slow_lookup", RiskLevel: aitooling.RiskLevelRead, TimeoutMS: 20},
|
||||
Policy: aitooling.Policy{AllowedToolCodes: []string{"builtin/slow_lookup"}},
|
||||
Handler: func(ctx context.Context, _ map[string]any) (string, error) {
|
||||
<-ctx.Done()
|
||||
return "", ctx.Err()
|
||||
},
|
||||
}
|
||||
if _, err := guardedTool.InvokableRun(context.Background(), `{}`); !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("expected tool timeout, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuardedToolEmitsTraceForPolicyFailure(t *testing.T) {
|
||||
var trace ToolTrace
|
||||
guardedTool := &GuardedTool{
|
||||
InfoDefinition: &schema.ToolInfo{Name: "restricted_lookup", Desc: "Read restricted data"},
|
||||
Definition: aitooling.Definition{Code: "builtin/restricted_lookup", RiskLevel: aitooling.RiskLevelRead},
|
||||
Policy: aitooling.Policy{AllowedToolCodes: []string{"builtin/other_lookup"}},
|
||||
Handler: func(context.Context, map[string]any) (string, error) {
|
||||
return "unexpected", nil
|
||||
},
|
||||
Trace: func(item ToolTrace) { trace = item },
|
||||
}
|
||||
if _, err := guardedTool.InvokableRun(context.Background(), `{"customerId":"42"}`); err == nil {
|
||||
t.Fatal("expected policy rejection")
|
||||
}
|
||||
if trace.ToolCode != "builtin/restricted_lookup" || trace.Status != "failed" || trace.Err == nil || trace.Arguments["customerId"] != "42" || trace.Duration < 0 {
|
||||
t.Fatalf("unexpected trace: %#v", trace)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPToolHandlerUsesSharedExecutorAndReducesResult(t *testing.T) {
|
||||
executor := &fakeMCPToolExecutor{result: &mcps.ToolCallResult{Content: []mcps.ToolResultContent{{Type: "text", Text: "customer: Ada"}}}}
|
||||
policy := aitooling.Policy{AllowedToolCodes: []string{"crm/customer_lookup"}, Confirmed: true}
|
||||
handler := NewMCPToolHandler(executor, "crm/customer_lookup", policy)
|
||||
result, err := handler(context.Background(), map[string]any{"customerId": "42"})
|
||||
if err != nil || result != "customer: Ada" {
|
||||
t.Fatalf("unexpected MCP handler result=%q err=%v", result, err)
|
||||
}
|
||||
if executor.toolCode != "crm/customer_lookup" || executor.arguments["customerId"] != "42" || !executor.policy.Confirmed {
|
||||
t.Fatalf("unexpected MCP execution: %#v", executor)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunStopsAtConfiguredMaxSteps(t *testing.T) {
|
||||
guardedTool := &GuardedTool{
|
||||
InfoDefinition: &schema.ToolInfo{Name: "loop_lookup", Desc: "Read loop data"},
|
||||
Definition: aitooling.Definition{Code: "builtin/loop_lookup", RiskLevel: aitooling.RiskLevelRead},
|
||||
Policy: aitooling.Policy{AllowedToolCodes: []string{"builtin/loop_lookup"}},
|
||||
Handler: func(context.Context, map[string]any) (string, error) {
|
||||
return "keep going", nil
|
||||
},
|
||||
}
|
||||
responses := make([]*schema.Message, 8)
|
||||
for i := range responses {
|
||||
responses[i] = schema.AssistantMessage("", []schema.ToolCall{{
|
||||
ID: "loop-call", Type: "function", Function: schema.FunctionCall{Name: "loop_lookup", Arguments: `{}`},
|
||||
}})
|
||||
}
|
||||
model := &scriptedToolCallingModel{responses: responses}
|
||||
if _, err := Run(context.Background(), ReActConfig{Model: model, Tools: []tool.BaseTool{guardedTool}, MaxSteps: 2}, []*schema.Message{schema.UserMessage("循环查询")}); err == nil {
|
||||
t.Fatal("expected configured maximum step limit to stop the loop")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamReturnsModelOutput(t *testing.T) {
|
||||
model := &scriptedToolCallingModel{responses: []*schema.Message{schema.AssistantMessage("流式回复", nil)}}
|
||||
stream, err := Stream(context.Background(), ReActConfig{Model: model}, []*schema.Message{schema.UserMessage("查询")})
|
||||
if err != nil {
|
||||
t.Fatalf("Stream: %v", err)
|
||||
}
|
||||
defer stream.Close()
|
||||
result, err := schema.ConcatMessageStream(stream)
|
||||
if err != nil {
|
||||
t.Fatalf("ConcatMessageStream: %v", err)
|
||||
}
|
||||
if result.Content != "流式回复" {
|
||||
t.Fatalf("unexpected stream result: %#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamPropagatesCancellation(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
model := &scriptedToolCallingModel{responses: []*schema.Message{schema.AssistantMessage("unused", nil)}}
|
||||
if _, err := Stream(ctx, ReActConfig{Model: model}, []*schema.Message{schema.UserMessage("查询")}); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("expected stream cancellation, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSupportsConcurrentIndependentCalls(t *testing.T) {
|
||||
model := &concurrentToolCallingModel{}
|
||||
const workers = 16
|
||||
errs := make(chan error, workers)
|
||||
var group sync.WaitGroup
|
||||
for range workers {
|
||||
group.Add(1)
|
||||
go func() {
|
||||
defer group.Done()
|
||||
result, err := Run(context.Background(), ReActConfig{Model: model, MaxSteps: 3}, []*schema.Message{schema.UserMessage("并发查询")})
|
||||
if err != nil {
|
||||
errs <- err
|
||||
return
|
||||
}
|
||||
if result == nil || result.Content != "并发调用完成。" {
|
||||
errs <- errors.New("unexpected concurrent result")
|
||||
}
|
||||
}()
|
||||
}
|
||||
group.Wait()
|
||||
close(errs)
|
||||
for err := range errs {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if model.calls.Load() != workers {
|
||||
t.Fatalf("model calls = %d, want %d", model.calls.Load(), workers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfirmationBridgeUsesGenericInterruptAndResumeContracts(t *testing.T) {
|
||||
input := applicationruntime.RunInput{
|
||||
Conversation: models.Conversation{ID: 11}, UserMessage: models.Message{ID: 22},
|
||||
}
|
||||
result, err := BuildConfirmationResult(input, ConfirmationRequest{
|
||||
InterruptID: "confirm_refund", ToolCode: "graph/create_ticket_with_confirmation", Prompt: "是否确认提交退款工单?",
|
||||
Arguments: map[string]any{"title": "退款申请"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildConfirmationResult: %v", err)
|
||||
}
|
||||
if !result.Interrupted || result.Status != "interrupted" || result.CheckPointID == "" || len(result.Interrupts) != 1 || result.Interrupts[0].Type != confirmationInterruptType || result.Interrupts[0].ID != "confirm_refund" {
|
||||
t.Fatalf("unexpected confirmation result: %#v", result)
|
||||
}
|
||||
decision, checkpoint, err := ResumeConfirmation(result.CheckPointData, applicationruntime.ResumeInput{ResumeData: map[string]string{"confirm_refund": "确认"}})
|
||||
if err != nil || decision != string(graphs.ConfirmationDecisionConfirm) || checkpoint.ToolCode != "graph/create_ticket_with_confirmation" || checkpoint.Arguments["title"] != "退款申请" {
|
||||
t.Fatalf("unexpected resume bridge decision=%q checkpoint=%#v err=%v", decision, checkpoint, err)
|
||||
}
|
||||
decision, _, err = ResumeConfirmation(result.CheckPointData, applicationruntime.ResumeInput{ResumeData: map[string]string{"confirm_refund": "取消"}})
|
||||
if err != nil || decision != string(graphs.ConfirmationDecisionCancel) {
|
||||
t.Fatalf("unexpected cancellation decision=%q err=%v", decision, err)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkRunWithInjectedModel(b *testing.B) {
|
||||
model := &concurrentToolCallingModel{}
|
||||
input := []*schema.Message{schema.SystemMessage("你是客服助手。"), schema.UserMessage("查询订单状态")}
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for range b.N {
|
||||
result, err := Run(context.Background(), ReActConfig{Model: model, MaxSteps: 3}, input)
|
||||
if err != nil || result == nil || result.Content == "" {
|
||||
b.Fatalf("Run result=%#v err=%v", result, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package einoexperiment
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"agent-desk/internal/bootstrap"
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/config"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
|
||||
einoopenai "github.com/cloudwego/eino-ext/components/model/openai"
|
||||
einomodel "github.com/cloudwego/eino/components/model"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
// TestRealOpenAICompatibleEndpoint is intentionally opt-in because it spends
|
||||
// a small amount of configured model quota. It verifies the production-shaped
|
||||
// OpenAI-compatible adapter without exposing credentials in test output.
|
||||
func TestRealOpenAICompatibleEndpoint(t *testing.T) {
|
||||
if os.Getenv("EINO_EXPERIMENT_REAL") != "1" {
|
||||
t.Skip("set EINO_EXPERIMENT_REAL=1 to run against the configured endpoint")
|
||||
}
|
||||
configPath := strings.TrimSpace(os.Getenv("EINO_EXPERIMENT_CONFIG"))
|
||||
var err error
|
||||
if configPath == "" {
|
||||
configPath, err = findExperimentConfigPath()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
workingDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("get working directory: %v", err)
|
||||
}
|
||||
repoRoot := filepath.Dir(filepath.Dir(configPath))
|
||||
if err := os.Chdir(repoRoot); err != nil {
|
||||
t.Fatalf("change to config root: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(workingDir) })
|
||||
cfg, err := config.Load(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("load config: %v", err)
|
||||
}
|
||||
db, err := bootstrap.InitDB(cfg.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("open configured database: %v", err)
|
||||
}
|
||||
sqlDB, err := db.DB()
|
||||
if err == nil {
|
||||
defer sqlDB.Close()
|
||||
}
|
||||
var aiConfig models.AIConfig
|
||||
if err := db.Where("model_type = ? AND status = ?", enums.AIModelTypeLLM, enums.StatusOk).Order("id").First(&aiConfig).Error; err != nil {
|
||||
t.Fatalf("load enabled LLM config: %v", err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(maxInt(aiConfig.TimeoutMS, 30000))*time.Millisecond)
|
||||
defer cancel()
|
||||
model, err := NewOpenAICompatibleModel(ctx, aiConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("create Eino model adapter: %v", err)
|
||||
}
|
||||
input := []*schema.Message{schema.SystemMessage("You are a terse service assistant."), schema.UserMessage("Reply with exactly: OK")}
|
||||
startedAt := time.Now()
|
||||
result, err := Run(ctx, ReActConfig{Model: model, MaxSteps: 2}, input)
|
||||
if err != nil {
|
||||
t.Fatalf("Eino ReAct request: %v", err)
|
||||
}
|
||||
if result == nil || strings.TrimSpace(result.Content) == "" {
|
||||
t.Fatal("Eino endpoint returned an empty response")
|
||||
}
|
||||
if result.ResponseMeta == nil || result.ResponseMeta.Usage == nil {
|
||||
t.Fatal("Eino endpoint did not return token usage")
|
||||
}
|
||||
t.Logf("real endpoint verified: latency=%s promptTokens=%d completionTokens=%d", time.Since(startedAt).Round(time.Millisecond), result.ResponseMeta.Usage.PromptTokens, result.ResponseMeta.Usage.CompletionTokens)
|
||||
|
||||
toolModel, err := model.WithTools([]*schema.ToolInfo{{
|
||||
Name: "eino_echo",
|
||||
Desc: "Echoes a short input. Always call this tool when asked to verify tool calling.",
|
||||
ParamsOneOf: schema.NewParamsOneOfByParams(map[string]*schema.ParameterInfo{
|
||||
"text": {Type: schema.String, Desc: "Short text to echo", Required: true},
|
||||
}),
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatalf("bind Eino tool: %v", err)
|
||||
}
|
||||
toolResult, err := toolModel.Generate(ctx, []*schema.Message{schema.UserMessage("Verify tool calling by invoking eino_echo with text OK.")},
|
||||
einomodel.WithToolChoice(schema.ToolChoiceForced, "eino_echo"),
|
||||
einoopenai.WithExtraFields(map[string]any{"enable_thinking": false}),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("real endpoint tool call: %v", err)
|
||||
}
|
||||
if toolResult == nil || len(toolResult.ToolCalls) != 1 || toolResult.ToolCalls[0].Function.Name != "eino_echo" {
|
||||
t.Fatalf("expected one eino_echo tool call, got %#v", toolResult)
|
||||
}
|
||||
|
||||
stream, err := model.Stream(ctx, []*schema.Message{schema.UserMessage("Reply with exactly: STREAM_OK")})
|
||||
if err != nil {
|
||||
t.Fatalf("real endpoint stream: %v", err)
|
||||
}
|
||||
// ConcatMessageStream consumes and closes the Eino reader. Do not close it
|
||||
// again here: v0.9.6 treats a second close as a panic.
|
||||
streamResult, err := schema.ConcatMessageStream(stream)
|
||||
if err != nil {
|
||||
t.Fatalf("concat real stream: %v", err)
|
||||
}
|
||||
if streamResult == nil || strings.TrimSpace(streamResult.Content) == "" {
|
||||
t.Fatal("Eino endpoint stream returned an empty response")
|
||||
}
|
||||
t.Logf("real endpoint tool and stream verified: toolCalls=%d streamChars=%d", len(toolResult.ToolCalls), len([]rune(streamResult.Content)))
|
||||
}
|
||||
|
||||
func findExperimentConfigPath() (string, error) {
|
||||
dir, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for {
|
||||
candidate := filepath.Join(dir, "config", "config.yaml")
|
||||
if _, err := os.Stat(candidate); err == nil {
|
||||
return candidate, nil
|
||||
}
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
return "", os.ErrNotExist
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
}
|
||||
|
||||
func maxInt(value, fallback int) int {
|
||||
if value > 0 {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"agent-desk/internal/pkg/errorsx"
|
||||
)
|
||||
|
||||
const (
|
||||
EngineCodeWorkflow = "workflow"
|
||||
EngineCodeAutonomous = "autonomous"
|
||||
)
|
||||
|
||||
// Engine executes one Agent Runtime mode. Implementations must keep business
|
||||
// mutations behind AgentDesk services and return a normalized RunResult.
|
||||
type Engine interface {
|
||||
Code() string
|
||||
Run(ctx context.Context, req RunInput) (*RunResult, error)
|
||||
Resume(ctx context.Context, req ResumeInput) (*RunResult, error)
|
||||
}
|
||||
|
||||
// EngineRegistry resolves the runtime implementation. Workflow is the default
|
||||
// until Agent runtime modes are persisted on AIAgent in the next migration.
|
||||
type EngineRegistry struct {
|
||||
engines map[string]Engine
|
||||
}
|
||||
|
||||
func NewEngineRegistry(engines ...Engine) *EngineRegistry {
|
||||
registry := &EngineRegistry{engines: make(map[string]Engine, len(engines))}
|
||||
for _, engine := range engines {
|
||||
if engine == nil || strings.TrimSpace(engine.Code()) == "" {
|
||||
continue
|
||||
}
|
||||
registry.engines[strings.TrimSpace(engine.Code())] = engine
|
||||
}
|
||||
return registry
|
||||
}
|
||||
|
||||
func NewDefaultEngineRegistry() *EngineRegistry {
|
||||
return NewEngineRegistry(NewWorkflowEngine(), NewAutonomousEngine(), NewHybridEngine())
|
||||
}
|
||||
|
||||
func (r *EngineRegistry) Resolve(code string) (Engine, error) {
|
||||
if r == nil {
|
||||
return nil, errors.New("agent runtime engine registry is not configured")
|
||||
}
|
||||
code = strings.TrimSpace(code)
|
||||
if code == "" {
|
||||
code = EngineCodeWorkflow
|
||||
}
|
||||
engine := r.engines[code]
|
||||
if engine == nil {
|
||||
return nil, errorsx.InvalidParam("agent runtime engine does not exist")
|
||||
}
|
||||
return engine, nil
|
||||
}
|
||||
@@ -0,0 +1,550 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
ai "agent-desk/internal/ai"
|
||||
"agent-desk/internal/ai/skills"
|
||||
aitooling "agent-desk/internal/ai/tooling"
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
"agent-desk/internal/pkg/toolx"
|
||||
svc "agent-desk/internal/services"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/schema"
|
||||
)
|
||||
|
||||
func TestServiceDefaultsToWorkflowEngine(t *testing.T) {
|
||||
service := NewService()
|
||||
engine, err := service.registry.Resolve("")
|
||||
if err != nil {
|
||||
t.Fatalf("resolve default engine: %v", err)
|
||||
}
|
||||
if engine.Code() != EngineCodeWorkflow {
|
||||
t.Fatalf("expected default engine %q, got %q", EngineCodeWorkflow, engine.Code())
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceDispatchesRequestedEngine(t *testing.T) {
|
||||
engine := &runtimeTestEngine{code: "test"}
|
||||
service := NewServiceWithRegistry(NewEngineRegistry(engine))
|
||||
summary, err := service.Run(context.Background(), RunInput{AIAgent: models.AIAgent{RuntimeMode: enums.AIAgentRuntimeMode(engine.code)}})
|
||||
if err != nil {
|
||||
t.Fatalf("run requested engine: %v", err)
|
||||
}
|
||||
if !engine.ran || summary == nil || summary.Status != "completed" {
|
||||
t.Fatalf("unexpected engine dispatch result: engine=%#v summary=%#v", engine, summary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngineContractKeepsLegacyRequestAliasesCompatible(t *testing.T) {
|
||||
var _ Engine = (*runtimeTestEngine)(nil)
|
||||
var input Request = RunInput{}
|
||||
var result Summary = RunResult{Status: "completed"}
|
||||
if input.Debug || result.Status != "completed" {
|
||||
t.Fatalf("unexpected compatibility values: input=%#v result=%#v", input, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceRejectsUnknownEngine(t *testing.T) {
|
||||
service := NewServiceWithRegistry(NewEngineRegistry())
|
||||
if _, err := service.Run(context.Background(), Request{AIAgent: models.AIAgent{RuntimeMode: "missing"}}); err == nil {
|
||||
t.Fatal("expected unknown engine error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutonomousEngineRecordsPublishedRevisionRun(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true}})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&models.AgentRevision{}, &models.AgentRun{}, &models.AgentStep{}); err != nil {
|
||||
t.Fatalf("auto migrate: %v", err)
|
||||
}
|
||||
sqls.SetDB(db)
|
||||
revision := &models.AgentRevision{AgentID: 7, Revision: 1}
|
||||
if err := db.Create(revision).Error; err != nil {
|
||||
t.Fatalf("create revision: %v", err)
|
||||
}
|
||||
var receivedPrompt string
|
||||
engine := newAutonomousEngineWithChat(func(_ context.Context, _ models.AIConfig, _ string, prompt string) (*ai.ChatCompletionResult, error) {
|
||||
receivedPrompt = prompt
|
||||
return &ai.ChatCompletionResult{Content: "可以协助你处理这个问题。", ModelName: "test-model", PromptTokens: 8, CompletionTokens: 5}, nil
|
||||
})
|
||||
engine.retrieve = func(context.Context, models.AIAgent, string) (string, int, error) {
|
||||
return "退款需要先确认订单号。", 1, nil
|
||||
}
|
||||
summary, err := engine.Run(context.Background(), Request{
|
||||
Conversation: models.Conversation{ID: 1}, UserMessage: models.Message{ID: 2, Content: "需要帮助"},
|
||||
AIAgent: models.AIAgent{ID: 7, PublishedRevisionID: revision.ID, SystemPrompt: "保持专业", KnowledgeIDs: "21"}, AIConfig: models.AIConfig{ModelName: "test-model"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run returned error: %v", err)
|
||||
}
|
||||
if summary == nil || summary.AgentRunID <= 0 || summary.ReplyText == "" {
|
||||
t.Fatalf("unexpected summary: %#v", summary)
|
||||
}
|
||||
var run models.AgentRun
|
||||
if err := db.First(&run, summary.AgentRunID).Error; err != nil {
|
||||
t.Fatalf("load agent run: %v", err)
|
||||
}
|
||||
if run.EngineCode != EngineCodeAutonomous || run.AgentRevisionID != revision.ID || run.Status != "completed" {
|
||||
t.Fatalf("unexpected agent run: %#v", run)
|
||||
}
|
||||
if run.PromptTokens != 8 || !strings.Contains(receivedPrompt, "Knowledge evidence") {
|
||||
t.Fatalf("expected knowledge evidence in prompt, got %q", receivedPrompt)
|
||||
}
|
||||
var steps []models.AgentStep
|
||||
if err := db.Where("agent_run_id = ?", run.ID).Find(&steps).Error; err != nil || len(steps) != 2 || steps[1].StepType != "knowledge" {
|
||||
t.Fatalf("expected model and knowledge steps, steps=%#v err=%v", steps, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutonomousEngineRecordsRejectedReplyAsFailed(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true}})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&models.AgentRevision{}, &models.AgentRun{}, &models.AgentStep{}); err != nil {
|
||||
t.Fatalf("auto migrate: %v", err)
|
||||
}
|
||||
sqls.SetDB(db)
|
||||
revision := &models.AgentRevision{AgentID: 15, Revision: 1}
|
||||
if err := db.Create(revision).Error; err != nil {
|
||||
t.Fatalf("create revision: %v", err)
|
||||
}
|
||||
engine := newAutonomousEngineWithChat(func(context.Context, models.AIConfig, string, string) (*ai.ChatCompletionResult, error) {
|
||||
return &ai.ChatCompletionResult{Content: "token=secret-value"}, nil
|
||||
})
|
||||
_, err = engine.Run(context.Background(), Request{UserMessage: models.Message{ID: 2, Content: "help"}, AIAgent: models.AIAgent{ID: 15, PublishedRevisionID: revision.ID}})
|
||||
if err == nil {
|
||||
t.Fatal("expected sensitive model reply to be rejected")
|
||||
}
|
||||
var run models.AgentRun
|
||||
if err := db.Last(&run).Error; err != nil || run.Status != "failed" || strings.Contains(run.ErrorMessage, "secret-value") {
|
||||
t.Fatalf("expected failed audit run, run=%#v err=%v", run, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutonomousEngineBuildsBoundedConversationContext(t *testing.T) {
|
||||
engine := newAutonomousEngineWithChat(nil)
|
||||
engine.history = func(conversationID int64, limit int) []models.Message {
|
||||
if conversationID != 11 || limit != 3 {
|
||||
t.Fatalf("unexpected history query: conversation=%d limit=%d", conversationID, limit)
|
||||
}
|
||||
return []models.Message{
|
||||
{ID: 1, SenderType: "customer", MessageType: "text", Content: "之前的问题"},
|
||||
{ID: 2, SenderType: "ai", MessageType: "text", Content: "之前的答复"},
|
||||
{ID: 3, SenderType: "customer", MessageType: "text", Content: "当前问题"},
|
||||
}
|
||||
}
|
||||
prompt, count := engine.buildUserPrompt(Request{
|
||||
Conversation: models.Conversation{ID: 11}, UserMessage: models.Message{ID: 3, Content: "当前问题", MessageType: "text"},
|
||||
AIAgent: models.AIAgent{ContextWindow: 2},
|
||||
})
|
||||
if count != 2 || !strings.Contains(prompt, "Customer: 之前的问题") || !strings.Contains(prompt, "Assistant: 之前的答复") || strings.Count(prompt, "当前问题") != 1 {
|
||||
t.Fatalf("unexpected assembled prompt: %q", prompt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutonomousEngineBuildsCustomerContext(t *testing.T) {
|
||||
engine := newAutonomousEngineWithChat(nil)
|
||||
prompt, count := engine.buildUserPrompt(Request{
|
||||
Conversation: models.Conversation{CustomerName: "张三", LastMessageSummary: "已咨询退款条件"},
|
||||
UserMessage: models.Message{Content: "我要申请退款", MessageType: "text"},
|
||||
})
|
||||
if count != 0 || !strings.Contains(prompt, "Customer: 张三") || !strings.Contains(prompt, "Recent summary: 已咨询退款条件") || !strings.Contains(prompt, "Current customer message:\n我要申请退款") {
|
||||
t.Fatalf("unexpected customer context: %q", prompt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutonomousEngineInjectsSelectedSkillAndRecordsRoute(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true}})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&models.AgentRevision{}, &models.AgentRun{}, &models.AgentStep{}); err != nil {
|
||||
t.Fatalf("auto migrate: %v", err)
|
||||
}
|
||||
sqls.SetDB(db)
|
||||
revision := &models.AgentRevision{AgentID: 9, Revision: 1}
|
||||
if err := db.Create(revision).Error; err != nil {
|
||||
t.Fatalf("create revision: %v", err)
|
||||
}
|
||||
var systemPrompt string
|
||||
engine := newAutonomousEngineWithChat(func(_ context.Context, _ models.AIConfig, system, _ string) (*ai.ChatCompletionResult, error) {
|
||||
systemPrompt = system
|
||||
return &ai.ChatCompletionResult{Content: "我来协助处理退款。", ModelName: "test-model"}, nil
|
||||
})
|
||||
engine.skillSelect = func(context.Context, skills.RuntimeContext) (*skills.ExecutionResult, error) {
|
||||
return &skills.ExecutionResult{Plan: &skills.ExecutionPlan{
|
||||
Skill: &models.SkillDefinition{ID: 70, Name: "退款处理", Instruction: "先核对订单信息。", Examples: `["我要退款"]`, ToolWhitelist: `["support/order_lookup"]`},
|
||||
MatchReason: "llm_route",
|
||||
}, Trace: &skills.ExecutionTrace{Status: "ok", MatchReason: "llm_route"}}, nil
|
||||
}
|
||||
summary, err := engine.Run(context.Background(), Request{
|
||||
Conversation: models.Conversation{ID: 1}, UserMessage: models.Message{ID: 2, Content: "我要退款"},
|
||||
AIAgent: models.AIAgent{ID: 9, PublishedRevisionID: revision.ID, SkillIDs: "70", SystemPrompt: "保持简洁"}, AIConfig: models.AIConfig{ModelName: "test-model"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run returned error: %v", err)
|
||||
}
|
||||
if summary.PlannedSkillID != 70 || summary.PlannedSkillName != "退款处理" || summary.PlanReason != "llm_route" {
|
||||
t.Fatalf("unexpected skill summary: %#v", summary)
|
||||
}
|
||||
if !strings.Contains(systemPrompt, "先核对订单信息") || !strings.Contains(systemPrompt, "我要退款") {
|
||||
t.Fatalf("selected skill was not injected into system prompt: %q", systemPrompt)
|
||||
}
|
||||
var steps []models.AgentStep
|
||||
if err := db.Where("agent_run_id = ?", summary.AgentRunID).Find(&steps).Error; err != nil {
|
||||
t.Fatalf("load steps: %v", err)
|
||||
}
|
||||
if len(steps) != 2 || steps[1].StepType != "skill_route" || steps[1].StepCode != "skill_select" {
|
||||
t.Fatalf("expected model and skill route audit steps, got %#v", steps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutonomousEngineEnforcesKnowledgeFallbackPolicy(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true}})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&models.AgentRevision{}, &models.AgentRun{}, &models.AgentStep{}); err != nil {
|
||||
t.Fatalf("auto migrate: %v", err)
|
||||
}
|
||||
sqls.SetDB(db)
|
||||
revision := &models.AgentRevision{AgentID: 10, Revision: 1}
|
||||
if err := db.Create(revision).Error; err != nil {
|
||||
t.Fatalf("create revision: %v", err)
|
||||
}
|
||||
chatCalled := false
|
||||
engine := newAutonomousEngineWithChat(func(context.Context, models.AIConfig, string, string) (*ai.ChatCompletionResult, error) {
|
||||
chatCalled = true
|
||||
return &ai.ChatCompletionResult{Content: "should not be used"}, nil
|
||||
})
|
||||
engine.retrieve = func(context.Context, models.AIAgent, string) (string, int, error) {
|
||||
return "", 0, nil
|
||||
}
|
||||
summary, err := engine.Run(context.Background(), Request{
|
||||
Conversation: models.Conversation{ID: 1}, UserMessage: models.Message{ID: 2, Content: "保修多久"},
|
||||
AIAgent: models.AIAgent{ID: 10, PublishedRevisionID: revision.ID, KnowledgeIDs: "100", FallbackMessage: "请提供产品型号,我再继续查询。"},
|
||||
AIConfig: models.AIConfig{ModelName: "test-model"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run returned error: %v", err)
|
||||
}
|
||||
if chatCalled || summary.ReplyText != "请提供产品型号,我再继续查询。" {
|
||||
t.Fatalf("knowledge fallback policy was not enforced: chatCalled=%t summary=%#v", chatCalled, summary)
|
||||
}
|
||||
var steps []models.AgentStep
|
||||
if err := db.Where("agent_run_id = ?", summary.AgentRunID).Find(&steps).Error; err != nil {
|
||||
t.Fatalf("load steps: %v", err)
|
||||
}
|
||||
if len(steps) != 3 || steps[1].StepType != "knowledge" || steps[2].StepType != "policy" || steps[2].StepCode != "knowledge_evidence" {
|
||||
t.Fatalf("expected model, knowledge and policy steps, got %#v", steps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutonomousEngineDebugRunDoesNotExposeMCPTools(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true}})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&models.AgentRevision{}, &models.AgentRun{}, &models.AgentStep{}); err != nil {
|
||||
t.Fatalf("auto migrate: %v", err)
|
||||
}
|
||||
sqls.SetDB(db)
|
||||
revision := &models.AgentRevision{AgentID: 11, Revision: 1}
|
||||
if err := db.Create(revision).Error; err != nil {
|
||||
t.Fatalf("create revision: %v", err)
|
||||
}
|
||||
engine := newAutonomousEngineWithChat(func(context.Context, models.AIConfig, string, string) (*ai.ChatCompletionResult, error) {
|
||||
return &ai.ChatCompletionResult{Content: "调试回复", ModelName: "test-model"}, nil
|
||||
})
|
||||
engine.toolChat = func(context.Context, models.AIConfig, string, string, []ai.ToolDefinition, int, ai.ToolCallExecutor) (*ai.ToolLoopResult, error) {
|
||||
t.Fatal("debug run must not enter tool calling loop")
|
||||
return nil, nil
|
||||
}
|
||||
summary, err := engine.Run(context.Background(), Request{
|
||||
Conversation: models.Conversation{ID: 1}, UserMessage: models.Message{ID: 2, Content: "查询订单"},
|
||||
AIAgent: models.AIAgent{ID: 11, PublishedRevisionID: revision.ID, AllowedMCPTools: `[{"toolCode":"orders/lookup"}]`},
|
||||
AIConfig: models.AIConfig{ModelName: "test-model"}, Debug: true,
|
||||
})
|
||||
if err != nil || summary == nil || summary.ReplyText != "调试回复" {
|
||||
t.Fatalf("unexpected debug run result: summary=%#v err=%v", summary, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutonomousEngineUsesPublishedRevisionSnapshot(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true}})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&models.AgentRevision{}, &models.AgentRun{}, &models.AgentStep{}); err != nil {
|
||||
t.Fatalf("auto migrate: %v", err)
|
||||
}
|
||||
sqls.SetDB(db)
|
||||
revision := &models.AgentRevision{AgentID: 12, Revision: 1, Definition: `{"agent":{"name":"published","aiConfigId":5,"runtimeMode":"autonomous","maxSteps":4,"systemPrompt":"published instruction"},"model":{"configId":5,"provider":"openai","baseUrl":"https://published.example/v1","modelType":"llm","modelName":"published-model","timeoutMs":12000}}`}
|
||||
if err := db.Create(revision).Error; err != nil {
|
||||
t.Fatalf("create revision: %v", err)
|
||||
}
|
||||
var receivedSystem string
|
||||
var receivedConfig models.AIConfig
|
||||
engine := newAutonomousEngineWithChat(func(_ context.Context, config models.AIConfig, system, _ string) (*ai.ChatCompletionResult, error) {
|
||||
receivedSystem = system
|
||||
receivedConfig = config
|
||||
return &ai.ChatCompletionResult{Content: "published response", ModelName: config.ModelName}, nil
|
||||
})
|
||||
_, err = engine.Run(context.Background(), Request{
|
||||
Conversation: models.Conversation{ID: 1}, UserMessage: models.Message{ID: 2, Content: "hello"},
|
||||
AIAgent: models.AIAgent{ID: 12, PublishedRevisionID: revision.ID, SystemPrompt: "draft instruction", AIConfigID: 5},
|
||||
AIConfig: models.AIConfig{ID: 5, APIKey: "rotated-key", ModelName: "draft-model"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run returned error: %v", err)
|
||||
}
|
||||
if !strings.Contains(receivedSystem, "published instruction") || strings.Contains(receivedSystem, "draft instruction") {
|
||||
t.Fatalf("system prompt did not use published snapshot: %q", receivedSystem)
|
||||
}
|
||||
if receivedConfig.ModelName != "published-model" || receivedConfig.BaseURL != "https://published.example/v1" || receivedConfig.APIKey != "rotated-key" {
|
||||
t.Fatalf("model config did not use safe published snapshot: %#v", receivedConfig)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHybridEngineUsesBoundPlaybookAndRecordsGenericAudit(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true}})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&models.AgentRevision{}, &models.AIWorkflowVersion{}, &models.AgentRun{}, &models.AgentStep{}); err != nil {
|
||||
t.Fatalf("auto migrate: %v", err)
|
||||
}
|
||||
sqls.SetDB(db)
|
||||
workflowVersion := &models.AIWorkflowVersion{WorkflowID: 21, Version: 1, Status: enums.StatusOk, Definition: `{"schemaVersion":2,"nodes":[{"id":"start_1","type":"start"},{"id":"end_1","type":"end"}],"edges":[{"sourceNodeID":"start_1","targetNodeID":"end_1"}]}`}
|
||||
if err := db.Create(workflowVersion).Error; err != nil {
|
||||
t.Fatalf("create workflow version: %v", err)
|
||||
}
|
||||
revision := &models.AgentRevision{AgentID: 14, Revision: 1, Status: enums.StatusOk, WorkflowVersionID: workflowVersion.ID, Definition: `{"agent":{"runtimeMode":"hybrid","systemPrompt":"published hybrid prompt","maxSteps":3},"workflowVersionId":1}`}
|
||||
if err := db.Create(revision).Error; err != nil {
|
||||
t.Fatalf("create revision: %v", err)
|
||||
}
|
||||
engine := NewHybridEngine()
|
||||
engine.chatWithTools = func(_ context.Context, _ models.AIConfig, system, _ string, definitions []ai.ToolDefinition, _ int, _ ai.ToolCallExecutor) (*ai.ToolLoopResult, error) {
|
||||
if !strings.Contains(system, "published hybrid prompt") || len(definitions) != 1 || definitions[0].Name != "run_playbook" {
|
||||
t.Fatalf("unexpected hybrid model context: system=%q definitions=%#v", system, definitions)
|
||||
}
|
||||
return &ai.ToolLoopResult{ChatCompletionResult: ai.ChatCompletionResult{Content: "这是自主回复。", ModelName: "test-model", PromptTokens: 5, CompletionTokens: 4}}, nil
|
||||
}
|
||||
summary, err := engine.Run(context.Background(), Request{
|
||||
UserMessage: models.Message{ID: 3, Content: "普通咨询"},
|
||||
AIAgent: models.AIAgent{ID: 14, RuntimeMode: enums.AIAgentRuntimeModeHybrid, PublishedRevisionID: revision.ID, WorkflowVersionID: workflowVersion.ID},
|
||||
AIConfig: models.AIConfig{ModelName: "test-model"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run returned error: %v", err)
|
||||
}
|
||||
if summary == nil || summary.AgentRunID <= 0 || summary.WorkflowRunID != 0 || summary.ReplyText != "这是自主回复。" {
|
||||
t.Fatalf("unexpected hybrid summary: %#v", summary)
|
||||
}
|
||||
var run models.AgentRun
|
||||
if err := db.First(&run, summary.AgentRunID).Error; err != nil {
|
||||
t.Fatalf("load agent run: %v", err)
|
||||
}
|
||||
if run.EngineCode != "hybrid" || run.AgentRevisionID != revision.ID || run.Status != "completed" {
|
||||
t.Fatalf("unexpected hybrid audit: %#v", run)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHybridEngineRejectsPlaybookWhenToolPolicyDisallowsWrites(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true}})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&models.AgentRevision{}, &models.AIWorkflowVersion{}, &models.AgentRun{}, &models.AgentStep{}); err != nil {
|
||||
t.Fatalf("auto migrate: %v", err)
|
||||
}
|
||||
sqls.SetDB(db)
|
||||
workflowVersion := &models.AIWorkflowVersion{WorkflowID: 22, Version: 1, Status: enums.StatusOk, Definition: `{"schemaVersion":2,"nodes":[{"id":"start_1","type":"start"},{"id":"end_1","type":"end"}],"edges":[{"sourceNodeID":"start_1","targetNodeID":"end_1"}]}`}
|
||||
if err := db.Create(workflowVersion).Error; err != nil {
|
||||
t.Fatalf("create workflow version: %v", err)
|
||||
}
|
||||
revision := &models.AgentRevision{AgentID: 15, Revision: 1, Status: enums.StatusOk, WorkflowVersionID: workflowVersion.ID, Definition: `{"agent":{"runtimeMode":"hybrid","systemPrompt":"published hybrid prompt","maxSteps":3,"toolPolicy":"{\"allowedRiskLevels\":[\"read\"]}"},"workflowVersionId":1}`}
|
||||
if err := db.Create(revision).Error; err != nil {
|
||||
t.Fatalf("create revision: %v", err)
|
||||
}
|
||||
engine := NewHybridEngine()
|
||||
engine.chatWithTools = func(ctx context.Context, _ models.AIConfig, _ string, _ string, _ []ai.ToolDefinition, _ int, execute ai.ToolCallExecutor) (*ai.ToolLoopResult, error) {
|
||||
_, err := execute(ctx, ai.ToolCall{Name: "run_playbook", Arguments: fmt.Sprintf(`{"workflowVersionId":%d}`, workflowVersion.ID)})
|
||||
return nil, err
|
||||
}
|
||||
_, err = engine.Run(context.Background(), Request{
|
||||
UserMessage: models.Message{ID: 4, Content: "请执行受控流程"},
|
||||
AIAgent: models.AIAgent{ID: 15, RuntimeMode: enums.AIAgentRuntimeModeHybrid, PublishedRevisionID: revision.ID, WorkflowVersionID: workflowVersion.ID},
|
||||
AIConfig: models.AIConfig{ModelName: "test-model"},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "tool risk level is not allowed") {
|
||||
t.Fatalf("expected tool policy rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntersectAutonomousToolCodesUsesSkillWhitelist(t *testing.T) {
|
||||
got := intersectAutonomousToolCodes([]string{"support/order_lookup", "support/create_ticket"}, []string{"support/order_lookup"})
|
||||
if len(got) != 1 || got[0] != "support/order_lookup" {
|
||||
t.Fatalf("intersection = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAutonomousToolPolicyAndPerToolCount(t *testing.T) {
|
||||
policy := parseAutonomousToolPolicy(`{"maxTotalCalls":2,"maxArgumentBytes":1024,"allowedRiskLevels":["read"]}`)
|
||||
if policy.MaxTotalCalls != 2 || policy.MaxArgumentBytes != 1024 || len(policy.AllowedRiskLevels) != 1 {
|
||||
t.Fatalf("unexpected policy: %#v", policy)
|
||||
}
|
||||
defaults := parseAutonomousToolPolicy(`{"maxTotalCalls":99,"maxArgumentBytes":999999}`)
|
||||
if defaults.MaxTotalCalls != 3 || defaults.MaxArgumentBytes != 32*1024 {
|
||||
t.Fatalf("invalid policy did not fall back to safe limits: %#v", defaults)
|
||||
}
|
||||
count := autonomousToolCallCount([]svc.EngineToolCallInput{{ToolCode: "orders/lookup"}, {ToolCode: "orders/other"}, {ToolCode: "orders/lookup"}}, "orders/lookup")
|
||||
if count != 2 {
|
||||
t.Fatalf("per-tool count = %d, want 2", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutonomousResponsePolicyRequestsHandoffOnlyWhenConfigured(t *testing.T) {
|
||||
handoff := evaluateAutonomousResponsePolicy(models.AIAgent{KnowledgeIDs: "1", FallbackMode: enums.AIAgentFallbackModeHandoff}, "", nil)
|
||||
if !handoff.Enforced || !handoff.RequestHandoff || handoff.Action != "handoff" {
|
||||
t.Fatalf("unexpected handoff policy: %#v", handoff)
|
||||
}
|
||||
clarify := evaluateAutonomousResponsePolicy(models.AIAgent{KnowledgeIDs: "1", FallbackMode: enums.AIAgentFallbackModeSuggestRetry}, "", nil)
|
||||
if !clarify.Enforced || clarify.RequestHandoff || clarify.Action != "clarify" {
|
||||
t.Fatalf("unexpected clarify policy: %#v", clarify)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutonomousConversationContextToolUsesRegistryPolicy(t *testing.T) {
|
||||
definition, result, err := executeAutonomousReadTool(context.Background(), models.Conversation{CustomerName: "张三", LastMessageSummary: "咨询退款"}, models.AIAgent{}, toolx.BuiltinConversationContext.Code, nil, aitooling.Policy{
|
||||
AllowedToolCodes: []string{toolx.BuiltinConversationContext.Code}, AllowedRiskLevels: []string{aitooling.RiskLevelRead}, Confirmed: true,
|
||||
})
|
||||
if err != nil || definition.Code != toolx.BuiltinConversationContext.Code || !strings.Contains(result, `"customerName":"张三"`) {
|
||||
t.Fatalf("unexpected conversation context tool result: definition=%#v result=%q err=%v", definition, result, err)
|
||||
}
|
||||
_, _, err = executeAutonomousReadTool(context.Background(), models.Conversation{}, models.AIAgent{}, toolx.BuiltinConversationContext.Code, nil, aitooling.Policy{
|
||||
AllowedToolCodes: []string{toolx.BuiltinConversationContext.Code}, AllowedRiskLevels: []string{aitooling.RiskLevelWrite}, Confirmed: true,
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "risk level") {
|
||||
t.Fatalf("expected read tool risk rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutonomousEngineExecutesAndAuditsConversationContextTool(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true}})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&models.AgentRevision{}, &models.AgentRun{}, &models.AgentStep{}, &models.AgentToolCall{}, &models.Message{}); err != nil {
|
||||
t.Fatalf("auto migrate: %v", err)
|
||||
}
|
||||
sqls.SetDB(db)
|
||||
revision := &models.AgentRevision{AgentID: 13, Revision: 1}
|
||||
if err := db.Create(revision).Error; err != nil {
|
||||
t.Fatalf("create revision: %v", err)
|
||||
}
|
||||
if err := db.Create(&models.Message{ConversationID: 1, SenderType: "customer", MessageType: "text", Content: "上一轮问题"}).Error; err != nil {
|
||||
t.Fatalf("create prior message: %v", err)
|
||||
}
|
||||
engine := newAutonomousEngineWithChat(nil)
|
||||
engine.toolChat = func(ctx context.Context, _ models.AIConfig, _, _ string, _ []ai.ToolDefinition, _ int, execute ai.ToolCallExecutor) (*ai.ToolLoopResult, error) {
|
||||
output, err := execute(ctx, ai.ToolCall{ID: "call-1", Name: "tool_search", Arguments: `{"toolCode":"builtin/conversation_context","arguments":{}}`})
|
||||
if err != nil || !strings.Contains(output, `"customerName":"张三"`) || !strings.Contains(output, "上一轮问题") {
|
||||
t.Fatalf("execute tool: output=%q err=%v", output, err)
|
||||
}
|
||||
output, err = execute(ctx, ai.ToolCall{ID: "call-2", Name: "tool_search", Arguments: `{"toolCode":"graph/prepare_ticket_draft","arguments":{"issue":"重复扣费"}}`})
|
||||
if err != nil || !strings.Contains(output, `"title":"重复扣费"`) {
|
||||
t.Fatalf("execute ticket draft tool: output=%q err=%v", output, err)
|
||||
}
|
||||
output, err = execute(ctx, ai.ToolCall{ID: "call-3", Name: "tool_search", Arguments: `{"toolCode":"graph/analyze_conversation","arguments":{"observedIssue":"重复扣费","needTicket":true}}`})
|
||||
if err != nil || !strings.Contains(output, `"userIntent":"ticket_request"`) {
|
||||
t.Fatalf("execute conversation analysis tool: output=%q err=%v", output, err)
|
||||
}
|
||||
output, err = execute(ctx, ai.ToolCall{ID: "call-4", Name: "tool_search", Arguments: `{"toolCode":"graph/triage_service_request","arguments":{"observedIssue":"重复扣费","needTicket":true}}`})
|
||||
if err != nil || !strings.Contains(output, `"recommendedAction":"prepare_ticket"`) || !strings.Contains(output, `"ticketDraft"`) {
|
||||
t.Fatalf("execute service triage tool: output=%q err=%v", output, err)
|
||||
}
|
||||
return &ai.ToolLoopResult{ChatCompletionResult: ai.ChatCompletionResult{Content: "已查询到当前会话信息。", ModelName: "test-model"}}, nil
|
||||
}
|
||||
summary, err := engine.Run(context.Background(), Request{
|
||||
Conversation: models.Conversation{ID: 1, CustomerName: "张三", LastMessageSummary: "咨询退款"}, UserMessage: models.Message{ID: 2, Content: "请查一下当前会话"},
|
||||
AIAgent: models.AIAgent{ID: 13, PublishedRevisionID: revision.ID, ToolPolicy: `{"maxTotalCalls":4}`, AllowedMCPTools: `[{"toolCode":"builtin/conversation_context"},{"toolCode":"graph/prepare_ticket_draft"},{"toolCode":"graph/analyze_conversation"},{"toolCode":"graph/triage_service_request"}]`},
|
||||
AIConfig: models.AIConfig{ModelName: "test-model"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run returned error: %v", err)
|
||||
}
|
||||
_, _, calls := svc.AgentRunService.GetDetail(summary.AgentRunID)
|
||||
if len(calls) != 4 || calls[0].ToolCode != toolx.BuiltinConversationContext.Code || calls[1].ToolCode != toolx.GraphPrepareTicketDraft.Code || calls[2].ToolCode != toolx.GraphAnalyzeConversation.Code || calls[3].ToolCode != toolx.GraphTriageServiceRequest.Code || calls[3].Status != "completed" {
|
||||
t.Fatalf("unexpected tool audit: %#v", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutonomousEngineFallsBackAfterConsecutiveToolFailures(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true}})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&models.AgentRevision{}, &models.AgentRun{}, &models.AgentStep{}, &models.AgentToolCall{}); err != nil {
|
||||
t.Fatalf("auto migrate: %v", err)
|
||||
}
|
||||
sqls.SetDB(db)
|
||||
revision := &models.AgentRevision{AgentID: 14, Revision: 1}
|
||||
if err := db.Create(revision).Error; err != nil {
|
||||
t.Fatalf("create revision: %v", err)
|
||||
}
|
||||
engine := newAutonomousEngineWithChat(nil)
|
||||
engine.toolChat = func(ctx context.Context, _ models.AIConfig, _, _ string, _ []ai.ToolDefinition, _ int, execute ai.ToolCallExecutor) (*ai.ToolLoopResult, error) {
|
||||
for _, callID := range []string{"call-1", "call-2"} {
|
||||
_, _ = execute(ctx, ai.ToolCall{ID: callID, Name: "tool_search", Arguments: `{"toolCode":"unknown/unsafe","arguments":{}}`})
|
||||
}
|
||||
return &ai.ToolLoopResult{ChatCompletionResult: ai.ChatCompletionResult{Content: "model reply should be replaced", ModelName: "test-model"}}, nil
|
||||
}
|
||||
summary, err := engine.Run(context.Background(), Request{
|
||||
Conversation: models.Conversation{ID: 1}, UserMessage: models.Message{ID: 2, Content: "查询订单"},
|
||||
AIAgent: models.AIAgent{ID: 14, PublishedRevisionID: revision.ID, FallbackMode: enums.AIAgentFallbackModeHandoff, FallbackMessage: "查询暂不可用,正在转人工。", AllowedMCPTools: `[{"toolCode":"builtin/conversation_context"}]`},
|
||||
AIConfig: models.AIConfig{ModelName: "test-model"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run returned error: %v", err)
|
||||
}
|
||||
if summary.ReplyText != "查询暂不可用,正在转人工。" || !summary.HandoffRequested {
|
||||
t.Fatalf("expected handoff fallback after tool failures, got %#v", summary)
|
||||
}
|
||||
_, steps, calls := svc.AgentRunService.GetDetail(summary.AgentRunID)
|
||||
if len(calls) != 2 || calls[0].Status != "failed" || calls[1].Status != "failed" {
|
||||
t.Fatalf("expected failed tool audits, got %#v", calls)
|
||||
}
|
||||
if len(steps) < 2 || steps[len(steps)-1].StepCode != "tool_failure" || steps[len(steps)-1].OutputPreview != "handoff" {
|
||||
t.Fatalf("expected tool failure policy audit, got %#v", steps)
|
||||
}
|
||||
}
|
||||
|
||||
type runtimeTestEngine struct {
|
||||
code string
|
||||
ran bool
|
||||
}
|
||||
|
||||
func (e *runtimeTestEngine) Code() string {
|
||||
return e.code
|
||||
}
|
||||
|
||||
func (e *runtimeTestEngine) Run(ctx context.Context, req RunInput) (*RunResult, error) {
|
||||
e.ran = true
|
||||
return &RunResult{Status: "completed"}, nil
|
||||
}
|
||||
|
||||
func (e *runtimeTestEngine) Resume(ctx context.Context, req ResumeInput) (*RunResult, error) {
|
||||
return &RunResult{Status: "completed"}, nil
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
"agent-desk/internal/pkg/toolx"
|
||||
)
|
||||
|
||||
// OfflineEvaluationCase is an isolated customer-service evaluation sample.
|
||||
// Expectations are intentionally declarative so the same baseline can evolve
|
||||
// without changing the runner's request contract.
|
||||
type OfflineEvaluationCase struct {
|
||||
ID string `json:"id"`
|
||||
Category string `json:"category"`
|
||||
Message string `json:"message"`
|
||||
History []string `json:"history,omitempty"`
|
||||
Expect map[string]any `json:"expect,omitempty"`
|
||||
}
|
||||
|
||||
type OfflineEvaluationResult struct {
|
||||
CaseID string `json:"caseId"`
|
||||
Category string `json:"category"`
|
||||
EngineCode string `json:"engineCode"`
|
||||
Passed bool `json:"passed"`
|
||||
ReplyText string `json:"replyText"`
|
||||
Interrupted bool `json:"interrupted"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Finding string `json:"finding,omitempty"`
|
||||
}
|
||||
|
||||
type OfflineEvaluationReport struct {
|
||||
EngineCode string `json:"engineCode"`
|
||||
Total int `json:"total"`
|
||||
Passed int `json:"passed"`
|
||||
Results []OfflineEvaluationResult `json:"results"`
|
||||
}
|
||||
|
||||
// OfflineEvaluationRunner executes only isolated Debug requests. The supplied
|
||||
// runner makes it testable without a real model and lets callers choose an
|
||||
// explicit Engine implementation for mode comparison.
|
||||
type OfflineEvaluationRunner struct {
|
||||
run func(context.Context, RunInput) (*RunResult, error)
|
||||
}
|
||||
|
||||
func NewOfflineEvaluationRunner(run func(context.Context, RunInput) (*RunResult, error)) *OfflineEvaluationRunner {
|
||||
return &OfflineEvaluationRunner{run: run}
|
||||
}
|
||||
|
||||
func (r *OfflineEvaluationRunner) Run(ctx context.Context, engineCode string, agent models.AIAgent, config models.AIConfig, cases []OfflineEvaluationCase) OfflineEvaluationReport {
|
||||
report := OfflineEvaluationReport{EngineCode: strings.TrimSpace(engineCode), Results: make([]OfflineEvaluationResult, 0, len(cases))}
|
||||
for _, item := range cases {
|
||||
result := OfflineEvaluationResult{CaseID: strings.TrimSpace(item.ID), Category: strings.TrimSpace(item.Category), EngineCode: report.EngineCode}
|
||||
if r == nil || r.run == nil {
|
||||
result.Error, result.Finding = "evaluation runner is not configured", "runner_missing"
|
||||
report.Results = append(report.Results, result)
|
||||
continue
|
||||
}
|
||||
summary, err := r.run(ctx, RunInput{
|
||||
Conversation: models.Conversation{AIAgentID: agent.ID, LastMessageSummary: strings.Join(item.History, "\n")},
|
||||
UserMessage: models.Message{SenderType: enums.IMSenderTypeCustomer, MessageType: enums.IMMessageTypeText, Content: strings.TrimSpace(item.Message), RequestID: "offline-eval:" + strings.TrimSpace(item.ID)},
|
||||
AIAgent: agent,
|
||||
AIConfig: config,
|
||||
Debug: true,
|
||||
})
|
||||
if err != nil {
|
||||
result.Error, result.Finding = err.Error(), "engine_error"
|
||||
report.Results = append(report.Results, result)
|
||||
continue
|
||||
}
|
||||
if summary != nil {
|
||||
result.ReplyText = strings.TrimSpace(summary.ReplyText)
|
||||
result.Interrupted = summary.Interrupted
|
||||
}
|
||||
result.Passed, result.Finding = evaluateOfflineCase(item.Expect, summary)
|
||||
if result.Passed {
|
||||
report.Passed++
|
||||
}
|
||||
report.Results = append(report.Results, result)
|
||||
}
|
||||
report.Total = len(report.Results)
|
||||
return report
|
||||
}
|
||||
|
||||
func (r OfflineEvaluationReport) CSV() (string, error) {
|
||||
var output strings.Builder
|
||||
writer := csv.NewWriter(&output)
|
||||
if err := writer.Write([]string{"caseId", "category", "engineCode", "passed", "interrupted", "finding", "error", "replyText"}); err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, item := range r.Results {
|
||||
if err := writer.Write([]string{item.CaseID, item.Category, item.EngineCode, strconv.FormatBool(item.Passed), strconv.FormatBool(item.Interrupted), item.Finding, item.Error, item.ReplyText}); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
writer.Flush()
|
||||
return output.String(), writer.Error()
|
||||
}
|
||||
|
||||
func evaluateOfflineCase(expect map[string]any, summary *RunResult) (bool, string) {
|
||||
if summary == nil || strings.TrimSpace(summary.ReplyText) == "" {
|
||||
return false, "empty_reply"
|
||||
}
|
||||
if requiresConfirmation, _ := expect["requiresConfirmation"].(bool); requiresConfirmation && !summary.Interrupted {
|
||||
return false, "confirmation_not_reached"
|
||||
}
|
||||
if maxWrites, ok := evaluationExpectationInt(expect["maxWriteToolCalls"]); ok {
|
||||
if maxWrites < 0 {
|
||||
return false, "invalid_expectation"
|
||||
}
|
||||
if writeToolCalls(summary) > maxWrites {
|
||||
return false, "write_tool_limit_exceeded"
|
||||
}
|
||||
}
|
||||
return true, ""
|
||||
}
|
||||
|
||||
func evaluationExpectationInt(value any) (int, bool) {
|
||||
switch item := value.(type) {
|
||||
case int:
|
||||
return item, true
|
||||
case int64:
|
||||
return int(item), true
|
||||
case float64:
|
||||
return int(item), item == float64(int(item))
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func writeToolCalls(summary *RunResult) int {
|
||||
if summary == nil {
|
||||
return 0
|
||||
}
|
||||
count := 0
|
||||
for _, code := range summary.InvokedToolCodes {
|
||||
switch toolx.NormalizeToolCodeAlias(code) {
|
||||
case toolx.GraphCreateTicketConfirm.Code, toolx.GraphHandoffConversation.Code:
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"agent-desk/internal/models"
|
||||
)
|
||||
|
||||
func TestOfflineEvaluationRunnerUsesDebugIsolationAndExportsCSV(t *testing.T) {
|
||||
var received []RunInput
|
||||
runner := NewOfflineEvaluationRunner(func(_ context.Context, input RunInput) (*RunResult, error) {
|
||||
received = append(received, input)
|
||||
return &RunResult{ReplyText: "已根据知识库回答。"}, nil
|
||||
})
|
||||
report := runner.Run(context.Background(), "autonomous", models.AIAgent{ID: 12}, models.AIConfig{ID: 13}, []OfflineEvaluationCase{{ID: "faq", Category: "faq", Message: "保修期多久", History: []string{"客户:你好"}}})
|
||||
if report.Total != 1 || report.Passed != 1 || len(received) != 1 || !received[0].Debug || received[0].Conversation.ID != 0 || received[0].UserMessage.RequestID != "offline-eval:faq" {
|
||||
t.Fatalf("unexpected report or input: report=%#v input=%#v", report, received)
|
||||
}
|
||||
csv, err := report.CSV()
|
||||
if err != nil || !strings.Contains(csv, "caseId,category,engineCode") || !strings.Contains(csv, "faq,faq,autonomous,true") {
|
||||
t.Fatalf("unexpected csv=%q err=%v", csv, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOfflineEvaluationRunnerChecksConfirmationExpectation(t *testing.T) {
|
||||
runner := NewOfflineEvaluationRunner(func(context.Context, RunInput) (*RunResult, error) {
|
||||
return &RunResult{ReplyText: "已转人工"}, nil
|
||||
})
|
||||
report := runner.Run(context.Background(), "workflow", models.AIAgent{}, models.AIConfig{}, []OfflineEvaluationCase{{ID: "handoff", Expect: map[string]any{"requiresConfirmation": true}}})
|
||||
if report.Passed != 0 || report.Results[0].Finding != "confirmation_not_reached" {
|
||||
t.Fatalf("unexpected report: %#v", report)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOfflineEvaluationRunnerChecksWriteToolLimit(t *testing.T) {
|
||||
runner := NewOfflineEvaluationRunner(func(context.Context, RunInput) (*RunResult, error) {
|
||||
return &RunResult{ReplyText: "调试回复", InvokedToolCodes: []string{"graph/handoff_to_human"}}, nil
|
||||
})
|
||||
report := runner.Run(context.Background(), "hybrid", models.AIAgent{}, models.AIConfig{}, []OfflineEvaluationCase{{ID: "write", Expect: map[string]any{"maxWriteToolCalls": 0}}})
|
||||
if report.Passed != 0 || report.Results[0].Finding != "write_tool_limit_exceeded" {
|
||||
t.Fatalf("unexpected report: %#v", report)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceRunsOfflineEvaluationWithExplicitEngine(t *testing.T) {
|
||||
engine := &runtimeTestEngine{code: "evaluation"}
|
||||
service := NewServiceWithRegistry(NewEngineRegistry(engine))
|
||||
report, err := service.RunOfflineEvaluation(context.Background(), "evaluation", models.AIAgent{RuntimeMode: "workflow"}, models.AIConfig{}, []OfflineEvaluationCase{{ID: "case"}})
|
||||
if err != nil || !engine.ran || report.EngineCode != "evaluation" || report.Total != 1 {
|
||||
t.Fatalf("unexpected evaluation report=%#v engine=%#v err=%v", report, engine, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
ai "agent-desk/internal/ai"
|
||||
aitooling "agent-desk/internal/ai/tooling"
|
||||
"agent-desk/internal/ai/runtime/instruction"
|
||||
workflowregistry "agent-desk/internal/ai/workflow/registry"
|
||||
workflowvalidator "agent-desk/internal/ai/workflow/validator"
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/errorsx"
|
||||
"agent-desk/internal/pkg/utils"
|
||||
svc "agent-desk/internal/services"
|
||||
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
)
|
||||
|
||||
const hybridPlaybookToolCode = "playbook/run"
|
||||
|
||||
// HybridEngine lets the model choose whether to enter the Agent's one bound
|
||||
// deterministic Playbook. The Playbook itself is always run by WorkflowEngine.
|
||||
type HybridEngine struct {
|
||||
chatWithTools func(context.Context, models.AIConfig, string, string, []ai.ToolDefinition, int, ai.ToolCallExecutor) (*ai.ToolLoopResult, error)
|
||||
autonomous *AutonomousEngine
|
||||
workflow *WorkflowEngine
|
||||
}
|
||||
|
||||
func NewHybridEngine() *HybridEngine {
|
||||
return &HybridEngine{
|
||||
chatWithTools: ai.LLM.ChatWithTools,
|
||||
autonomous: NewAutonomousEngine(),
|
||||
workflow: NewWorkflowEngine(),
|
||||
}
|
||||
}
|
||||
|
||||
func (e *HybridEngine) Code() string {
|
||||
return "hybrid"
|
||||
}
|
||||
|
||||
func (e *HybridEngine) Run(ctx context.Context, req RunInput) (*RunResult, error) {
|
||||
startedAt := time.Now()
|
||||
req.UserMessage.Content = utils.BuildRuntimeMessageText(req.UserMessage.MessageType, req.UserMessage.Content)
|
||||
snapshot, err := svc.AgentRevisionService.ResolvePublishedSnapshot(req.AIAgent, req.AIConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.AIAgent, req.AIConfig = snapshot.Agent, snapshot.AIConfig
|
||||
if req.AIAgent.WorkflowVersionID <= 0 {
|
||||
return nil, errorsx.InvalidParam("hybrid agent requires a published playbook workflow")
|
||||
}
|
||||
workflow, err := resolveAgentWorkflow(req.AIAgent)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if result := workflowvalidator.ValidateDefinition(workflow.Definition, workflowregistry.DefaultRegistry()); !result.Valid {
|
||||
return nil, errorsx.InvalidParam("hybrid agent playbook validation failed")
|
||||
}
|
||||
|
||||
skillContext := e.autonomous.selectSkill(ctx, req)
|
||||
knowledgeContext, retrieverCount, retrieveErr := e.autonomous.retrieveKnowledge(ctx, req.AIAgent, req.UserMessage.Content)
|
||||
responsePolicy := evaluateAutonomousResponsePolicy(req.AIAgent, knowledgeContext, retrieveErr)
|
||||
if responsePolicy.Enforced {
|
||||
return writeHybridResult(req, startedAt, &ai.ChatCompletionResult{Content: responsePolicy.ReplyText, ModelName: req.AIConfig.ModelName}, "", 0, retrieverCount, skillContext, nil, responsePolicy, nil)
|
||||
}
|
||||
systemPrompt := buildAutonomousSystemPrompt(req.AIAgent, len(utils.SplitInt64s(req.AIAgent.KnowledgeIDs)) > 0, knowledgeContext, retrieveErr)
|
||||
if skillInstruction := strings.TrimSpace(instruction.BuildSkillDocument(skillContext.Skill, nil)); skillInstruction != "" {
|
||||
systemPrompt += "\n\nSkill instructions:\n" + skillInstruction
|
||||
}
|
||||
systemPrompt += "\n\nWhen a deterministic process is required, use run_playbook. Do not call it for ordinary factual questions."
|
||||
userPrompt, historyCount := e.autonomous.buildUserPrompt(req)
|
||||
if knowledgeContext != "" {
|
||||
userPrompt += "\n\nKnowledge evidence:\n" + knowledgeContext
|
||||
}
|
||||
|
||||
var playbookSummary *Summary
|
||||
toolCalls := make([]svc.EngineToolCallInput, 0, 1)
|
||||
toolPolicy := parseAutonomousToolPolicy(req.AIAgent.ToolPolicy)
|
||||
loop, err := e.chatWithTools(ctx, req.AIConfig, systemPrompt, userPrompt, []ai.ToolDefinition{hybridPlaybookToolDefinition(req.AIAgent.WorkflowVersionID)}, req.AIAgent.MaxSteps, func(ctx context.Context, call ai.ToolCall) (string, error) {
|
||||
if call.Name != "run_playbook" {
|
||||
return "", fmt.Errorf("unsupported hybrid tool: %s", call.Name)
|
||||
}
|
||||
if len(toolCalls) >= 1 {
|
||||
return "", fmt.Errorf("playbook call limit reached")
|
||||
}
|
||||
workflowVersionID, err := parseHybridPlaybookCall(call.Arguments)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if workflowVersionID != req.AIAgent.WorkflowVersionID {
|
||||
return "", fmt.Errorf("playbook is not allowed")
|
||||
}
|
||||
playbookDefinition := aitooling.Definition{Code: hybridPlaybookToolCode, Name: "run_playbook", RiskLevel: aitooling.RiskLevelWrite, RequireConfirmation: true, MaxCallsPerRun: 1}
|
||||
if err := aitooling.DefaultRegistry.Authorize(playbookDefinition, aitooling.Policy{
|
||||
AllowedRiskLevels: toolPolicy.AllowedRiskLevels,
|
||||
CallCount: len(toolCalls),
|
||||
TotalCallCount: len(toolCalls),
|
||||
MaxTotalCalls: 1,
|
||||
Confirmed: true, // Workflow validation guarantees a human-confirm predecessor for high-risk nodes.
|
||||
}); err != nil {
|
||||
return "", err
|
||||
}
|
||||
callStartedAt := time.Now()
|
||||
playbookSummary, err = e.workflow.Run(ctx, req)
|
||||
toolRecord := svc.EngineToolCallInput{ToolCode: hybridPlaybookToolCode, RiskLevel: "write", RequireConfirm: true, ArgumentsPreview: call.Arguments, DurationMS: int(time.Since(callStartedAt).Milliseconds())}
|
||||
if err != nil {
|
||||
toolRecord.Status, toolRecord.ErrorMessage = "failed", err.Error()
|
||||
toolCalls = append(toolCalls, toolRecord)
|
||||
return "", err
|
||||
}
|
||||
toolRecord.Status = "completed"
|
||||
toolRecord.ResultPreview = fmt.Sprintf("workflowRunId=%d status=%s", playbookSummary.WorkflowRunID, playbookSummary.Status)
|
||||
toolCalls = append(toolCalls, toolRecord)
|
||||
data, _ := json.Marshal(map[string]any{"workflowRunId": playbookSummary.WorkflowRunID, "status": playbookSummary.Status, "replyText": playbookSummary.ReplyText, "interrupted": playbookSummary.Interrupted})
|
||||
return string(data), nil
|
||||
})
|
||||
if err != nil {
|
||||
_, _ = writeHybridAudit(req, startedAt, nil, userPrompt, historyCount, retrieverCount, skillContext, toolCalls, responsePolicy, false, err)
|
||||
return nil, err
|
||||
}
|
||||
if playbookSummary != nil && playbookSummary.Interrupted {
|
||||
runID, auditErr := writeHybridAudit(req, startedAt, &ai.ChatCompletionResult{Content: playbookSummary.ReplyText, ModelName: playbookSummary.ModelName, PromptTokens: playbookSummary.PromptTokens, CompletionTokens: playbookSummary.CompletionTokens}, userPrompt, historyCount, retrieverCount, skillContext, toolCalls, responsePolicy, true, nil)
|
||||
if auditErr != nil {
|
||||
return nil, auditErr
|
||||
}
|
||||
playbookSummary.AgentRunID = runID
|
||||
return playbookSummary, nil
|
||||
}
|
||||
if loop == nil || strings.TrimSpace(loop.Content) == "" {
|
||||
err = errorsx.InvalidParam("hybrid engine returned an empty reply")
|
||||
_, _ = writeHybridAudit(req, startedAt, nil, userPrompt, historyCount, retrieverCount, skillContext, toolCalls, responsePolicy, false, err)
|
||||
return nil, err
|
||||
}
|
||||
return writeHybridResult(req, startedAt, &loop.ChatCompletionResult, userPrompt, historyCount, retrieverCount, skillContext, playbookSummary, responsePolicy, toolCalls)
|
||||
}
|
||||
|
||||
func (e *HybridEngine) Resume(ctx context.Context, req ResumeInput) (*RunResult, error) {
|
||||
interrupt := svc.ConversationInterruptService.GetByCheckPointID(req.CheckPointID)
|
||||
summary, err := e.workflow.Resume(ctx, req)
|
||||
if err != nil || summary == nil || interrupt == nil || interrupt.AgentRunID <= 0 {
|
||||
return summary, err
|
||||
}
|
||||
if err := sqls.WithTransaction(func(tx *sqls.TxContext) error {
|
||||
return svc.AgentRunService.RecordHybridPlaybookResume(tx.Tx, interrupt.AgentRunID, summary.WorkflowRunID, summary.Status, summary.ReplyText)
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// The resumed WorkflowRun is a child audit artifact. Keep the original
|
||||
// Hybrid run as the summary run surfaced to the conversation caller.
|
||||
summary.AgentRunID = interrupt.AgentRunID
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
func hybridPlaybookToolDefinition(workflowVersionID int64) ai.ToolDefinition {
|
||||
return ai.ToolDefinition{Name: "run_playbook", Description: "Run the Agent's published deterministic Playbook when the customer needs a controlled business action.", Parameters: map[string]any{
|
||||
"type": "object", "properties": map[string]any{"workflowVersionId": map[string]any{"type": "integer", "description": "The bound Playbook version."}}, "required": []string{"workflowVersionId"},
|
||||
}}
|
||||
}
|
||||
|
||||
func parseHybridPlaybookCall(raw string) (int64, error) {
|
||||
var input struct {
|
||||
WorkflowVersionID int64 `json:"workflowVersionId"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(raw), &input); err != nil || input.WorkflowVersionID <= 0 {
|
||||
return 0, errorsx.InvalidParam("invalid playbook call")
|
||||
}
|
||||
return input.WorkflowVersionID, nil
|
||||
}
|
||||
|
||||
func writeHybridResult(req Request, startedAt time.Time, result *ai.ChatCompletionResult, inputPreview string, historyCount, retrieverCount int, skillContext autonomousSkillContext, playbook *Summary, responsePolicy autonomousResponsePolicy, toolCalls []svc.EngineToolCallInput) (*Summary, error) {
|
||||
runID, err := writeHybridAudit(req, startedAt, result, inputPreview, historyCount, retrieverCount, skillContext, toolCalls, responsePolicy, false, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Summary{Status: "completed", ReplyText: strings.TrimSpace(result.Content), ModelName: result.ModelName, PromptTokens: result.PromptTokens, CompletionTokens: result.CompletionTokens, HistoryMessageCount: historyCount, RetrieverCount: retrieverCount, AgentRunID: runID, WorkflowRunID: workflowRunIDFromSummary(playbook)}, nil
|
||||
}
|
||||
|
||||
func writeHybridAudit(req Request, startedAt time.Time, result *ai.ChatCompletionResult, inputPreview string, historyCount, retrieverCount int, skillContext autonomousSkillContext, toolCalls []svc.EngineToolCallInput, responsePolicy autonomousResponsePolicy, interrupted bool, cause error) (int64, error) {
|
||||
endedAt := time.Now()
|
||||
status, errorMessage, outputPreview := "completed", "", ""
|
||||
promptTokens, completionTokens := 0, 0
|
||||
if interrupted {
|
||||
status = "interrupted"
|
||||
} else if cause != nil {
|
||||
status, errorMessage = "failed", cause.Error()
|
||||
} else if result != nil {
|
||||
outputPreview, promptTokens, completionTokens = strings.TrimSpace(result.Content), result.PromptTokens, result.CompletionTokens
|
||||
}
|
||||
steps := autonomousAdditionalSteps(req, retrieverCount, nil, skillContext, responsePolicy)
|
||||
for _, call := range toolCalls {
|
||||
if call.ToolCode == hybridPlaybookToolCode {
|
||||
steps = append(steps, svc.EngineStepInput{StepType: "playbook", StepCode: hybridPlaybookToolCode, WorkflowRunID: workflowRunIDFromToolResult(call.ResultPreview), Status: call.Status, InputPreview: call.ArgumentsPreview, OutputPreview: call.ResultPreview, ErrorMessage: call.ErrorMessage})
|
||||
}
|
||||
}
|
||||
var runID int64
|
||||
err := sqls.WithTransaction(func(tx *sqls.TxContext) error {
|
||||
var recordErr error
|
||||
runID, recordErr = svc.AgentRunService.RecordEngineRun(tx.Tx, svc.EngineAgentRunInput{ConversationID: req.Conversation.ID, AIAgentID: req.AIAgent.ID, AgentRevisionID: req.AIAgent.PublishedRevisionID, SourceMessageID: req.UserMessage.ID, EngineCode: "hybrid", Status: status, PromptTokens: promptTokens, CompletionTokens: completionTokens, StartedAt: startedAt, EndedAt: &endedAt, ErrorMessage: errorMessage, TraceData: `{"engine":"hybrid"}`, StepType: "model", StepCode: "chat_completion", StepInputPreview: inputPreview, StepOutputPreview: outputPreview, AdditionalSteps: steps, ToolCalls: toolCalls})
|
||||
return recordErr
|
||||
})
|
||||
return runID, err
|
||||
}
|
||||
|
||||
func workflowRunIDFromSummary(summary *Summary) int64 {
|
||||
if summary == nil {
|
||||
return 0
|
||||
}
|
||||
return summary.WorkflowRunID
|
||||
}
|
||||
|
||||
func workflowRunIDFromToolResult(value string) int64 {
|
||||
var id int64
|
||||
_, _ = fmt.Sscanf(value, "workflowRunId=%d", &id)
|
||||
return id
|
||||
}
|
||||
|
||||
var _ Engine = (*HybridEngine)(nil)
|
||||
@@ -8,14 +8,15 @@ import (
|
||||
|
||||
workflowexecutor "agent-desk/internal/ai/runtime/workflow"
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/errorsx"
|
||||
"agent-desk/internal/pkg/utils"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
"agent-desk/internal/repositories"
|
||||
svc "agent-desk/internal/services"
|
||||
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
registry *EngineRegistry
|
||||
}
|
||||
|
||||
const (
|
||||
@@ -25,91 +26,46 @@ const (
|
||||
)
|
||||
|
||||
func NewService() *Service {
|
||||
return &Service{}
|
||||
return NewServiceWithRegistry(NewDefaultEngineRegistry())
|
||||
}
|
||||
|
||||
func (s *Service) Run(ctx context.Context, req Request) (*Summary, error) {
|
||||
req.UserMessage.Content = utils.BuildRuntimeMessageText(req.UserMessage.MessageType, req.UserMessage.Content)
|
||||
aiAgent, workflow, err := prepareWorkflowAgent(req.AIAgent)
|
||||
if err != nil {
|
||||
_, _ = writeWorkflowPrepareFailedRun(req, err.Error())
|
||||
return nil, err
|
||||
}
|
||||
req.AIAgent = aiAgent
|
||||
workflowResult, err := workflowexecutor.NewExecutor().Execute(ctx, workflowexecutor.Input{
|
||||
Definition: workflow.Definition,
|
||||
Conversation: req.Conversation,
|
||||
UserMessage: req.UserMessage,
|
||||
AIAgent: req.AIAgent,
|
||||
AIConfig: req.AIConfig,
|
||||
})
|
||||
if err != nil {
|
||||
if workflowResult != nil {
|
||||
_, _ = writeWorkflowRun(req, workflow, workflowResult, err.Error())
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
workflowRunID, err := writeWorkflowRun(req, workflow, workflowResult, "")
|
||||
func NewServiceWithRegistry(registry *EngineRegistry) *Service {
|
||||
return &Service{registry: registry}
|
||||
}
|
||||
|
||||
func (s *Service) Run(ctx context.Context, req RunInput) (*RunResult, error) {
|
||||
engine, err := s.registry.Resolve(resolveEngineCode(req.AIAgent.RuntimeMode))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return toWorkflowSummary(workflowResult, req.AIConfig.ModelName, workflow, workflowRunID), nil
|
||||
return engine.Run(ctx, req)
|
||||
}
|
||||
|
||||
func (s *Service) Resume(ctx context.Context, req ResumeRequest) (*Summary, error) {
|
||||
aiAgent, workflow, err := prepareWorkflowAgent(req.AIAgent)
|
||||
func (s *Service) Resume(ctx context.Context, req ResumeInput) (*RunResult, error) {
|
||||
engine, err := s.registry.Resolve(resolveEngineCode(req.AIAgent.RuntimeMode))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.AIAgent = aiAgent
|
||||
if interrupt := repositories.ConversationInterruptRepository.GetByCheckPointID(sqls.DB(), req.CheckPointID); interrupt != nil {
|
||||
if strings.TrimSpace(interrupt.RequestData) == "" {
|
||||
if interrupt.WorkflowRunID > 0 || strings.HasPrefix(strings.TrimSpace(req.CheckPointID), "workflow:") {
|
||||
return nil, errorsx.InvalidParam("workflow checkpoint data is required")
|
||||
}
|
||||
} else {
|
||||
workflowResult, err := workflowexecutor.NewExecutor().Resume(ctx, workflowexecutor.Input{
|
||||
Definition: workflow.Definition,
|
||||
Conversation: req.Conversation,
|
||||
AIAgent: req.AIAgent,
|
||||
AIConfig: req.AIConfig,
|
||||
}, interrupt.RequestData, firstWorkflowResumeText(req.ResumeData))
|
||||
if err != nil {
|
||||
if workflowResult != nil {
|
||||
_, _ = writeWorkflowRunWithExistingID(Request{
|
||||
Conversation: req.Conversation,
|
||||
UserMessage: req.UserMessage,
|
||||
AIAgent: req.AIAgent,
|
||||
AIConfig: req.AIConfig,
|
||||
}, workflow, workflowResult, err.Error(), interrupt.WorkflowRunID)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
workflowRunID, err := writeWorkflowRunWithExistingID(Request{
|
||||
Conversation: req.Conversation,
|
||||
UserMessage: req.UserMessage,
|
||||
AIAgent: req.AIAgent,
|
||||
AIConfig: req.AIConfig,
|
||||
}, workflow, workflowResult, "", interrupt.WorkflowRunID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return toWorkflowSummary(workflowResult, req.AIConfig.ModelName, workflow, workflowRunID), nil
|
||||
}
|
||||
}
|
||||
return nil, errorsx.InvalidParam("legacy checkpoint is not supported; please start a new workflow reply")
|
||||
return engine.Resume(ctx, req)
|
||||
}
|
||||
|
||||
func firstWorkflowResumeText(data map[string]string) string {
|
||||
for _, value := range data {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
// RunOfflineEvaluation executes an explicitly selected Engine against isolated
|
||||
// Debug inputs. It does not rely on the Agent's configured runtime mode, which
|
||||
// makes Workflow/Autonomous/Hybrid comparisons possible against one revision.
|
||||
func (s *Service) RunOfflineEvaluation(ctx context.Context, engineCode string, agent models.AIAgent, config models.AIConfig, cases []OfflineEvaluationCase) (OfflineEvaluationReport, error) {
|
||||
engine, err := s.registry.Resolve(strings.TrimSpace(engineCode))
|
||||
if err != nil {
|
||||
return OfflineEvaluationReport{EngineCode: strings.TrimSpace(engineCode)}, err
|
||||
}
|
||||
return ""
|
||||
runner := NewOfflineEvaluationRunner(engine.Run)
|
||||
return runner.Run(ctx, engine.Code(), agent, config, cases), nil
|
||||
}
|
||||
|
||||
func toWorkflowSummary(result *workflowexecutor.Result, modelName string, workflow resolvedWorkflow, workflowRunID int64) *Summary {
|
||||
func resolveEngineCode(mode enums.AIAgentRuntimeMode) string {
|
||||
return strings.TrimSpace(string(mode))
|
||||
}
|
||||
|
||||
func toWorkflowSummary(result *workflowexecutor.Result, modelName string, workflow resolvedWorkflow, workflowRunID int64, agentRunID int64) *Summary {
|
||||
if result == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -131,6 +87,7 @@ func toWorkflowSummary(result *workflowexecutor.Result, modelName string, workfl
|
||||
WorkflowID: workflow.WorkflowID,
|
||||
WorkflowVersionID: workflow.VersionID,
|
||||
WorkflowRunID: workflowRunID,
|
||||
AgentRunID: agentRunID,
|
||||
WorkflowNodePath: append([]string(nil), result.NodePath...),
|
||||
TraceData: string(traceData),
|
||||
CheckPointID: result.CheckPointID,
|
||||
@@ -155,7 +112,7 @@ func toWorkflowInterruptSummaries(items []workflowexecutor.InterruptSummary) []I
|
||||
return ret
|
||||
}
|
||||
|
||||
func writeWorkflowRun(req Request, workflow resolvedWorkflow, result *workflowexecutor.Result, errorMessage string) (int64, error) {
|
||||
func writeWorkflowRun(req Request, workflow resolvedWorkflow, result *workflowexecutor.Result, errorMessage string) (int64, int64, error) {
|
||||
return writeWorkflowRunWithExistingID(req, workflow, result, errorMessage, 0)
|
||||
}
|
||||
|
||||
@@ -180,15 +137,38 @@ func writeWorkflowPrepareFailedRun(req Request, errorMessage string) (int64, err
|
||||
EndedAt: &endedAt,
|
||||
ErrorMessage: errorMessage,
|
||||
}
|
||||
if err := repositories.AIWorkflowRunRepository.Create(sqls.DB(), run); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return run.ID, nil
|
||||
err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
if err := repositories.AIWorkflowRunRepository.Create(ctx.Tx, run); err != nil {
|
||||
return err
|
||||
}
|
||||
traceData, _ := json.Marshal(map[string]any{
|
||||
"status": "error",
|
||||
"workflowId": workflowID,
|
||||
"workflowVersionId": workflowVersionID,
|
||||
"workflowRunId": run.ID,
|
||||
})
|
||||
_, err := svc.AgentRunService.RecordWorkflowRun(ctx.Tx, svc.WorkflowAgentRunInput{
|
||||
WorkflowRunID: run.ID,
|
||||
WorkflowVersionID: workflowVersionID,
|
||||
ConversationID: req.Conversation.ID,
|
||||
AIAgentID: req.AIAgent.ID,
|
||||
SourceMessageID: req.UserMessage.ID,
|
||||
Status: "failed",
|
||||
StartedAt: now,
|
||||
EndedAt: &endedAt,
|
||||
ErrorMessage: errorMessage,
|
||||
TraceData: string(traceData),
|
||||
StepInputPreview: "workflow preparation",
|
||||
StepOutputPreview: "",
|
||||
})
|
||||
return err
|
||||
})
|
||||
return run.ID, err
|
||||
}
|
||||
|
||||
func writeWorkflowRunWithExistingID(req Request, workflow resolvedWorkflow, result *workflowexecutor.Result, errorMessage string, existingRunID int64) (int64, error) {
|
||||
func writeWorkflowRunWithExistingID(req Request, workflow resolvedWorkflow, result *workflowexecutor.Result, errorMessage string, existingRunID int64) (int64, int64, error) {
|
||||
if result == nil {
|
||||
return 0, nil
|
||||
return 0, 0, nil
|
||||
}
|
||||
now := time.Now()
|
||||
endedAt := now
|
||||
@@ -198,6 +178,7 @@ func writeWorkflowRunWithExistingID(req Request, workflow resolvedWorkflow, resu
|
||||
}
|
||||
runStatus := workflowRunStatus(result.Status, errorMessage)
|
||||
var runID int64
|
||||
var agentRunID int64
|
||||
err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
run := repositories.AIWorkflowRunRepository.Get(ctx.Tx, existingRunID)
|
||||
if run == nil {
|
||||
@@ -249,9 +230,46 @@ func writeWorkflowRunWithExistingID(req Request, workflow resolvedWorkflow, resu
|
||||
return err
|
||||
}
|
||||
}
|
||||
traceData, _ := json.Marshal(map[string]any{
|
||||
"status": result.Status,
|
||||
"workflowId": workflow.WorkflowID,
|
||||
"workflowVersionId": workflow.VersionID,
|
||||
"workflowRunId": run.ID,
|
||||
"nodePath": result.NodePath,
|
||||
})
|
||||
createdAgentRunID, recordErr := svc.AgentRunService.RecordWorkflowRun(ctx.Tx, svc.WorkflowAgentRunInput{
|
||||
WorkflowRunID: run.ID,
|
||||
WorkflowVersionID: workflow.VersionID,
|
||||
ConversationID: req.Conversation.ID,
|
||||
AIAgentID: req.AIAgent.ID,
|
||||
SourceMessageID: req.UserMessage.ID,
|
||||
Status: workflowAgentRunStatus(result.Status, errorMessage),
|
||||
PromptTokens: result.PromptTokens,
|
||||
CompletionTokens: result.CompletionTokens,
|
||||
StartedAt: now,
|
||||
EndedAt: &endedAt,
|
||||
ErrorMessage: errorMessage,
|
||||
TraceData: string(traceData),
|
||||
StepInputPreview: "workflow execution",
|
||||
StepOutputPreview: strings.Join(result.NodePath, ","),
|
||||
})
|
||||
if recordErr != nil {
|
||||
return recordErr
|
||||
}
|
||||
agentRunID = createdAgentRunID
|
||||
return nil
|
||||
})
|
||||
return runID, err
|
||||
return runID, agentRunID, err
|
||||
}
|
||||
|
||||
func workflowAgentRunStatus(status string, errorMessage string) string {
|
||||
if strings.TrimSpace(errorMessage) != "" || strings.TrimSpace(status) == "error" {
|
||||
return "failed"
|
||||
}
|
||||
if strings.TrimSpace(status) == "interrupted" {
|
||||
return "interrupted"
|
||||
}
|
||||
return "completed"
|
||||
}
|
||||
|
||||
func workflowRunStatus(status string, errorMessage string) int {
|
||||
|
||||
@@ -2,32 +2,49 @@ package runtime
|
||||
|
||||
import (
|
||||
"agent-desk/internal/models"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Request struct {
|
||||
// RunInput is the normalized, fully prepared input shared by all Engine
|
||||
// implementations. Persistent adapters load this object before dispatching
|
||||
// into the runtime.
|
||||
type RunInput struct {
|
||||
Conversation models.Conversation
|
||||
UserMessage models.Message
|
||||
AIAgent models.AIAgent
|
||||
AIConfig models.AIConfig
|
||||
CheckPointID string
|
||||
Debug bool
|
||||
}
|
||||
|
||||
type ResumeRequest struct {
|
||||
// Request remains as a compatibility alias while callers move to RunInput.
|
||||
type Request = RunInput
|
||||
|
||||
// ResumeInput extends the prepared input with an approved interrupt payload.
|
||||
// It deliberately carries the same persisted context as RunInput so resume
|
||||
// semantics are consistent across Workflow, Autonomous, and Hybrid engines.
|
||||
type ResumeInput struct {
|
||||
Conversation models.Conversation
|
||||
UserMessage models.Message
|
||||
AIAgent models.AIAgent
|
||||
AIConfig models.AIConfig
|
||||
CheckPointID string
|
||||
ResumeData map[string]string
|
||||
Debug bool
|
||||
}
|
||||
|
||||
// ResumeRequest remains as a compatibility alias while callers move to ResumeInput.
|
||||
type ResumeRequest = ResumeInput
|
||||
|
||||
type InterruptContextSummary struct {
|
||||
Type string `json:"type,omitempty"`
|
||||
ID string `json:"id"`
|
||||
InfoPreview string `json:"infoPreview,omitempty"`
|
||||
}
|
||||
|
||||
type Summary struct {
|
||||
// RunResult is the normalized result returned by every Engine. Engine-specific
|
||||
// details are represented by optional fields rather than engine-specific DTOs.
|
||||
type RunResult struct {
|
||||
RunID string
|
||||
Status string
|
||||
ReplyText string
|
||||
@@ -47,11 +64,39 @@ type Summary struct {
|
||||
WorkflowID int64
|
||||
WorkflowVersionID int64
|
||||
WorkflowRunID int64
|
||||
AgentRunID int64
|
||||
WorkflowNodePath []string
|
||||
CheckPointID string
|
||||
CheckPointData string
|
||||
Interrupted bool
|
||||
HandoffRequested bool
|
||||
Interrupts []InterruptContextSummary
|
||||
TraceData string
|
||||
ErrorMessage string
|
||||
}
|
||||
|
||||
// Summary remains as a compatibility alias while callers move to RunResult.
|
||||
type Summary = RunResult
|
||||
|
||||
type StreamEventType string
|
||||
|
||||
const (
|
||||
StreamEventStarted StreamEventType = "started"
|
||||
StreamEventStep StreamEventType = "step"
|
||||
StreamEventOutput StreamEventType = "output"
|
||||
StreamEventCompleted StreamEventType = "completed"
|
||||
StreamEventFailed StreamEventType = "failed"
|
||||
)
|
||||
|
||||
// StreamEvent is the transport-neutral event contract for future streaming
|
||||
// endpoints. Engines may emit partial output, audit steps, or a terminal state
|
||||
// without exposing engine-specific event payloads to callers.
|
||||
type StreamEvent struct {
|
||||
Type StreamEventType `json:"type"`
|
||||
RunID string `json:"runId,omitempty"`
|
||||
AgentRunID int64 `json:"agentRunId,omitempty"`
|
||||
StepCode string `json:"stepCode,omitempty"`
|
||||
Content string `json:"content,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
OccurredAt time.Time `json:"occurredAt"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
workflowexecutor "agent-desk/internal/ai/runtime/workflow"
|
||||
"agent-desk/internal/pkg/errorsx"
|
||||
"agent-desk/internal/pkg/utils"
|
||||
"agent-desk/internal/repositories"
|
||||
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
)
|
||||
|
||||
// WorkflowEngine preserves the existing FlowGram DSL execution path as the
|
||||
// first Agent Runtime engine. It remains the compatibility default for agents
|
||||
// created before autonomous and hybrid modes are available.
|
||||
type WorkflowEngine struct{}
|
||||
|
||||
func NewWorkflowEngine() *WorkflowEngine {
|
||||
return &WorkflowEngine{}
|
||||
}
|
||||
|
||||
func (e *WorkflowEngine) Code() string {
|
||||
return EngineCodeWorkflow
|
||||
}
|
||||
|
||||
func (e *WorkflowEngine) Run(ctx context.Context, req RunInput) (*RunResult, error) {
|
||||
req.UserMessage.Content = utils.BuildRuntimeMessageText(req.UserMessage.MessageType, req.UserMessage.Content)
|
||||
aiAgent, workflow, err := prepareWorkflowAgent(req.AIAgent)
|
||||
if err != nil {
|
||||
_, _ = writeWorkflowPrepareFailedRun(req, err.Error())
|
||||
return nil, err
|
||||
}
|
||||
req.AIAgent = aiAgent
|
||||
workflowResult, err := workflowexecutor.NewExecutor().Execute(ctx, workflowexecutor.Input{
|
||||
Definition: workflow.Definition,
|
||||
Conversation: req.Conversation,
|
||||
UserMessage: req.UserMessage,
|
||||
AIAgent: req.AIAgent,
|
||||
AIConfig: req.AIConfig,
|
||||
Debug: req.Debug,
|
||||
})
|
||||
if err != nil {
|
||||
if workflowResult != nil {
|
||||
_, _, _ = writeWorkflowRun(req, workflow, workflowResult, err.Error())
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
workflowRunID, agentRunID, err := writeWorkflowRun(req, workflow, workflowResult, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return toWorkflowSummary(workflowResult, req.AIConfig.ModelName, workflow, workflowRunID, agentRunID), nil
|
||||
}
|
||||
|
||||
func (e *WorkflowEngine) Resume(ctx context.Context, req ResumeInput) (*RunResult, error) {
|
||||
aiAgent, workflow, err := prepareWorkflowAgent(req.AIAgent)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.AIAgent = aiAgent
|
||||
interrupt := repositories.ConversationInterruptRepository.GetByCheckPointID(sqls.DB(), req.CheckPointID)
|
||||
if interrupt == nil {
|
||||
return nil, errorsx.InvalidParam("legacy checkpoint is not supported; please start a new workflow reply")
|
||||
}
|
||||
if strings.TrimSpace(interrupt.RequestData) == "" {
|
||||
if interrupt.WorkflowRunID > 0 || strings.HasPrefix(strings.TrimSpace(req.CheckPointID), "workflow:") {
|
||||
return nil, errorsx.InvalidParam("workflow checkpoint data is required")
|
||||
}
|
||||
return nil, errorsx.InvalidParam("legacy checkpoint is not supported; please start a new workflow reply")
|
||||
}
|
||||
workflowResult, err := workflowexecutor.NewExecutor().Resume(ctx, workflowexecutor.Input{
|
||||
Definition: workflow.Definition,
|
||||
Conversation: req.Conversation,
|
||||
AIAgent: req.AIAgent,
|
||||
AIConfig: req.AIConfig,
|
||||
Debug: req.Debug,
|
||||
}, interrupt.RequestData, firstWorkflowResumeText(req.ResumeData))
|
||||
if err != nil {
|
||||
if workflowResult != nil {
|
||||
_, _, _ = writeWorkflowRunWithExistingID(Request{
|
||||
Conversation: req.Conversation,
|
||||
UserMessage: req.UserMessage,
|
||||
AIAgent: req.AIAgent,
|
||||
AIConfig: req.AIConfig,
|
||||
}, workflow, workflowResult, err.Error(), interrupt.WorkflowRunID)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
workflowRunID, agentRunID, err := writeWorkflowRunWithExistingID(Request{
|
||||
Conversation: req.Conversation,
|
||||
UserMessage: req.UserMessage,
|
||||
AIAgent: req.AIAgent,
|
||||
AIConfig: req.AIConfig,
|
||||
}, workflow, workflowResult, "", interrupt.WorkflowRunID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return toWorkflowSummary(workflowResult, req.AIConfig.ModelName, workflow, workflowRunID, agentRunID), nil
|
||||
}
|
||||
|
||||
func firstWorkflowResumeText(data map[string]string) string {
|
||||
for _, value := range data {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
var _ Engine = (*WorkflowEngine)(nil)
|
||||
@@ -5,12 +5,14 @@ import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
workflowexecutor "agent-desk/internal/ai/runtime/workflow"
|
||||
"agent-desk/internal/ai/workflow/dsl"
|
||||
workflowregistry "agent-desk/internal/ai/workflow/registry"
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
svc "agent-desk/internal/services"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
@@ -27,7 +29,7 @@ func TestToWorkflowSummaryPreservesInterruptCheckpoint(t *testing.T) {
|
||||
Interrupts: []workflowexecutor.InterruptSummary{
|
||||
{Type: "human_confirm", ID: "confirm_1", InfoPreview: `{"message":"请确认"}`},
|
||||
},
|
||||
}, "test-model", resolvedWorkflow{WorkflowID: 11, VersionID: 22}, 33)
|
||||
}, "test-model", resolvedWorkflow{WorkflowID: 11, VersionID: 22}, 33, 44)
|
||||
|
||||
if summary == nil || !summary.Interrupted {
|
||||
t.Fatalf("expected interrupted summary, got %#v", summary)
|
||||
@@ -41,6 +43,9 @@ func TestToWorkflowSummaryPreservesInterruptCheckpoint(t *testing.T) {
|
||||
if summary.WorkflowID != 11 || summary.WorkflowVersionID != 22 || summary.WorkflowRunID != 33 {
|
||||
t.Fatalf("unexpected workflow identity: workflow=%d version=%d run=%d", summary.WorkflowID, summary.WorkflowVersionID, summary.WorkflowRunID)
|
||||
}
|
||||
if summary.AgentRunID != 44 {
|
||||
t.Fatalf("unexpected agent run id: %d", summary.AgentRunID)
|
||||
}
|
||||
if len(summary.Interrupts) != 1 || summary.Interrupts[0].ID != "confirm_1" {
|
||||
t.Fatalf("unexpected interrupts: %#v", summary.Interrupts)
|
||||
}
|
||||
@@ -125,6 +130,9 @@ func TestServiceResumeUsesWorkflowCheckpointData(t *testing.T) {
|
||||
if summary.WorkflowRunID <= 0 {
|
||||
t.Fatalf("expected workflow run id in resume summary")
|
||||
}
|
||||
if summary.AgentRunID <= 0 {
|
||||
t.Fatalf("expected generic agent run id in resume summary")
|
||||
}
|
||||
var run models.AIWorkflowRun
|
||||
if err := db.First(&run, summary.WorkflowRunID).Error; err != nil {
|
||||
t.Fatalf("find resume workflow run: %v", err)
|
||||
@@ -132,6 +140,68 @@ func TestServiceResumeUsesWorkflowCheckpointData(t *testing.T) {
|
||||
if run.MessageID != 2 || run.Status != workflowRunStatusCompleted {
|
||||
t.Fatalf("unexpected resume workflow run: %#v", run)
|
||||
}
|
||||
var agentRun models.AgentRun
|
||||
if err := db.First(&agentRun, "workflow_run_id = ?", summary.WorkflowRunID).Error; err != nil {
|
||||
t.Fatalf("find generic agent run: %v", err)
|
||||
}
|
||||
if agentRun.EngineCode != EngineCodeWorkflow || agentRun.Status != "completed" {
|
||||
t.Fatalf("unexpected generic agent run: %#v", agentRun)
|
||||
}
|
||||
var stepCount int64
|
||||
if err := db.Model(&models.AgentStep{}).Where("agent_run_id = ?", agentRun.ID).Count(&stepCount).Error; err != nil {
|
||||
t.Fatalf("count generic agent steps: %v", err)
|
||||
}
|
||||
if stepCount != 1 {
|
||||
t.Fatalf("expected one generic agent step, got %d", stepCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHybridEngineResumeCompletesOriginalAgentRun(t *testing.T) {
|
||||
db := setupWorkflowResumeTestDB(t)
|
||||
def := runtimeHumanConfirmDefinition()
|
||||
version := models.AIWorkflowVersion{
|
||||
WorkflowID: 1,
|
||||
Version: 1,
|
||||
Status: enums.StatusOk,
|
||||
Definition: mustMarshalDefinition(t, def),
|
||||
}
|
||||
if err := db.Create(&version).Error; err != nil {
|
||||
t.Fatalf("create workflow version: %v", err)
|
||||
}
|
||||
startedAt := time.Now()
|
||||
hybridRun := models.AgentRun{AIAgentID: 1, EngineCode: "hybrid", Status: "interrupted", StartedAt: startedAt, EndedAt: &startedAt, CreatedAt: startedAt, UpdatedAt: startedAt}
|
||||
if err := db.Create(&hybridRun).Error; err != nil {
|
||||
t.Fatalf("create interrupted hybrid run: %v", err)
|
||||
}
|
||||
interruptedRun := models.AIWorkflowRun{WorkflowID: version.WorkflowID, WorkflowVersionID: version.ID, ConversationID: 1, AIAgentID: 1, MessageID: 2, Status: workflowRunStatusInterrupted}
|
||||
if err := db.Create(&interruptedRun).Error; err != nil {
|
||||
t.Fatalf("create interrupted workflow run: %v", err)
|
||||
}
|
||||
const checkpointID = "workflow:1:2:confirm_1"
|
||||
if err := db.Create(&models.ConversationInterrupt{
|
||||
ConversationID: 1, AIAgentID: 1, AgentRunID: hybridRun.ID,
|
||||
CheckPointID: checkpointID, InterruptID: "confirm_1", InterruptType: "human_confirm",
|
||||
WorkflowRunID: interruptedRun.ID, WorkflowNodeID: "confirm_1", RequestData: mustMarshalWorkflowCheckpoint(t, def), Status: "pending",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create interrupt: %v", err)
|
||||
}
|
||||
|
||||
summary, err := NewHybridEngine().Resume(context.Background(), ResumeRequest{
|
||||
Conversation: models.Conversation{ID: 1}, UserMessage: models.Message{ID: 3, Content: "确认"},
|
||||
AIAgent: models.AIAgent{ID: 1, RuntimeMode: enums.AIAgentRuntimeModeHybrid, WorkflowVersionID: version.ID},
|
||||
AIConfig: models.AIConfig{ModelName: "test-model"}, CheckPointID: checkpointID,
|
||||
ResumeData: map[string]string{"confirm_1": "确认"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("resume hybrid playbook: %v", err)
|
||||
}
|
||||
if summary == nil || summary.Status != "completed" || summary.AgentRunID != hybridRun.ID || summary.WorkflowRunID != interruptedRun.ID {
|
||||
t.Fatalf("unexpected hybrid resume summary: %#v", summary)
|
||||
}
|
||||
item, steps, _ := svc.AgentRunService.GetDetail(hybridRun.ID)
|
||||
if item == nil || item.Status != "completed" || len(steps) != 1 || steps[0].StepCode != "playbook_resume" || steps[0].WorkflowRunID != interruptedRun.ID {
|
||||
t.Fatalf("expected original hybrid run to receive resume audit, run=%#v steps=%#v", item, steps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceResumeReusesInterruptedWorkflowRun(t *testing.T) {
|
||||
@@ -262,6 +332,13 @@ func TestServiceRunWritesFailedWorkflowRun(t *testing.T) {
|
||||
if badNodeRun.Status != workflowRunStatusFailed || badNodeRun.ErrorMessage == "" {
|
||||
t.Fatalf("unexpected failed node run: %#v", badNodeRun)
|
||||
}
|
||||
var agentRun models.AgentRun
|
||||
if err := db.First(&agentRun, "workflow_run_id = ?", run.ID).Error; err != nil {
|
||||
t.Fatalf("find generic failed agent run: %v", err)
|
||||
}
|
||||
if agentRun.Status != "failed" || !strings.Contains(agentRun.ErrorMessage, "unsupported workflow node type") {
|
||||
t.Fatalf("unexpected generic failed agent run: %#v", agentRun)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceRunWritesFailedWorkflowRunWhenVersionDisabled(t *testing.T) {
|
||||
@@ -324,7 +401,15 @@ func setupWorkflowResumeTestDB(t *testing.T) *gorm.DB {
|
||||
_ = sqlDB.Close()
|
||||
}
|
||||
})
|
||||
if err := db.AutoMigrate(&models.AIWorkflowVersion{}, &models.AIWorkflowRun{}, &models.AIWorkflowNodeRun{}, &models.ConversationInterrupt{}); err != nil {
|
||||
if err := db.AutoMigrate(
|
||||
&models.AIWorkflowVersion{},
|
||||
&models.AIWorkflowRun{},
|
||||
&models.AIWorkflowNodeRun{},
|
||||
&models.AgentRun{},
|
||||
&models.AgentStep{},
|
||||
&models.AgentRevision{},
|
||||
&models.ConversationInterrupt{},
|
||||
); err != nil {
|
||||
t.Fatalf("auto migrate: %v", err)
|
||||
}
|
||||
sqls.SetDB(db)
|
||||
|
||||
Reference in New Issue
Block a user