chore: import clean Agent Desk snapshot (2/7)

This commit is contained in:
2026-10-16 11:57:21 +00:00
parent 79176541c7
commit 89c5231c4c
461 changed files with 60092 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,864 @@
package runtime
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"testing"
ai "agent-desk/internal/ai"
"agent-desk/internal/models"
"agent-desk/internal/pkg/dto/request"
"agent-desk/internal/pkg/enums"
svc "agent-desk/internal/services"
"github.com/glebarez/sqlite"
"github.com/mlogclub/simple/sqls"
"gorm.io/gorm"
)
func TestAgentLoopActivatesSkillInsideSameToolLoop(t *testing.T) {
skill := models.SkillDefinition{
ID: 7, Name: "退款说明", Instruction: "只根据退款政策回答。",
ToolWhitelist: `["builtin/knowledge_retrieve"]`, Status: enums.StatusOk,
}
turn := agentLoopTurn{
AllowedTools: []string{"skill/7"},
ToolPolicy: parseAgentLoopToolPolicy(""),
Skills: map[int64]models.SkillDefinition{skill.ID: skill},
}
state := agentLoopExecutionState{}
var calls []svc.AgentLoopToolCallInput
execute := NewAgentLoopEngine().toolSearchExecutor(RunInput{}, turn, &state, &calls)
result, err := execute(context.Background(), ai.ToolCall{
Name: "tool_search", Arguments: `{"toolCode":"skill/7","arguments":{}}`,
})
if err != nil {
t.Fatalf("activate Skill: %v", err)
}
if state.SkillContext.SkillID() != skill.ID || !strings.Contains(result, skill.Instruction) {
t.Fatalf("Skill was not activated in the Agent Loop: state=%#v result=%q", state, result)
}
if len(calls) != 1 || calls[0].ToolCode != "skill/7" || calls[0].Status != "completed" {
t.Fatalf("unexpected Skill audit: %#v", calls)
}
}
func TestAgentLoopRegistersDirectCapabilityAliases(t *testing.T) {
turn := agentLoopTurn{AllowedTools: []string{
"builtin/conversation_context",
"graph/triage_service_request",
"graph/triage_service_request",
"workflow/47",
}}
definitions := agentLoopToolDefinitions(turn)
names := make(map[string]bool, len(definitions))
for _, definition := range definitions {
names[definition.Name] = true
}
for _, expected := range []string{
"tool_search",
"builtin_conversation_context",
"graph_triage_service_request",
"workflow_47",
} {
if !names[expected] {
t.Fatalf("missing registered function alias %q: %#v", expected, definitions)
}
}
}
func TestConversationDecisionIsStructuredAndValidated(t *testing.T) {
decision, err := parseConversationDecision(`{"action":"handoff","reason":"customer requested a human","reply":"","handoffInitiator":"customer","handoffConfirmed":true}`)
if err != nil {
t.Fatalf("parse handoff decision: %v", err)
}
if decision.Action != ConversationActionHandoff || decision.Reason != "customer requested a human" {
t.Fatalf("unexpected handoff decision: %#v", decision)
}
for _, raw := range []string{
`{"action":"unknown","reason":"x","reply":"x","handoffInitiator":"none","handoffConfirmed":false}`,
`{"action":"reply","reason":"x","reply":"","handoffInitiator":"none","handoffConfirmed":false}`,
`{"action":"ask_handoff_confirmation","reason":"x","reply":"confirm?","handoffInitiator":"customer","handoffConfirmed":false}`,
} {
if _, err := parseConversationDecision(raw); err == nil {
t.Fatalf("expected invalid decision to fail: %s", raw)
}
}
}
func TestAgentLoopRecordsConversationDecision(t *testing.T) {
state := agentLoopExecutionState{}
var calls []svc.AgentLoopToolCallInput
execute := NewAgentLoopEngine().toolSearchExecutor(RunInput{}, agentLoopTurn{}, &state, &calls)
if _, err := execute(context.Background(), ai.ToolCall{
Name: "conversation_decision", Arguments: `{"action":"handoff","reason":"customer requested a human","reply":"","handoffInitiator":"customer","handoffConfirmed":true}`,
}); err != nil {
t.Fatalf("record decision: %v", err)
}
if state.Decision == nil || state.Decision.Action != ConversationActionHandoff {
t.Fatalf("decision was not stored: %#v", state.Decision)
}
if len(calls) != 1 || calls[0].ToolCode != "conversation_decision" || calls[0].Status != "completed" {
t.Fatalf("decision audit missing: %#v", calls)
}
}
func TestResolveAgentLoopReplyKeepsNormalModelReplyWithoutDecision(t *testing.T) {
reply, handoff, reason, err := resolveAgentLoopReply("你好,有什么可以帮你?", nil)
if err != nil || handoff || reason != "" || reply != "你好,有什么可以帮你?" {
t.Fatalf("unexpected normal reply resolution: reply=%q handoff=%t reason=%q err=%v", reply, handoff, reason, err)
}
}
func TestResolveAgentLoopReplyDetectsModelHandoffClaimWithoutDecision(t *testing.T) {
reply, handoff, reason, err := resolveAgentLoopReply("Перевожу вас на оператора, пожалуйста подождите.", nil)
if err != nil || !handoff || reply != "" || reason != "model indicated handoff in reply text" {
t.Fatalf("expected handoff from model claim text: reply=%q handoff=%t reason=%q err=%v", reply, handoff, reason, err)
}
replyEn, handoffEn, reasonEn, errEn := resolveAgentLoopReply("Connecting you to a human representative right now.", nil)
if errEn != nil || !handoffEn || replyEn != "" || reasonEn != "model indicated handoff in reply text" {
t.Fatalf("expected handoff from English model claim text: reply=%q handoff=%t reason=%q err=%v", replyEn, handoffEn, reasonEn, errEn)
}
}
func TestResolveAgentLoopReplyFallbackOnUserHandoffRequestWithoutDecision(t *testing.T) {
reply, handoff, reason, err := resolveAgentLoopReply("Чем я могу помочь?", nil, "соедините со специалистом")
if err != nil || !handoff || reply != "" || reason != "customer requested human support" {
t.Fatalf("expected handoff fallback from user handoff request: reply=%q handoff=%t reason=%q err=%v", reply, handoff, reason, err)
}
}
func TestResolveAgentLoopReplyUsesStructuredHandoffDecision(t *testing.T) {
reply, handoff, reason, err := resolveAgentLoopReply("模型自由文本不应生效", &ConversationDecision{
Action: ConversationActionHandoff, Reason: "customer requested human support", HandoffInitiator: HandoffInitiatorCustomer, HandoffConfirmed: true,
})
if err != nil || !handoff || reply != "" || reason != "customer requested human support" {
t.Fatalf("unexpected handoff resolution: reply=%q handoff=%t reason=%q err=%v", reply, handoff, reason, err)
}
}
func TestNormalizeAgentLoopReplyAllowsEmptyInternalHandoff(t *testing.T) {
reply, err := normalizeAgentLoopReply("", true)
if err != nil || reply != "" {
t.Fatalf("handoff must bypass customer reply normalization: reply=%q err=%v", reply, err)
}
if _, err := normalizeAgentLoopReply("", false); err == nil {
t.Fatal("ordinary empty model replies must still be rejected")
}
}
func TestAgentLoopDirectCapabilityAliasUsesSamePolicyBoundary(t *testing.T) {
skill := models.SkillDefinition{
ID: 7, Name: "售后升级处理", Instruction: "先确认升级诉求。", Status: enums.StatusOk,
}
turn := agentLoopTurn{
AllowedTools: []string{"skill/7"},
ToolPolicy: parseAgentLoopToolPolicy(""),
Skills: map[int64]models.SkillDefinition{skill.ID: skill},
}
state := agentLoopExecutionState{}
var calls []svc.AgentLoopToolCallInput
execute := NewAgentLoopEngine().toolSearchExecutor(RunInput{}, turn, &state, &calls)
result, err := execute(context.Background(), ai.ToolCall{Name: "skill/7", Arguments: `{}`})
if err != nil {
t.Fatalf("execute direct capability alias: %v", err)
}
if state.SkillContext.SkillID() != skill.ID || !strings.Contains(result, skill.Instruction) {
t.Fatalf("direct capability was not routed through Skill activation: state=%#v result=%q", state, result)
}
if len(calls) != 1 || calls[0].ToolCode != "skill/7" || calls[0].Status != "completed" {
t.Fatalf("unexpected direct capability audit: %#v", calls)
}
}
func TestAgentLoopDirectSanitizedCapabilityAliasRoutesCorrectly(t *testing.T) {
skill := models.SkillDefinition{
ID: 7, Name: "售后升级处理", Instruction: "先确认升级诉求。", Status: enums.StatusOk,
}
turn := agentLoopTurn{
AllowedTools: []string{"skill/7"},
ToolPolicy: parseAgentLoopToolPolicy(""),
Skills: map[int64]models.SkillDefinition{skill.ID: skill},
}
state := agentLoopExecutionState{}
var calls []svc.AgentLoopToolCallInput
execute := NewAgentLoopEngine().toolSearchExecutor(RunInput{}, turn, &state, &calls)
result, err := execute(context.Background(), ai.ToolCall{Name: "skill_7", Arguments: `{}`})
if err != nil {
t.Fatalf("execute sanitized direct capability alias: %v", err)
}
if state.SkillContext.SkillID() != skill.ID || !strings.Contains(result, skill.Instruction) {
t.Fatalf("sanitized direct capability was not routed through Skill activation: state=%#v result=%q", state, result)
}
if len(calls) != 1 || calls[0].ToolCode != "skill/7" || calls[0].Status != "completed" {
t.Fatalf("unexpected direct capability audit: %#v", calls)
}
}
func TestAgentLoopInterruptsBeforeWriteMCPTool(t *testing.T) {
configured, err := json.Marshal([]request.AIAgentMCPToolRequest{{
ToolCode: "crm/update_customer", ServerCode: "crm", ToolName: "update_customer",
Title: "更新客户", RiskLevel: "write", RequireConfirmation: true,
}})
if err != nil {
t.Fatalf("marshal MCP configuration: %v", err)
}
runInput := RunInput{
Conversation: models.Conversation{ID: 9},
AIAgent: models.AIAgent{AllowedMCPTools: string(configured)},
}
turn := agentLoopTurn{
AllowedTools: []string{"crm/update_customer"},
ToolPolicy: parseAgentLoopToolPolicy(`{"allowedRiskLevels":["read","write"]}`),
}
state := agentLoopExecutionState{}
var calls []svc.AgentLoopToolCallInput
execute := NewAgentLoopEngine().toolSearchExecutor(runInput, turn, &state, &calls)
_, err = execute(context.Background(), ai.ToolCall{
Name: "tool_search", Arguments: `{"toolCode":"crm/update_customer","arguments":{"name":"Ada"}}`,
})
if err == nil {
t.Fatal("expected write MCP Tool to interrupt")
}
if state.Interrupted == nil || !state.Interrupted.Interrupted || !strings.HasPrefix(state.Interrupted.CheckPointID, "tool:9:") {
t.Fatalf("missing MCP confirmation checkpoint: %#v", state.Interrupted)
}
if state.Interrupted.ReplyText != "即将执行“更新客户”,是否确认继续?" ||
len(state.Interrupted.Interrupts) != 1 ||
state.Interrupted.Interrupts[0].PromptText != state.Interrupted.ReplyText {
t.Fatalf("unexpected customer confirmation prompt: %#v", state.Interrupted)
}
if len(calls) != 1 || calls[0].RiskLevel != "write" || !calls[0].RequireConfirm || calls[0].Status != "interrupted" {
t.Fatalf("unexpected MCP safety audit: %#v", calls)
}
}
func TestAgentLoopRejectsWriteMCPBeforeConfirmationWhenRiskIsNotAllowed(t *testing.T) {
configured, _ := json.Marshal([]request.AIAgentMCPToolRequest{{
ToolCode: "crm/update_customer", ServerCode: "crm", ToolName: "update_customer",
Title: "更新客户", RiskLevel: "write", RequireConfirmation: true,
}})
runInput := RunInput{
Conversation: models.Conversation{ID: 9},
AIAgent: models.AIAgent{AllowedMCPTools: string(configured)},
}
turn := agentLoopTurn{
AllowedTools: []string{"crm/update_customer"},
ToolPolicy: parseAgentLoopToolPolicy(`{"allowedRiskLevels":["read"]}`),
}
state := agentLoopExecutionState{}
var calls []svc.AgentLoopToolCallInput
execute := NewAgentLoopEngine().toolSearchExecutor(runInput, turn, &state, &calls)
_, err := execute(context.Background(), ai.ToolCall{
Name: "tool_search", Arguments: `{"toolCode":"crm/update_customer","arguments":{"name":"Ada"}}`,
})
if err == nil || !strings.Contains(err.Error(), "risk level") {
t.Fatalf("expected MCP risk policy rejection, got %v", err)
}
if state.Interrupted != nil || len(calls) != 1 || calls[0].Status != "failed" {
t.Fatalf("disallowed MCP call should fail without a checkpoint: state=%#v calls=%#v", state, calls)
}
}
func TestAgentLoopRejectsWorkflowWhenWriteRiskIsNotAllowed(t *testing.T) {
turn := agentLoopTurn{
AllowedTools: []string{"workflow/23"},
ToolPolicy: parseAgentLoopToolPolicy(`{"allowedRiskLevels":["read"]}`),
Workflows: map[int64]svc.AgentRevisionWorkflowBinding{
23: {WorkflowVersionID: 23, ToolName: "创建工单"},
},
}
state := agentLoopExecutionState{}
var calls []svc.AgentLoopToolCallInput
execute := NewAgentLoopEngine().toolSearchExecutor(RunInput{}, turn, &state, &calls)
_, err := execute(context.Background(), ai.ToolCall{
Name: "tool_search", Arguments: `{"toolCode":"workflow/23","arguments":{}}`,
})
if err == nil || !strings.Contains(err.Error(), "risk level") {
t.Fatalf("expected Workflow risk policy rejection, got %v", err)
}
if len(calls) != 1 || calls[0].Status != "failed" || calls[0].RiskLevel != "write" {
t.Fatalf("unexpected Workflow policy audit: %#v", calls)
}
}
func TestAgentLoopKnowledgeFallbackCanRequestHandoff(t *testing.T) {
agent := models.AIAgent{
KnowledgeIDs: "1", FallbackMode: enums.AIAgentFallbackModeHandoff,
FallbackMessage: "我暂时无法核实,马上为你转人工。",
}
policy := evaluateAgentLoopResponsePolicy(agent, "", nil)
prompt := buildAgentLoopSystemPrompt(agent, true, "", nil)
if !policy.RequestHandoff || !strings.Contains(prompt, agent.FallbackMessage) {
t.Fatalf("knowledge fallback was not applied: policy=%#v prompt=%q", policy, prompt)
}
}
func TestAgentLoopConfirmationNormalizesHTMLAndKeepsUnknownPending(t *testing.T) {
data := normalizeAgentLoopResumeData(enums.IMMessageTypeHTML, map[string]string{
"message": "<p>确认。</p>",
})
if got := parseAgentLoopConfirmation(firstAgentLoopResumeText(data)); got != agentLoopConfirmationConfirmed {
t.Fatalf("expected HTML confirmation, got %v from %#v", got, data)
}
if got := parseAgentLoopConfirmation("取消!"); got != agentLoopConfirmationCancelled {
t.Fatalf("expected cancellation, got %v", got)
}
if got := parseAgentLoopConfirmation("稍后再说"); got != agentLoopConfirmationUnknown {
t.Fatalf("ambiguous input must stay pending, got %v", got)
}
}
func TestConfiguredMCPToolAppliesTrustedSystemPolicy(t *testing.T) {
configured, _ := json.Marshal([]request.AIAgentMCPToolRequest{{
ToolCode: "system/server_time",
ServerCode: "system",
ToolName: "server_time",
Title: "server_time",
RiskLevel: "write",
RequireConfirmation: true,
}})
tool, err := configuredMCPTool(string(configured), "system/server_time")
if err != nil {
t.Fatalf("resolve configured system tool: %v", err)
}
if tool.Title != "获取当前时间" || tool.RiskLevel != "read" || tool.RequireConfirmation {
t.Fatalf("trusted policy was not applied at runtime: %#v", tool)
}
}
func TestAgentLoopPromptAvoidsRepeatingWelcomeMessage(t *testing.T) {
prompt := buildAgentLoopSystemPrompt(models.AIAgent{}, false, "", nil)
if !strings.Contains(prompt, "without repeating the welcome wording") {
t.Fatalf("conversation continuity instruction missing: %q", prompt)
}
}
func TestAgentLoopPromptEnforcesCustomerLanguage(t *testing.T) {
prompt := buildAgentLoopSystemPrompt(models.AIAgent{}, false, "", nil)
if !strings.Contains(prompt, "CRITICAL LANGUAGE RULE") || !strings.Contains(prompt, "same language as the customer's current message") {
t.Fatalf("language rule missing from system prompt: %q", prompt)
}
}
func TestCompleteConfirmedMCPReplyGeneratesCustomerFacingAnswerWithoutTools(t *testing.T) {
engine := NewAgentLoopEngine()
var systemPrompt string
var userPrompt string
engine.complete = func(_ context.Context, _ models.AIConfig, system, user string) (*ai.ChatCompletionResult, error) {
systemPrompt = system
userPrompt = user
return &ai.ChatCompletionResult{
Content: "当前服务端时间是 2026-07-28 11:51:52。",
ModelName: "test-model",
PromptTokens: 20,
CompletionTokens: 10,
}, nil
}
result, err := engine.completeConfirmedMCPReply(
context.Background(),
models.AIAgent{},
models.AIConfig{ModelName: "test-model"},
"获取当前时间",
"现在几点钟?",
`{"timestamp":"2026-07-28 11:51:52","timezone":"Local"}`,
)
if err != nil {
t.Fatalf("complete confirmed MCP reply: %v", err)
}
if result.Content != "当前服务端时间是 2026-07-28 11:51:52。" {
t.Fatalf("unexpected customer reply: %#v", result)
}
for _, expected := range []string{
"Do not request or invoke another tool",
"现在几点钟?",
"获取当前时间",
`"timestamp":"2026-07-28 11:51:52"`,
} {
if !strings.Contains(systemPrompt+"\n"+userPrompt, expected) {
t.Fatalf("post-tool completion context missing %q: system=%q user=%q", expected, systemPrompt, userPrompt)
}
}
}
func TestConfirmedMCPReplyFallbackDoesNotExposeRawResult(t *testing.T) {
got := buildAgentLoopConfirmedMCPFallback("获取当前时间")
if got != "“获取当前时间”已成功执行。" || strings.Contains(got, "{") {
t.Fatalf("unexpected confirmed MCP fallback: %q", got)
}
}
func TestAgentTurnPublishesAllConfiguredCapabilityKinds(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.SkillDefinition{}); err != nil {
t.Fatalf("migrate Skill: %v", err)
}
sqls.SetDB(db)
skill := models.SkillDefinition{Name: "订单查询", Description: "查询订单状态", Status: enums.StatusOk}
if err := db.Create(&skill).Error; err != nil {
t.Fatalf("create Skill: %v", err)
}
mcp, _ := json.Marshal([]request.AIAgentMCPToolRequest{{
ToolCode: "crm/get_customer", ServerCode: "crm", ToolName: "get_customer",
RiskLevel: "read",
}})
agent := models.AIAgent{SkillIDs: jsonInt64List(skill.ID), AllowedMCPTools: string(mcp)}
snapshot := &svc.AgentRevisionSnapshot{
Agent: agent,
WorkflowBindings: []svc.AgentRevisionWorkflowBinding{{
WorkflowVersionID: 23, ToolName: "创建工单", TriggerInstruction: "用户要求创建工单",
}},
}
engine := NewAgentLoopEngine()
engine.retrieve = nil
engine.history = nil
turn := engine.prepareTurn(context.Background(), RunInput{AIAgent: agent}, snapshot)
for _, code := range []string{"skill/" + jsonInt64List(skill.ID), "workflow/23", "crm/get_customer"} {
if !strings.Contains(turn.SystemPrompt, code) {
t.Fatalf("capability %q missing from prompt:\n%s", code, turn.SystemPrompt)
}
}
}
func jsonInt64List(id int64) string {
data, _ := json.Marshal([]int64{id})
return strings.Trim(string(data), "[]")
}
func TestAgentTurnCatalogFormatAndInstruction(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open in-memory db: %v", err)
}
if err := db.AutoMigrate(&models.SkillDefinition{}); err != nil {
t.Fatalf("migrate Skill: %v", err)
}
sqls.SetDB(db)
skill := models.SkillDefinition{Name: "订单查询", Description: "查询订单状态", Status: enums.StatusOk}
if err := db.Create(&skill).Error; err != nil {
t.Fatalf("create Skill: %v", err)
}
mcp, _ := json.Marshal([]request.AIAgentMCPToolRequest{{
ToolCode: "crm/get_customer", ServerCode: "crm", ToolName: "get_customer",
RiskLevel: "read",
}})
agent := models.AIAgent{SkillIDs: jsonInt64List(skill.ID), AllowedMCPTools: string(mcp)}
snapshot := &svc.AgentRevisionSnapshot{
Agent: agent,
WorkflowBindings: []svc.AgentRevisionWorkflowBinding{{
WorkflowVersionID: 23, ToolName: "创建工单", TriggerInstruction: "用户要求创建工单",
}},
}
engine := NewAgentLoopEngine()
engine.retrieve = nil
engine.history = nil
turn := engine.prepareTurn(context.Background(), RunInput{AIAgent: agent}, snapshot)
expectedDirectFunctions := []string{
"direct function: builtin_conversation_context",
"direct function: builtin_knowledge_retrieve",
"direct function: graph_triage_service_request",
"direct function: skill_" + jsonInt64List(skill.ID),
"direct function: workflow_23",
"direct function: crm_get_customer",
}
for _, expected := range expectedDirectFunctions {
if !strings.Contains(turn.SystemPrompt, expected) {
t.Fatalf("expected direct function %q in system prompt:\n%s", expected, turn.SystemPrompt)
}
}
prohibition := "Never pass a capability code containing slashes as a tool function name; use the exact direct function name provided in the catalog or call tool_search with toolCode."
if !strings.Contains(turn.SystemPrompt, prohibition) {
t.Fatalf("expected prohibition instruction in system prompt:\n%s", turn.SystemPrompt)
}
}
func TestAgentLoopEngineEmitsActivitySafelyWithConversationID(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.AgentRevision{},
&models.Conversation{}, &models.Message{},
&models.AgentRun{}, &models.AgentStep{}, &models.AgentToolCall{},
); 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)
}
definitionJSON, _ := json.Marshal(map[string]any{
"agent": map[string]any{"name": "test-agent"},
})
agent := &models.AIAgent{Name: "test-agent", Status: enums.StatusOk, AIConfigID: config.ID}
if err := db.Create(agent).Error; err != nil {
t.Fatalf("create agent: %v", err)
}
revision := &models.AgentRevision{
AgentID: agent.ID, Status: enums.StatusOk, Revision: 1,
Definition: string(definitionJSON),
}
if err := db.Create(revision).Error; err != nil {
t.Fatalf("create revision: %v", err)
}
agent.PublishedRevisionID = revision.ID
if err := db.Save(agent).Error; err != nil {
t.Fatalf("save agent: %v", err)
}
conv := models.Conversation{ID: 100}
userMsg := models.Message{ID: 200, Content: "test message", SenderType: enums.IMSenderTypeCustomer, MessageType: enums.IMMessageTypeText}
// 1. Test Run with conversation ID > 0 (success)
engine := newAgentLoopEngineWithLoop(func(ctx context.Context, config models.AIConfig, sysPrompt, userPrompt string, tools []ai.ToolDefinition, maxSteps int, executor ai.ToolCallExecutor) (*ai.ToolLoopResult, error) {
return &ai.ToolLoopResult{
ChatCompletionResult: ai.ChatCompletionResult{
Content: "Hello from loop",
ModelName: "test-model",
},
}, nil
})
engine.retrieve = nil
engine.history = nil
res, err := engine.Run(context.Background(), RunInput{
Conversation: conv,
UserMessage: userMsg,
AIAgent: *agent,
AIConfig: *config,
})
if err != nil {
t.Fatalf("expected Run to succeed, got %v", err)
}
if res == nil || res.ReplyText != "Hello from loop" {
t.Fatalf("unexpected result: %#v", res)
}
// 2. Test Run with conversation ID > 0 (loop error)
errEngine := newAgentLoopEngineWithLoop(func(ctx context.Context, config models.AIConfig, sysPrompt, userPrompt string, tools []ai.ToolDefinition, maxSteps int, executor ai.ToolCallExecutor) (*ai.ToolLoopResult, error) {
return nil, errors.New("loop execution failed")
})
errEngine.retrieve = nil
errEngine.history = nil
_, loopErr := errEngine.Run(context.Background(), RunInput{
Conversation: conv,
UserMessage: userMsg,
AIAgent: *agent,
AIConfig: *config,
})
if loopErr == nil || !strings.Contains(loopErr.Error(), "loop execution failed") {
t.Fatalf("expected loop error, got: %v", loopErr)
}
// 3. Test toolSearchExecutor with conversation ID > 0
turn := agentLoopTurn{
AllowedTools: []string{"builtin/conversation_context"},
ToolPolicy: parseAgentLoopToolPolicy(""),
}
state := agentLoopExecutionState{}
var calls []svc.AgentLoopToolCallInput
executor := engine.toolSearchExecutor(RunInput{Conversation: conv}, turn, &state, &calls)
toolResult, err := executor(context.Background(), ai.ToolCall{
Name: "builtin_conversation_context",
Arguments: `{}`,
})
if err != nil {
t.Fatalf("executor tool call failed: %v", err)
}
if toolResult == "" {
t.Fatal("expected non-empty tool result")
}
// 4. Test offline / zero conversation ID
offlineRes, err := engine.Run(context.Background(), RunInput{
Conversation: models.Conversation{ID: 0},
UserMessage: userMsg,
AIAgent: *agent,
AIConfig: *config,
})
if err != nil {
t.Fatalf("expected offline Run to succeed, got %v", err)
}
if offlineRes == nil || offlineRes.ReplyText != "Hello from loop" {
t.Fatalf("unexpected offline result: %#v", offlineRes)
}
}
func TestAgentLoopEngineRetrieveKnowledgeEmitsActivity(t *testing.T) {
engine := NewAgentLoopEngine()
retrievedCalled := false
engine.retrieve = func(ctx context.Context, agent models.AIAgent, query string) (string, int, error) {
retrievedCalled = true
return "evidence", 1, nil
}
// Case 1: KnowledgeIDs present and conversation in context
conv := models.Conversation{ID: 100}
ctx := withAgentLoopConversation(context.Background(), conv)
text, count, err := engine.retrieveKnowledge(ctx, models.AIAgent{KnowledgeIDs: "1,2"}, "query")
if err != nil || !retrievedCalled || text != "evidence" || count != 1 {
t.Fatalf("retrieveKnowledge failed: text=%q count=%d err=%v", text, count, err)
}
// Case 2: No KnowledgeIDs -> retrieve not called, returns empty
retrievedCalled = false
text, count, err = engine.retrieveKnowledge(ctx, models.AIAgent{KnowledgeIDs: ""}, "query")
if err != nil || retrievedCalled || text != "" || count != 0 {
t.Fatalf("expected no retrieve call when KnowledgeIDs is empty")
}
// Case 3: Conversation ID is 0 -> retrieve called safely without panic
retrievedCalled = false
zeroCtx := withAgentLoopConversation(context.Background(), models.Conversation{ID: 0})
text, count, err = engine.retrieveKnowledge(zeroCtx, models.AIAgent{KnowledgeIDs: "1"}, "query")
if err != nil || !retrievedCalled || text != "evidence" {
t.Fatalf("retrieveKnowledge with zero conversation failed")
}
}
func TestAgentLoopToolSearchExecutorEmitsWorkflowActivity(t *testing.T) {
turn := agentLoopTurn{
AllowedTools: []string{"workflow/42"},
ToolPolicy: parseAgentLoopToolPolicy(""),
Workflows: map[int64]svc.AgentRevisionWorkflowBinding{
42: {WorkflowVersionID: 42, ToolName: "Cancel Subscription"},
},
}
state := agentLoopExecutionState{}
var calls []svc.AgentLoopToolCallInput
conv := models.Conversation{ID: 100}
executor := NewAgentLoopEngine().toolSearchExecutor(RunInput{Conversation: conv}, turn, &state, &calls)
// Since workflow 42 is not in db, it will fail to resolve workflow version, but activity is published beforehand
_, _ = executor(context.Background(), ai.ToolCall{
Name: "workflow_42",
Arguments: `{}`,
})
// Check that records captured the attempt
if len(calls) != 1 || calls[0].ToolCode != "workflow/42" {
t.Fatalf("unexpected calls: %#v", calls)
}
}
func TestAgentLoopEngineBuildUserPromptContextWindowLimits(t *testing.T) {
engine := NewAgentLoopEngine()
// Create 50 dummy messages in history
engine.history = func(conversationID int64, limit int) []models.Message {
messages := make([]models.Message, 50)
for i := 0; i < 50; i++ {
messages[i] = models.Message{
ID: int64(i + 1),
SenderType: enums.IMSenderTypeCustomer,
MessageType: enums.IMMessageTypeText,
Content: fmt.Sprintf("message %d", i+1),
}
}
if limit < len(messages) {
return messages[:limit]
}
return messages
}
testCases := []struct {
name string
contextWindow int
expectedCount int
}{
{"default when zero", 0, 10},
{"default when negative", -3, 10},
{"clamped to min 2", 1, 2},
{"within range", 15, 15},
{"clamped to max 30", 45, 30},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
req := RunInput{
Conversation: models.Conversation{ID: 1},
UserMessage: models.Message{ID: 999, Content: "current query", SenderType: enums.IMSenderTypeCustomer, MessageType: enums.IMMessageTypeText},
AIAgent: models.AIAgent{ContextWindow: tc.contextWindow},
}
prompt, count := engine.buildUserPrompt(req)
if count != tc.expectedCount {
t.Fatalf("expected history count %d, got %d (prompt=%q)", tc.expectedCount, count, prompt)
}
})
}
}
func TestAgentLoopEnginePrepareTurnMaxStepsBounds(t *testing.T) {
engine := NewAgentLoopEngine()
engine.retrieve = nil
engine.history = nil
snapshot := &svc.AgentRevisionSnapshot{}
testCases := []struct {
name string
inputMaxSteps int
expectedSteps int
}{
{"default when zero", 0, 4},
{"default when negative", -2, 4},
{"clamped to max 8", 12, 8},
{"within range 6", 6, 6},
{"within range 2", 2, 2},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
turn := engine.prepareTurn(context.Background(), RunInput{
AIAgent: models.AIAgent{MaxSteps: tc.inputMaxSteps},
}, snapshot)
if turn.MaxSteps != tc.expectedSteps {
t.Fatalf("expected turn.MaxSteps %d, got %d", tc.expectedSteps, turn.MaxSteps)
}
})
}
}
func TestAgentLoopEngineRunPassesBoundedMaxSteps(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.AgentRevision{},
&models.Conversation{}, &models.Message{},
&models.AgentRun{}, &models.AgentStep{}, &models.AgentToolCall{},
); 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)
}
definitionJSON, _ := json.Marshal(map[string]any{
"agent": map[string]any{"name": "test-agent", "maxSteps": 15},
})
agent := &models.AIAgent{Name: "test-agent", Status: enums.StatusOk, AIConfigID: config.ID, MaxSteps: 15}
if err := db.Create(agent).Error; err != nil {
t.Fatalf("create agent: %v", err)
}
revision := &models.AgentRevision{
AgentID: agent.ID, Status: enums.StatusOk, Revision: 1,
Definition: string(definitionJSON),
}
if err := db.Create(revision).Error; err != nil {
t.Fatalf("create revision: %v", err)
}
agent.PublishedRevisionID = revision.ID
if err := db.Save(agent).Error; err != nil {
t.Fatalf("save agent: %v", err)
}
var capturedMaxSteps int
engine := newAgentLoopEngineWithLoop(func(ctx context.Context, config models.AIConfig, sysPrompt, userPrompt string, tools []ai.ToolDefinition, maxSteps int, executor ai.ToolCallExecutor) (*ai.ToolLoopResult, error) {
capturedMaxSteps = maxSteps
return &ai.ToolLoopResult{
ChatCompletionResult: ai.ChatCompletionResult{
Content: "Hello",
ModelName: "test-model",
},
}, nil
})
engine.retrieve = nil
engine.history = nil
_, err = engine.Run(context.Background(), RunInput{
Conversation: models.Conversation{ID: 100},
UserMessage: models.Message{ID: 200, Content: "test", SenderType: enums.IMSenderTypeCustomer, MessageType: enums.IMMessageTypeText},
AIAgent: *agent,
AIConfig: *config,
})
if err != nil {
t.Fatalf("Run failed: %v", err)
}
if capturedMaxSteps != 8 {
t.Fatalf("expected capturedMaxSteps to be capped at 8, got %d", capturedMaxSteps)
}
}
func TestAgentLoopEngineMultilingualHandling(t *testing.T) {
engine := NewAgentLoopEngine()
// 1. Language directive appended to Russian prompt
ruPrompt, _ := engine.buildUserPrompt(RunInput{
UserMessage: models.Message{Content: "Здравствуйте, как вернуть товар?"},
})
if !strings.Contains(ruPrompt, "Language directive: The customer is writing in Russian") {
t.Fatalf("expected Russian language directive in prompt, got %q", ruPrompt)
}
// 2. Language directive appended to English/Spanish/global prompt
enPrompt, _ := engine.buildUserPrompt(RunInput{
UserMessage: models.Message{Content: "Hello, can I get a refund?"},
})
if !strings.Contains(enPrompt, "Language directive: You MUST inspect the language used by the customer") {
t.Fatalf("expected universal language directive in English prompt, got %q", enPrompt)
}
esPrompt, _ := engine.buildUserPrompt(RunInput{
UserMessage: models.Message{Content: "Hola, ¿cómo puedo rastrear mi paquete?"},
})
if !strings.Contains(esPrompt, "Language directive: You MUST inspect the language used by the customer") {
t.Fatalf("expected universal language directive in Spanish prompt, got %q", esPrompt)
}
zhPrompt, _ := engine.buildUserPrompt(RunInput{
UserMessage: models.Message{Content: "你好,怎么退款?"},
})
if !strings.Contains(zhPrompt, "Language directive: The customer is writing in Chinese") {
t.Fatalf("expected Chinese language directive in Chinese prompt, got %q", zhPrompt)
}
// 3. Russian confirmation and cancellation parsing
if parseAgentLoopConfirmation("да") != agentLoopConfirmationConfirmed {
t.Fatalf("expected 'да' to be confirmed")
}
if parseAgentLoopConfirmation("подтверждаю") != agentLoopConfirmationConfirmed {
t.Fatalf("expected 'подтверждаю' to be confirmed")
}
if parseAgentLoopConfirmation("нет") != agentLoopConfirmationCancelled {
t.Fatalf("expected 'нет' to be cancelled")
}
if parseAgentLoopConfirmation("отмена") != agentLoopConfirmationCancelled {
t.Fatalf("expected 'отмена' to be cancelled")
}
// 4. Russian MCP confirmation prompt and fallback
ruConfirmationPrompt := buildAgentLoopMCPConfirmationPrompt("Создание заказа", "Создайте заказ, пожалуйста")
if !strings.Contains(ruConfirmationPrompt, "Будет выполнено действие «Создание заказа». Подтверждаете продолжение?") {
t.Fatalf("unexpected Russian MCP confirmation prompt: %q", ruConfirmationPrompt)
}
ruFallback := buildAgentLoopConfirmedMCPFallback("Создание заказа", "Создайте заказ, пожалуйста")
if !strings.Contains(ruFallback, "Действие «Создание заказа» успешно выполнено.") {
t.Fatalf("unexpected Russian MCP fallback: %q", ruFallback)
}
}

