转人工流程问题修复
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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{
|
||||
|
||||
Reference in New Issue
Block a user