diff --git a/internal/ai/application/runtime/agent_loop_engine.go b/internal/ai/application/runtime/agent_loop_engine.go index 0a69880..6b067dd 100644 --- a/internal/ai/application/runtime/agent_loop_engine.go +++ b/internal/ai/application/runtime/agent_loop_engine.go @@ -69,7 +69,8 @@ func (e *AgentLoopEngine) Run(ctx context.Context, req RunInput) (*RunResult, er turn := e.prepareTurn(ctx, req, snapshot) var toolCalls []svc.AgentLoopToolCallInput state := agentLoopExecutionState{} - loopResult, loopErr := e.loop(ctx, req.AIConfig, turn.SystemPrompt, turn.UserPrompt, agentLoopToolDefinitions(turn), req.AIAgent.MaxSteps, + definitions := append(agentLoopToolDefinitions(turn), agentLoopDecisionTool) + loopResult, loopErr := e.loop(ctx, req.AIConfig, turn.SystemPrompt, turn.UserPrompt, definitions, req.AIAgent.MaxSteps, e.toolSearchExecutor(req, turn, &state, &toolCalls)) if state.Interrupted != nil { result := state.Interrupted @@ -90,12 +91,18 @@ func (e *AgentLoopEngine) Run(ctx context.Context, req RunInput) (*RunResult, er return nil, err } result := &loopResult.ChatCompletionResult - if strings.TrimSpace(result.Content) == "" { + replyText, handoffRequested, handoffReason, err := resolveAgentLoopReply(result.Content, state.Decision) + if err != nil { + _, _ = writeAgentLoopRun(req, startedAt, nil, turn.UserPrompt, turn.HistoryCount, turn.RetrieverCount, turn.RetrieveErr, state.SkillContext, turn.ResponsePolicy, toolCalls, err, false, state.WorkflowSteps) + return nil, err + } + result.Content = replyText + if result.Content == "" && !handoffRequested { err = errorsx.InvalidParam("Agent Loop returned an empty reply") _, _ = writeAgentLoopRun(req, startedAt, nil, turn.UserPrompt, turn.HistoryCount, turn.RetrieverCount, turn.RetrieveErr, state.SkillContext, turn.ResponsePolicy, toolCalls, err, false, state.WorkflowSteps) return nil, err } - result.Content, err = aitooling.NormalizeCustomerReply(result.Content) + result.Content, err = normalizeAgentLoopReply(result.Content, handoffRequested) if err != nil { _, _ = writeAgentLoopRun(req, startedAt, nil, turn.UserPrompt, turn.HistoryCount, turn.RetrieverCount, turn.RetrieveErr, state.SkillContext, turn.ResponsePolicy, toolCalls, err, false, state.WorkflowSteps) return nil, err @@ -128,11 +135,34 @@ func (e *AgentLoopEngine) Run(ctx context.Context, req RunInput) (*RunResult, er InvokedToolCodes: agentLoopInvokedToolCodes(toolCalls), WorkflowRunID: state.WorkflowRunID, AgentRunID: runID, - HandoffRequested: turn.ResponsePolicy.RequestHandoff && !req.Debug, + HandoffRequested: handoffRequested && !req.Debug, + HandoffReason: handoffReason, + ConversationDecision: state.Decision, TraceData: string(trace), }, nil } +func resolveAgentLoopReply(modelReply string, decision *ConversationDecision) (reply string, handoffRequested bool, handoffReason string, err error) { + if decision == nil { + return strings.TrimSpace(modelReply), false, "", nil + } + switch decision.Action { + case ConversationActionHandoff: + return "", true, decision.Reason, nil + case ConversationActionReply, ConversationActionAskHandoffConfirmation: + return strings.TrimSpace(decision.Reply), false, "", nil + default: + return "", false, "", fmt.Errorf("invalid conversation decision action: %s", decision.Action) + } +} + +func normalizeAgentLoopReply(reply string, handoffRequested bool) (string, error) { + if handoffRequested { + return "", nil + } + return aitooling.NormalizeCustomerReply(reply) +} + func (e *AgentLoopEngine) buildUserPrompt(req RunInput) (string, int) { limit := req.AIAgent.ContextWindow if limit <= 0 { @@ -470,8 +500,8 @@ func parseAgentLoopToolPolicy(raw string) agentLoopToolPolicy { return policy } -func agentLoopToolSearchDefinition() ai.ToolDefinition { - return ai.ToolDefinition{ +var ( + agentLoopToolSearchTool = ai.ToolDefinition{ Name: "tool_search", Description: "Activate a configured Skill or execute a configured Workflow, builtin capability, or MCP tool. Pass the exact capability code and arguments.", Parameters: map[string]any{ @@ -483,7 +513,22 @@ func agentLoopToolSearchDefinition() ai.ToolDefinition { "required": []string{"toolCode", "arguments"}, }, } -} + agentLoopDecisionTool = ai.ToolDefinition{ + Name: "conversation_decision", + Description: "Return the final structured conversation decision after completing any needed analysis or tool calls.", + Parameters: map[string]any{ + "type": "object", + "properties": map[string]any{ + "action": map[string]any{"type": "string", "enum": []string{"reply", "handoff", "ask_handoff_confirmation"}}, + "reason": map[string]any{"type": "string"}, + "reply": map[string]any{"type": "string"}, + "handoffInitiator": map[string]any{"type": "string", "enum": []string{"none", "customer", "agent"}}, + "handoffConfirmed": map[string]any{"type": "boolean"}, + }, + "required": []string{"action", "reason", "reply", "handoffInitiator", "handoffConfirmed"}, + }, + } +) // agentLoopToolDefinitions registers the capability codes as compatibility // aliases in addition to tool_search. Some OpenAI-compatible providers invoke @@ -492,7 +537,7 @@ func agentLoopToolSearchDefinition() ai.ToolDefinition { // those calls must be registered here and then routed through the same policy // boundary below. func agentLoopToolDefinitions(turn agentLoopTurn) []ai.ToolDefinition { - definitions := []ai.ToolDefinition{agentLoopToolSearchDefinition()} + definitions := []ai.ToolDefinition{agentLoopToolSearchTool} seen := map[string]struct{}{"tool_search": {}} for _, code := range turn.AllowedTools { code = strings.TrimSpace(code) @@ -552,6 +597,7 @@ type agentLoopExecutionState struct { WorkflowRunID int64 WorkflowSteps []svc.AgentLoopStepInput Interrupted *RunResult + Decision *ConversationDecision } type agentLoopInterruptError struct { @@ -565,6 +611,23 @@ func (e *agentLoopInterruptError) Error() string { func (e *AgentLoopEngine) toolSearchExecutor(runInput RunInput, turn agentLoopTurn, state *agentLoopExecutionState, records *[]svc.AgentLoopToolCallInput) ai.ToolCallExecutor { return func(ctx context.Context, call ai.ToolCall) (string, error) { startedAt := time.Now() + if call.Name == "conversation_decision" { + decision, err := parseConversationDecision(call.Arguments) + if err != nil { + return "", err + } + state.Decision = decision + result, _ := json.Marshal(decision) + *records = append(*records, svc.AgentLoopToolCallInput{ + ToolCode: "conversation_decision", + RiskLevel: aitooling.RiskLevelRead, + Status: "completed", + ArgumentsPreview: aitooling.SanitizePreview(call.Arguments), + ResultPreview: aitooling.SanitizePreview(string(result)), + DurationMS: int(time.Since(startedAt).Milliseconds()), + }) + return string(result), nil + } toolCode, arguments, err := resolveAgentLoopToolCall(call) if err != nil { return "", err @@ -629,6 +692,35 @@ func (e *AgentLoopEngine) toolSearchExecutor(runInput RunInput, turn agentLoopTu } } +func parseConversationDecision(raw string) (*ConversationDecision, error) { + decision := &ConversationDecision{} + if err := json.Unmarshal([]byte(raw), decision); err != nil { + return nil, fmt.Errorf("invalid conversation decision: %w", err) + } + decision.Reason = strings.TrimSpace(decision.Reason) + decision.Reply = strings.TrimSpace(decision.Reply) + switch decision.Action { + case ConversationActionReply: + if decision.HandoffInitiator != HandoffInitiatorNone || decision.HandoffConfirmed { + return nil, fmt.Errorf("reply decision must not contain handoff state") + } + case ConversationActionHandoff: + if decision.HandoffInitiator == HandoffInitiatorNone || !decision.HandoffConfirmed { + return nil, fmt.Errorf("handoff decision requires a confirmed handoff initiator") + } + case ConversationActionAskHandoffConfirmation: + if decision.HandoffInitiator != HandoffInitiatorAgent || decision.HandoffConfirmed { + return nil, fmt.Errorf("handoff confirmation may only be requested for an unconfirmed agent recommendation") + } + default: + return nil, fmt.Errorf("invalid conversation decision action: %s", decision.Action) + } + if decision.Action != ConversationActionHandoff && decision.Reply == "" { + return nil, fmt.Errorf("conversation decision reply is required") + } + return decision, nil +} + func resolveAgentLoopToolCall(call ai.ToolCall) (string, map[string]any, error) { if call.Name == "tool_search" { var request agentLoopToolSearchRequest diff --git a/internal/ai/application/runtime/agent_loop_engine_test.go b/internal/ai/application/runtime/agent_loop_engine_test.go index fbf0950..9b26b80 100644 --- a/internal/ai/application/runtime/agent_loop_engine_test.go +++ b/internal/ai/application/runtime/agent_loop_engine_test.go @@ -69,6 +69,68 @@ func TestAgentLoopRegistersDirectCapabilityAliases(t *testing.T) { } } +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 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, diff --git a/internal/ai/application/runtime/agent_turn.go b/internal/ai/application/runtime/agent_turn.go index b1c46c7..cb03503 100644 --- a/internal/ai/application/runtime/agent_turn.go +++ b/internal/ai/application/runtime/agent_turn.go @@ -70,7 +70,7 @@ func (e *AgentLoopEngine) prepareTurn(ctx context.Context, req RunInput, snapsho catalog = append(catalog, fmt.Sprintf("- %s | MCP | %s | %s", tool.ToolCode, tool.Title, tool.Description)) } 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." + 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. For any requested internal action such as human handoff, call conversation_decision; its action is a structured proposal only, and the runtime performs the action. When the customer explicitly asks for human support, set action=handoff, handoffInitiator=customer, and handoffConfirmed=true; do not ask again. Use ask_handoff_confirmation only when you, not the customer, recommend an unconfirmed handoff, with handoffInitiator=agent and handoffConfirmed=false. Never claim a handoff, assignment, or queue entry succeeded in reply text." return agentLoopTurn{ RetrieverCount: retrieverCount, RetrieveErr: retrieveErr, diff --git a/internal/ai/application/runtime/types.go b/internal/ai/application/runtime/types.go index ad2c6e2..5001dd6 100644 --- a/internal/ai/application/runtime/types.go +++ b/internal/ai/application/runtime/types.go @@ -58,11 +58,39 @@ type RunResult struct { 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 ( diff --git a/internal/ai/runtime/reply_trigger_service.go b/internal/ai/runtime/reply_trigger_service.go index 2f1b703..f3a18e0 100644 --- a/internal/ai/runtime/reply_trigger_service.go +++ b/internal/ai/runtime/reply_trigger_service.go @@ -89,11 +89,12 @@ func (s *aiReplyService) executeReply(ctx context.Context, replyCtx aiReplyConte if _, err := svc.ConversationHumanDispatchService.HandoffByAIWithRequestID( replyCtx.Conversation.ID, replyCtx.AIAgent, - "knowledge evidence unavailable", + summary.HandoffReason, replyCtx.Message.RequestID, - ); err == nil { - return nil + ); err != nil { + return err } + return nil } if summary != nil && strings.TrimSpace(summary.ReplyText) != "" { _, err := s.commit.CommitAIReply(replyCommitInput{