View File

@@ -0,0 +1,102 @@
package runtime
import (
"context"
"fmt"
"strings"
"agent-desk/internal/models"
"agent-desk/internal/pkg/enums"
"agent-desk/internal/pkg/toolx"
"agent-desk/internal/pkg/utils"
svc "agent-desk/internal/services"
)
type agentLoopTurn struct {
RetrieverCount int
RetrieveErr error
ResponsePolicy agentLoopResponsePolicy
SystemPrompt string
UserPrompt string
HistoryCount int
AllowedTools []string
ToolPolicy agentLoopToolPolicy
Skills map[int64]models.SkillDefinition
Workflows map[int64]svc.AgentRevisionWorkflowBinding
MaxSteps int
}
func (e *AgentLoopEngine) prepareTurn(ctx context.Context, req RunInput, snapshot *svc.AgentRevisionSnapshot) agentLoopTurn {
maxSteps := req.AIAgent.MaxSteps
if maxSteps <= 0 {
maxSteps = 4
}
if maxSteps > 8 {
maxSteps = 8
}
knowledgeContext, retrieverCount, retrieveErr := e.retrieveKnowledge(ctx, req.AIAgent, req.UserMessage.Content)
responsePolicy := evaluateAgentLoopResponsePolicy(req.AIAgent, knowledgeContext, retrieveErr)
systemPrompt := buildAgentLoopSystemPrompt(req.AIAgent, len(utils.SplitInt64s(req.AIAgent.KnowledgeIDs)) > 0, knowledgeContext, retrieveErr)
userPrompt, historyCount := e.buildUserPrompt(req)
if knowledgeContext != "" {
userPrompt += "\n\nKnowledge evidence:\n" + knowledgeContext
}
skills := svc.SkillDefinitionService.GetByIDs(utils.SplitInt64s(req.AIAgent.SkillIDs))
workflows := make(map[int64]svc.AgentRevisionWorkflowBinding, len(snapshot.WorkflowBindings))
allowedTools := agentLoopSafeBuiltinCodes()
// TODO 这么实现我觉得不太好,最好是能够有个统一的能力目录
catalog := []string{
"- " + toolx.BuiltinConversationContext.Code + " | Builtin | Read current conversation and customer context | direct function: " + toolx.SanitizeToolFunctionName(toolx.BuiltinConversationContext.Code),
"- " + toolx.BuiltinKnowledgeRetrieve.Code + " | Builtin | Retrieve linked knowledge base on demand | direct function: " + toolx.SanitizeToolFunctionName(toolx.BuiltinKnowledgeRetrieve.Code),
"- " + toolx.GraphTriageServiceRequest.Code + " | Builtin | Analyze service request and generate handling advice | direct function: " + toolx.SanitizeToolFunctionName(toolx.GraphTriageServiceRequest.Code),
"- " + toolx.GraphAnalyzeConversation.Code + " | Builtin | Analyze conversation intent and risk signals | direct function: " + toolx.SanitizeToolFunctionName(toolx.GraphAnalyzeConversation.Code),
"- " + toolx.GraphPrepareTicketDraft.Code + " | Builtin | Generate ticket draft without writing | direct function: " + toolx.SanitizeToolFunctionName(toolx.GraphPrepareTicketDraft.Code),
}
for id, skill := range skills {
if skill.Status != enums.StatusOk {
continue
}
code := agentLoopSkillCode(id)
allowedTools = append(allowedTools, code)
catalog = append(catalog, fmt.Sprintf("- %s | Skill | %s | %s | direct function: %s", code, strings.TrimSpace(skill.Name), strings.TrimSpace(skill.Description), toolx.SanitizeToolFunctionName(code)))
}
for _, binding := range snapshot.WorkflowBindings {
if binding.WorkflowVersionID <= 0 {
continue
}
workflows[binding.WorkflowVersionID] = binding
code := agentLoopWorkflowCode(binding.WorkflowVersionID)
allowedTools = append(allowedTools, code)
catalog = append(catalog, fmt.Sprintf("- %s | Workflow | %s | %s | direct function: %s", code, strings.TrimSpace(binding.ToolName), strings.TrimSpace(binding.TriggerInstruction), toolx.SanitizeToolFunctionName(code)))
}
mcpTools, _ := toolx.ParseAgentMCPToolsJSON(req.AIAgent.AllowedMCPTools)
for _, tool := range mcpTools {
if strings.TrimSpace(tool.ToolCode) == "" {
continue
}
allowedTools = append(allowedTools, tool.ToolCode)
catalog = append(catalog, fmt.Sprintf("- %s | MCP | %s | %s | direct function: %s", tool.ToolCode, tool.Title, tool.Description, toolx.SanitizeToolFunctionName(tool.ToolCode)))
}
systemPrompt += "\n\nAvailable capabilities:\n" + strings.Join(catalog, "\n")
systemPrompt += "\n\nUse tool_search with an exact capability code only when needed. You decide whether to answer directly, activate a Skill, execute a Workflow, retrieve knowledge, or call MCP. A Skill activation returns instructions for this same run. Never invent a capability code. Never pass a capability code containing slashes as a tool function name; use the exact direct function name provided in the catalog or call tool_search with toolCode."
systemPrompt += "\n\nHUMAN OPERATOR TRANSFER & SEMANTIC INTENT RECOGNITION:\n" +
"- Customers may request to speak to a human operator, specialist, agent, manager, representative, or live person in many different ways across ANY language (e.g., 'переведи на оператора', 'соедини со специалистом', 'позови сотрудника', 'хочу живого человека', 'мне нужен оператор', 'хватит бота', 'с кем я могу поговорить кроме вас', 'can I speak with someone?', 'connect me to an agent', 'I want a real person', 'representative please', 'let me talk to support', 'quiero hablar con un humano', 'ich möchte mit einem Mitarbeiter sprechen', etc.).\n" +
"- Do NOT require a specific hardcoded phrase. You MUST understand this intent semantically from the user's message regardless of their wording, slang, or language.\n" +
"- When the customer indicates or asks that they want to be connected to a human operator or specialist, you MUST IMMEDIATELY call the conversation_decision tool with action='handoff', handoffInitiator='customer', handoffConfirmed=true, and a short reason. Do NOT ask for confirmation again, do NOT refuse, and do NOT claim in text that the handoff succeeded without calling conversation_decision.\n" +
"- Use action='ask_handoff_confirmation' only when YOU (the AI agent), not the customer, proactively suggest transferring to a human, with handoffInitiator='agent' and handoffConfirmed=false.\n" +
"- For any regular response to the customer, call conversation_decision with action='reply', handoffInitiator='none', handoffConfirmed=false, and your response in reply."
systemPrompt += "\n\nCRITICAL MULTILINGUAL RESPONSE DIRECTIVE: You MUST ALWAYS reply in the EXACT SAME LANGUAGE that the customer uses in their inquiry (e.g. Russian, English, Spanish, German, French, Arabic, Turkish, Kazakh, Italian, Portuguese, Japanese, etc.). Under NO circumstances should you default or drift into Chinese or any language other than the customer's language. Even if capabilities, tool names, or tool outputs contain Chinese or English, translate everything seamlessly into the customer's language."
return agentLoopTurn{
RetrieverCount: retrieverCount,
RetrieveErr: retrieveErr,
ResponsePolicy: responsePolicy,
SystemPrompt: systemPrompt,
UserPrompt: userPrompt,
HistoryCount: historyCount,
AllowedTools: allowedTools,
ToolPolicy: parseAgentLoopToolPolicy(req.AIAgent.ToolPolicy),
Skills: skills,
Workflows: workflows,
MaxSteps: maxSteps,
}
}

