转人工流程问题修复

This commit is contained in:
mlogclub
2026-07-29 17:02:40 +08:00
parent c66ab1050e
commit fa123a9c41
5 changed files with 195 additions and 12 deletions
@@ -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
@@ -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,
@@ -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,
+28
View File
@@ -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 (
+4 -3
View File
@@ -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{