View File

@@ -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
// Agent Loop 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
}

View File

@@ -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")
}
}

View File

@@ -0,0 +1,162 @@
package runtime
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
ai "agent-desk/internal/ai"
"agent-desk/internal/models"
einoopenai "github.com/cloudwego/eino-ext/components/model/openai"
einomodel "github.com/cloudwego/eino/components/model"
einotool "github.com/cloudwego/eino/components/tool"
"github.com/cloudwego/eino/compose"
"github.com/cloudwego/eino/flow/agent/react"
"github.com/cloudwego/eino/schema"
einojsonschema "github.com/eino-contrib/jsonschema"
)
// einoAgentLoop is the production model/tool loop. AgentDesk still owns tool
// authorization, business execution, interrupts, idempotency, and auditing.
func einoAgentLoop(
ctx context.Context,
config models.AIConfig,
systemPrompt string,
userPrompt string,
definitions []ai.ToolDefinition,
maxSteps int,
execute ai.ToolCallExecutor,
) (*ai.ToolLoopResult, error) {
model, err := newEinoChatModel(ctx, config)
if err != nil {
return nil, err
}
if maxSteps <= 0 {
maxSteps = 4
}
if maxSteps > 8 {
maxSteps = 8
}
tools := make([]einotool.BaseTool, 0, len(definitions))
for _, definition := range definitions {
tool, buildErr := newEinoFunctionTool(definition, execute)
if buildErr != nil {
return nil, buildErr
}
tools = append(tools, tool)
}
agent, err := react.NewAgent(ctx, &react.AgentConfig{
ToolCallingModel: model,
ToolsConfig: compose.ToolsNodeConfig{Tools: tools},
MaxStep: maxSteps,
})
if err != nil {
return nil, fmt.Errorf("create Eino agent loop: %w", err)
}
messages := make([]*schema.Message, 0, 2)
if value := strings.TrimSpace(systemPrompt); value != "" {
messages = append(messages, schema.SystemMessage(value))
}
messages = append(messages, schema.UserMessage(strings.TrimSpace(userPrompt)))
result, err := agent.Generate(ctx, messages)
if err != nil {
return nil, err
}
if result == nil {
return nil, fmt.Errorf("Eino agent loop returned no result")
}
ret := &ai.ToolLoopResult{ChatCompletionResult: ai.ChatCompletionResult{
Content: strings.TrimSpace(result.Content),
ModelName: config.ModelName,
}}
if result.ResponseMeta != nil && result.ResponseMeta.Usage != nil {
ret.PromptTokens = result.ResponseMeta.Usage.PromptTokens
ret.CompletionTokens = result.ResponseMeta.Usage.CompletionTokens
}
return ret, nil
}
func newEinoChatModel(ctx context.Context, config models.AIConfig) (einomodel.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
}
if isDashScopeQwenThinkingModel(config) {
modelConfig.ExtraFields = map[string]any{"enable_thinking": false}
}
model, err := einoopenai.NewChatModel(ctx, modelConfig)
if err != nil {
return nil, fmt.Errorf("create Eino OpenAI-compatible model: %w", err)
}
return model, nil
}
func isDashScopeQwenThinkingModel(config models.AIConfig) bool {
baseURL := strings.ToLower(strings.TrimSpace(config.BaseURL))
modelName := strings.ToLower(strings.TrimSpace(config.ModelName))
return strings.Contains(baseURL, "dashscope.aliyuncs.com") && strings.HasPrefix(modelName, "qwen3")
}
type einoFunctionTool struct {
info *schema.ToolInfo
execute ai.ToolCallExecutor
}
var _ einotool.InvokableTool = (*einoFunctionTool)(nil)
func newEinoFunctionTool(definition ai.ToolDefinition, execute ai.ToolCallExecutor) (*einoFunctionTool, error) {
if strings.TrimSpace(definition.Name) == "" || execute == nil {
return nil, fmt.Errorf("Eino tool name and executor are required")
}
info := &schema.ToolInfo{
Name: strings.TrimSpace(definition.Name),
Desc: strings.TrimSpace(definition.Description),
}
if len(definition.Parameters) > 0 {
data, err := json.Marshal(definition.Parameters)
if err != nil {
return nil, fmt.Errorf("encode Eino tool schema: %w", err)
}
var params einojsonschema.Schema
if err := json.Unmarshal(data, &params); err != nil {
return nil, fmt.Errorf("decode Eino tool schema: %w", err)
}
info.ParamsOneOf = schema.NewParamsOneOfByJSONSchema(&params)
}
return &einoFunctionTool{info: info, execute: execute}, nil
}
func (t *einoFunctionTool) Info(context.Context) (*schema.ToolInfo, error) {
return t.info, nil
}
func (t *einoFunctionTool) InvokableRun(ctx context.Context, arguments string, _ ...einotool.Option) (string, error) {
result, err := t.execute(ctx, ai.ToolCall{Name: t.info.Name, Arguments: arguments})
if err == nil {
return result, nil
}
var interrupt *agentLoopInterruptError
if errors.As(err, &interrupt) {
return "", err
}
observation, marshalErr := json.Marshal(map[string]string{"error": err.Error()})
if marshalErr != nil {
return "", err
}
return string(observation), nil
}

View File

@@ -0,0 +1,145 @@
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"`
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 {
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, agent models.AIAgent, config models.AIConfig, cases []OfflineEvaluationCase) OfflineEvaluationReport {
report := OfflineEvaluationReport{Results: make([]OfflineEvaluationResult, 0, len(cases))}
for _, item := range cases {
result := OfflineEvaluationResult{CaseID: strings.TrimSpace(item.ID), Category: strings.TrimSpace(item.Category)}
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", "passed", "interrupted", "finding", "error", "replyText"}); err != nil {
return "", err
}
for _, item := range r.Results {
if err := writer.Write([]string{item.CaseID, item.Category, 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
}

View File

@@ -0,0 +1,220 @@
package runtime
import (
"context"
"encoding/json"
"strings"
"time"
workflowexecutor "agent-desk/internal/ai/runtime/workflow"
"agent-desk/internal/models"
"agent-desk/internal/repositories"
"github.com/mlogclub/simple/sqls"
)
type Service struct {
engine *AgentLoopEngine
}
const (
workflowRunStatusCompleted = 1
workflowRunStatusInterrupted = 2
workflowRunStatusFailed = 3
)
func NewService() *Service {
return NewServiceWithEngine(NewAgentLoopEngine())
}
func NewServiceWithEngine(engine *AgentLoopEngine) *Service {
return &Service{engine: engine}
}
func (s *Service) Run(ctx context.Context, req RunInput) (*RunResult, error) {
return s.engine.Run(ctx, req)
}
func (s *Service) Resume(ctx context.Context, req ResumeInput) (*RunResult, error) {
return s.engine.Resume(ctx, req)
}
func (s *Service) RunOfflineEvaluation(ctx context.Context, agent models.AIAgent, config models.AIConfig, cases []OfflineEvaluationCase) (OfflineEvaluationReport, error) {
runner := NewOfflineEvaluationRunner(s.engine.Run)
return runner.Run(ctx, agent, config, cases), nil
}
func toWorkflowResult(result *workflowexecutor.Result, modelName string, workflow resolvedWorkflow, workflowRunID int64) *RunResult {
if result == nil {
return nil
}
trace := map[string]any{
"status": result.Status,
"workflowId": workflow.WorkflowID,
"workflowVersionId": workflow.VersionID,
"workflowRunId": workflowRunID,
"nodePath": result.NodePath,
}
traceData, _ := json.Marshal(trace)
return &RunResult{
Status: result.Status,
ReplyText: result.ReplyText,
ModelName: modelName,
PromptTokens: result.PromptTokens,
CompletionTokens: result.CompletionTokens,
RetrieverCount: result.RetrieverCount,
WorkflowID: workflow.WorkflowID,
WorkflowVersionID: workflow.VersionID,
WorkflowRunID: workflowRunID,
WorkflowNodePath: append([]string(nil), result.NodePath...),
TraceData: string(traceData),
CheckPointID: result.CheckPointID,
CheckPointData: result.CheckPointData,
Interrupted: result.Interrupted,
Interrupts: toWorkflowInterruptSummaries(result.Interrupts),
}
}
func toWorkflowInterruptSummaries(items []workflowexecutor.InterruptSummary) []InterruptContextSummary {
if len(items) == 0 {
return nil
}
ret := make([]InterruptContextSummary, 0, len(items))
for _, item := range items {
ret = append(ret, InterruptContextSummary{
Type: item.Type,
ID: item.ID,
InfoPreview: item.InfoPreview,
})
}
return ret
}
func writeWorkflowRun(req RunInput, workflow resolvedWorkflow, result *workflowexecutor.Result, errorMessage string) (int64, error) {
return writeWorkflowRunWithExistingID(req, workflow, result, errorMessage, 0)
}
func writeWorkflowRunWithExistingID(req RunInput, workflow resolvedWorkflow, result *workflowexecutor.Result, errorMessage string, existingRunID int64) (int64, error) {
if result == nil {
return 0, nil
}
now := time.Now()
endedAt := now
nodeTypes := make(map[string]string, len(workflow.Definition.Nodes))
for _, node := range workflow.Definition.Nodes {
nodeTypes[node.ID] = node.Type
}
runStatus := workflowRunStatus(result.Status, errorMessage)
var runID int64
err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
run := repositories.AIWorkflowRunRepository.Get(ctx.Tx, existingRunID)
if run == nil {
run = &models.AIWorkflowRun{
WorkflowID: workflow.WorkflowID,
WorkflowVersionID: workflow.VersionID,
ConversationID: req.Conversation.ID,
AIAgentID: req.AIAgent.ID,
MessageID: req.UserMessage.ID,
Status: runStatus,
StartedAt: now,
EndedAt: &endedAt,
InterruptType: firstWorkflowInterruptType(result),
InterruptNodeID: firstWorkflowInterruptNodeID(result),
ErrorMessage: errorMessage,
}
if err := repositories.AIWorkflowRunRepository.Create(ctx.Tx, run); err != nil {
return err
}
} else if err := repositories.AIWorkflowRunRepository.Updates(ctx.Tx, run.ID, map[string]any{
"status": runStatus,
"ended_at": &endedAt,
"interrupt_type": firstWorkflowInterruptType(result),
"interrupt_node_id": firstWorkflowInterruptNodeID(result),
"error_message": errorMessage,
"updated_at": now,
}); err != nil {
return err
}
runID = run.ID
nodeTraces := result.NodeTraces
if len(nodeTraces) == 0 {
nodeTraces = fallbackWorkflowNodeTraces(result.NodePath, nodeTypes, result.Status)
}
for _, nodeTrace := range nodeTraces {
nodeRun := &models.AIWorkflowNodeRun{
WorkflowRunID: run.ID,
NodeID: nodeTrace.NodeID,
NodeType: firstNonEmpty(nodeTrace.NodeType, nodeTypes[nodeTrace.NodeID]),
Status: workflowRunStatus(nodeTrace.Status, nodeTrace.ErrorMessage),
InputPreview: nodeTrace.InputPreview,
OutputPreview: nodeTrace.OutputPreview,
ErrorMessage: nodeTrace.ErrorMessage,
StartedAt: now,
EndedAt: &endedAt,
DurationMS: nodeTrace.DurationMS,
}
if err := repositories.AIWorkflowNodeRunRepository.Create(ctx.Tx, nodeRun); err != nil {
return err
}
}
return nil
})
return runID, 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 {
if strings.TrimSpace(errorMessage) != "" || strings.TrimSpace(status) == "error" {
return workflowRunStatusFailed
}
switch strings.TrimSpace(status) {
case "interrupted":
return workflowRunStatusInterrupted
default:
return workflowRunStatusCompleted
}
}
func fallbackWorkflowNodeTraces(nodePath []string, nodeTypes map[string]string, status string) []workflowexecutor.NodeTrace {
ret := make([]workflowexecutor.NodeTrace, 0, len(nodePath))
for _, nodeID := range nodePath {
ret = append(ret, workflowexecutor.NodeTrace{
NodeID: nodeID,
NodeType: nodeTypes[nodeID],
Status: status,
})
}
return ret
}
func firstWorkflowInterruptType(result *workflowexecutor.Result) string {
if result == nil || len(result.Interrupts) == 0 {
return ""
}
return strings.TrimSpace(result.Interrupts[0].Type)
}
func firstWorkflowInterruptNodeID(result *workflowexecutor.Result) string {
if result == nil || len(result.Interrupts) == 0 {
return ""
}
return strings.TrimSpace(result.Interrupts[0].ID)
}
func firstNonEmpty(items ...string) string {
for _, item := range items {
if strings.TrimSpace(item) != "" {
return strings.TrimSpace(item)
}
}
return ""
}

View File

@@ -0,0 +1,113 @@
package runtime
import (
"agent-desk/internal/models"
"time"
)
// RunInput is the normalized, fully prepared input for the Agent Loop.
type RunInput struct {
Conversation models.Conversation
UserMessage models.Message
AIAgent models.AIAgent
AIConfig models.AIConfig
CheckPointID string
Debug bool
}
// ResumeInput extends the prepared input with an approved interrupt payload.
type ResumeInput struct {
Conversation models.Conversation
UserMessage models.Message
AIAgent models.AIAgent
AIConfig models.AIConfig
CheckPointID string
ResumeData map[string]string
Debug bool
}
type InterruptContextSummary struct {
Type string `json:"type,omitempty"`
ID string `json:"id"`
DisplayName string `json:"displayName,omitempty"`
PromptText string `json:"promptText,omitempty"`
InfoPreview string `json:"infoPreview,omitempty"`
}
// RunResult is the normalized Agent Loop result.
type RunResult struct {
RunID string
Status string
ReplyText string
PlannedSkillID int64
PlannedSkillName string
SkillAllowedToolCodes []string
ModelName string
PromptTokens int
CompletionTokens int
HistoryMessageCount int
RetrieverCount int
ToolCallCount int
InvokedToolCodes []string
WorkflowID int64
WorkflowVersionID int64
WorkflowRunID int64
AgentRunID int64
WorkflowNodePath []string
CheckPointID string
CheckPointData string
Interrupted bool
HandoffRequested bool
HandoffReason string
ConversationDecision *ConversationDecision
Interrupts []InterruptContextSummary
TraceData string
ErrorMessage string
}
type ConversationAction string
const (
ConversationActionReply ConversationAction = "reply"
ConversationActionHandoff ConversationAction = "handoff"
ConversationActionAskHandoffConfirmation ConversationAction = "ask_handoff_confirmation"
)
type HandoffInitiator string
const (
HandoffInitiatorNone HandoffInitiator = "none"
HandoffInitiatorCustomer HandoffInitiator = "customer"
HandoffInitiatorAgent HandoffInitiator = "agent"
)
// ConversationDecision is the model's structured, non-side-effect decision.
// The runtime remains the only component that can execute a handoff.
type ConversationDecision struct {
Action ConversationAction `json:"action"`
Reason string `json:"reason"`
Reply string `json:"reply"`
HandoffInitiator HandoffInitiator `json:"handoffInitiator"`
HandoffConfirmed bool `json:"handoffConfirmed"`
}
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.
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"`
}

View File

@@ -0,0 +1,37 @@
package runtime
import (
"encoding/json"
"agent-desk/internal/ai/workflow/dsl"
"agent-desk/internal/pkg/enums"
"agent-desk/internal/pkg/errorsx"
"agent-desk/internal/repositories"
"github.com/mlogclub/simple/sqls"
)
type resolvedWorkflow struct {
Definition dsl.Definition
WorkflowID int64
VersionID int64
}
func resolveWorkflowVersion(workflowVersionID int64) (resolvedWorkflow, error) {
if workflowVersionID <= 0 {
return resolvedWorkflow{}, errorsx.InvalidParam("workflow version is required")
}
version := repositories.AIWorkflowVersionRepository.Get(sqls.DB(), workflowVersionID)
if version == nil || version.Status != enums.StatusOk {
return resolvedWorkflow{}, errorsx.InvalidParam("workflow version does not exist")
}
var def dsl.Definition
if err := json.Unmarshal([]byte(version.Definition), &def); err != nil {
return resolvedWorkflow{}, errorsx.InvalidParam("workflow definition is invalid")
}
return resolvedWorkflow{
Definition: def,
WorkflowID: version.WorkflowID,
VersionID: version.ID,
}, nil
}