fix(agent): 云端 Agent - 限制确认循环次数

This commit is contained in:
ddcat666
2026-09-27 09:00:45 +08:00
parent fdade39c61
commit caaa178722
7 changed files with 146 additions and 48 deletions

View File

@@ -61,16 +61,18 @@ func cloudAgentStepLimit(req CloudAgentRequest) int {
}
type cloudAgentState struct {
Version int `json:"version"`
Request CloudAgentRequest `json:"request"`
ParentID string `json:"parentId"`
Fingerprint string `json:"fingerprint"`
CreativeAnchor cloudAgentCreativeAnchor `json:"creativeAnchor,omitempty"`
Plan []cloudAgentPlanItem `json:"plan,omitempty"`
ConfirmationRounds int `json:"confirmationRounds,omitempty"`
Skills []cloudAgentSkill `json:"skills,omitempty"`
Profile cloudAgentProfileSnapshot `json:"profile"`
Policy cloudAgentPolicySnapshot `json:"policy"`
Version int `json:"version"`
Request CloudAgentRequest `json:"request"`
ParentID string `json:"parentId"`
Fingerprint string `json:"fingerprint"`
CreativeAnchor cloudAgentCreativeAnchor `json:"creativeAnchor,omitempty"`
Plan []cloudAgentPlanItem `json:"plan,omitempty"`
ConfirmationRounds int `json:"confirmationRounds,omitempty"`
ConfirmationFingerprints []string `json:"confirmationFingerprints,omitempty"`
PendingConfirmationFingerprint string `json:"pendingConfirmationFingerprint,omitempty"`
Skills []cloudAgentSkill `json:"skills,omitempty"`
Profile cloudAgentProfileSnapshot `json:"profile"`
Policy cloudAgentPolicySnapshot `json:"policy"`
}
type CloudAgentRun struct {
@@ -285,7 +287,7 @@ func (s *Service) cloudAgentTask(userID, id string) (*model.Task, cloudAgentStat
if task.ID != cloudAgentID(userID, state.Request.IdempotencyKey) || task.ProjectID != state.Request.CanvasID {
return nil, input.Agent, kernel.NotFound("Agent 运行不存在")
}
input.Agent = cloudAgentState{Version: 1, Request: state.Request, ParentID: state.ParentID, Fingerprint: state.Fingerprint, CreativeAnchor: state.CreativeAnchor, ConfirmationRounds: state.ConfirmationRounds, Skills: state.Skills, Profile: state.Profile, Policy: state.Policy}
input.Agent = cloudAgentState{Version: 1, Request: state.Request, ParentID: state.ParentID, Fingerprint: state.Fingerprint, CreativeAnchor: state.CreativeAnchor, ConfirmationRounds: state.ConfirmationRounds, ConfirmationFingerprints: append([]string(nil), state.ConfirmationFingerprints...), PendingConfirmationFingerprint: state.PendingConfirmationFingerprint, Skills: state.Skills, Profile: state.Profile, Policy: state.Policy}
return task, input.Agent, nil
}
@@ -380,6 +382,7 @@ func (s *Service) CreateCloudAgentRun(userID string, req CloudAgentRequest, pare
var creativeAnchor cloudAgentCreativeAnchor
var inheritedPlan []cloudAgentPlanItem
inheritedConfirmationRounds := 0
var inheritedConfirmationFingerprints []string
if parentID != "" {
parent, _, parentErr := s.cloudAgentTask(userID, parentID)
if parentErr != nil {
@@ -415,7 +418,10 @@ func (s *Service) CreateCloudAgentRun(userID string, req CloudAgentRequest, pare
return nil, WrapAppError(409, "上一轮 Agent 历史记录不完整,无法继续对话;请新建对话", err)
}
inheritedPlan = parentState.Plan
inheritedConfirmationRounds = parentState.ConfirmationRounds
if parentRun.Status == "completed" && parentState.PendingConfirmationFingerprint != "" {
inheritedConfirmationRounds = parentState.ConfirmationRounds
inheritedConfirmationFingerprints = append([]string(nil), parentState.ConfirmationFingerprints...)
}
// 视觉事实跨轮继承:这一轮已经看过的画面与模型自己写下的观察随锚点带过来,
// 否则新轮会把看过的图重新标成"没有视觉识别证据"并再花一次视觉 token。
creativeAnchor = parentState.CreativeAnchor
@@ -472,7 +478,7 @@ func (s *Service) CreateCloudAgentRun(userID string, req CloudAgentRequest, pare
if err != nil {
return nil, err
}
state := cloudAgentState{Version: 1, Request: req, ParentID: parentID, Fingerprint: fingerprint, CreativeAnchor: creativeAnchor, Plan: inheritedPlan, ConfirmationRounds: inheritedConfirmationRounds, Skills: skillSnapshots, Profile: profile, Policy: policy}
state := cloudAgentState{Version: 1, Request: req, ParentID: parentID, Fingerprint: fingerprint, CreativeAnchor: creativeAnchor, Plan: inheritedPlan, ConfirmationRounds: inheritedConfirmationRounds, ConfirmationFingerprints: inheritedConfirmationFingerprints, Skills: skillSnapshots, Profile: profile, Policy: policy}
canonical := cloudAgentCanonicalFor(system, history, req.Prompt, req, len(profile.Layers) > 0)
// Keep the catalog out of TextHistory (which defines conversation turns),
// while exposing it as a fresh data message for this run immediately before

View File

@@ -1,9 +1,12 @@
package app
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"slices"
"sort"
"strings"
)
@@ -138,6 +141,34 @@ func cloudAgentApplyPlanUpdate(state *cloudAgentRuntime, call cloudAgentCall) (a
return map[string]any{"items": args.Items, "pendingTitles": cloudAgentPendingPlanItems(args.Items)}, nil
}
func cloudAgentConfirmationPointFingerprint(_ string, options []map[string]any) string {
// The choices are the durable identity of a decision point. Using the choice
// labels/details rather than question wording prevents harmless paraphrases
// from consuming a second confirmation round.
parts := make([]string, 0, len(options))
for _, option := range options {
label := strings.ToLower(strings.Join(strings.Fields(stringValue(option["label"])), " "))
detail := strings.ToLower(strings.Join(strings.Fields(stringValue(option["detail"])), " "))
parts = append(parts, label+"\x00"+detail)
}
sort.Strings(parts)
canonical := strings.Join(parts, "\x00")
sum := sha256.Sum256([]byte(canonical))
return hex.EncodeToString(sum[:])
}
func cloudAgentHasConfirmationFingerprint(state *cloudAgentRuntime, fingerprint string) bool {
if state == nil || fingerprint == "" {
return false
}
for _, existing := range state.ConfirmationFingerprints {
if existing == fingerprint {
return true
}
}
return false
}
func cloudAgentAskUser(call cloudAgentCall, states ...*cloudAgentRuntime) (any, error) {
var state *cloudAgentRuntime
if len(states) > 0 {
@@ -184,8 +215,11 @@ func cloudAgentAskUser(call cloudAgentCall, states ...*cloudAgentRuntime) (any,
}
maxRounds := cloudAgentMaxConfirmationRounds
currentRound := 1
fingerprint := cloudAgentConfirmationPointFingerprint(question, options)
duplicate := false
if state != nil {
currentRound = state.ConfirmationRounds + 1
duplicate = cloudAgentHasConfirmationFingerprint(state, fingerprint)
}
payload := map[string]any{
"phase": "question",
@@ -195,14 +229,23 @@ func cloudAgentAskUser(call cloudAgentCall, states ...*cloudAgentRuntime) (any,
"round": currentRound,
"maxRounds": maxRounds,
}
if currentRound > maxRounds {
if duplicate || currentRound > maxRounds {
payload["phase"] = "defaulted"
payload["defaulted"] = true
payload["text"] = "确认次数已达到上限。请使用安全默认方案继续,不要再次询问;最终回复中列出采用的默认假设。"
payload["reason"] = "repeated_confirmation_point"
if currentRound > maxRounds {
payload["reason"] = "confirmation_round_limit"
}
payload["text"] = "确认次数已达到上限或确认点已重复。请使用安全默认方案继续,不要再次询问;最终回复中列出采用的默认假设。"
if state != nil {
state.PendingConfirmationFingerprint = ""
}
return payload, nil
}
if state != nil {
state.ConfirmationRounds = currentRound
state.ConfirmationFingerprints = append(state.ConfirmationFingerprints, fingerprint)
state.PendingConfirmationFingerprint = fingerprint
}
return payload, nil
}

View File

@@ -53,6 +53,8 @@ func TestCloudAgentInheritedPlanReachesFirstModelRequest(t *testing.T) {
run, state := agentInterjectionState(t, s, root.ID)
state.Plan = []cloudAgentPlanItem{{ID: "1", Title: "生成镜头2视频", Status: "doing"}}
state.ConfirmationRounds = 1
state.ConfirmationFingerprints = []string{strings.Repeat("a", 64)}
state.PendingConfirmationFingerprint = state.ConfirmationFingerprints[0]
if err := cloudAgentSave(run, &state); err != nil {
t.Fatal(err)
}
@@ -127,22 +129,39 @@ func TestCloudAgentAskUserTracksConfirmationRoundsAndDefaults(t *testing.T) {
call.Function.Name = "ask_user"
call.Function.Arguments = `{"question":"选哪个方向?","options":[{"label":"A"},{"label":"B"}]}`
state := &cloudAgentRuntime{}
for round := 1; round <= cloudAgentMaxConfirmationRounds; round++ {
result, err := cloudAgentAskUser(call, state)
if err != nil {
t.Fatal(err)
}
body := result.(map[string]any)
if body["phase"] != "question" || body["round"] != round || body["maxRounds"] != cloudAgentMaxConfirmationRounds {
t.Fatalf("round %d payload = %+v", round, body)
}
}
result, err := cloudAgentAskUser(call, state)
if err != nil {
t.Fatal(err)
}
body := result.(map[string]any)
if body["phase"] != "defaulted" || body["defaulted"] != true || state.ConfirmationRounds != cloudAgentMaxConfirmationRounds {
if body["phase"] != "question" || body["round"] != 1 || state.ConfirmationRounds != 1 || state.PendingConfirmationFingerprint == "" {
t.Fatalf("first ask_user payload/state = %+v / %+v", body, state)
}
call.Function.Arguments = `{"question":"哪一个方向更合适?","options":[{"label":"B"},{"label":"A"}]}`
result, err = cloudAgentAskUser(call, state)
if err != nil {
t.Fatal(err)
}
body = result.(map[string]any)
if body["phase"] != "defaulted" || body["defaulted"] != true || body["reason"] != "repeated_confirmation_point" || state.ConfirmationRounds != 1 {
t.Fatalf("repeated ask_user payload/state = %+v / %+v", body, state)
}
call.Function.Arguments = `{"question":"换一个说法","options":[{"label":"A"},{"label":"C"}]}`
result, err = cloudAgentAskUser(call, state)
if err != nil {
t.Fatal(err)
}
body = result.(map[string]any)
if body["phase"] != "question" || body["round"] != 2 || state.ConfirmationRounds != cloudAgentMaxConfirmationRounds {
t.Fatalf("second confirmation payload/state = %+v / %+v", body, state)
}
call.Function.Arguments = `{"question":"最后一个方向","options":[{"label":"D"},{"label":"E"}]}`
result, err = cloudAgentAskUser(call, state)
if err != nil {
t.Fatal(err)
}
body = result.(map[string]any)
if body["phase"] != "defaulted" || body["defaulted"] != true || body["reason"] != "confirmation_round_limit" || state.ConfirmationRounds != cloudAgentMaxConfirmationRounds {
t.Fatalf("exhausted ask_user payload/state = %+v / %+v", body, state)
}
}

View File

@@ -113,21 +113,23 @@ type cloudAgentRuntime struct {
// AutoPreparedMedia is the durable admission checkpoint for permissionMode=auto.
// It prevents a worker restart between dry admission and billed submission from
// re-running admission or creating the draft node a second time.
AutoPreparedMedia *cloudAgentPreparedMedia `json:"autoPreparedMedia,omitempty"`
AutoPreparedCallHash string `json:"autoPreparedCallHash,omitempty"`
Decisions map[string]string `json:"decisions"`
DecisionSettings map[string]string `json:"decisionSettings,omitempty"`
DecisionPreparedHashes map[string]string `json:"decisionPreparedHashes,omitempty"`
ActionNudged bool `json:"actionNudged,omitempty"`
EmptyOutputNudged int `json:"emptyOutputNudged,omitempty"`
StepSnapshotHash string `json:"stepSnapshotHash,omitempty"`
StoryboardTaskID string `json:"storyboardTaskId,omitempty"`
Plan []cloudAgentPlanItem `json:"plan,omitempty"`
ConfirmationRounds int `json:"confirmationRounds,omitempty"`
PendingInterjections []cloudAgentInterjection `json:"pendingInterjections,omitempty"`
TransientReferences map[string]cloudAgentTransientReference `json:"transientReferences,omitempty"`
InterjectionIDs []string `json:"interjectionIds,omitempty"`
Events []CloudAgentEvent `json:"events"`
AutoPreparedMedia *cloudAgentPreparedMedia `json:"autoPreparedMedia,omitempty"`
AutoPreparedCallHash string `json:"autoPreparedCallHash,omitempty"`
Decisions map[string]string `json:"decisions"`
DecisionSettings map[string]string `json:"decisionSettings,omitempty"`
DecisionPreparedHashes map[string]string `json:"decisionPreparedHashes,omitempty"`
ActionNudged bool `json:"actionNudged,omitempty"`
EmptyOutputNudged int `json:"emptyOutputNudged,omitempty"`
StepSnapshotHash string `json:"stepSnapshotHash,omitempty"`
StoryboardTaskID string `json:"storyboardTaskId,omitempty"`
Plan []cloudAgentPlanItem `json:"plan,omitempty"`
ConfirmationRounds int `json:"confirmationRounds,omitempty"`
ConfirmationFingerprints []string `json:"confirmationFingerprints,omitempty"`
PendingConfirmationFingerprint string `json:"pendingConfirmationFingerprint,omitempty"`
PendingInterjections []cloudAgentInterjection `json:"pendingInterjections,omitempty"`
TransientReferences map[string]cloudAgentTransientReference `json:"transientReferences,omitempty"`
InterjectionIDs []string `json:"interjectionIds,omitempty"`
Events []CloudAgentEvent `json:"events"`
// EmptyOutputEscalated 记录"空输出已经升级重试过几次"(关思考 + 放大输出预算)。
EmptyOutputEscalated int `json:"emptyOutputEscalated,omitempty"`
// StepTimeoutEscalated 记录"单步墙钟到点后已经关思考重试过几次"。
@@ -219,7 +221,7 @@ func (s *Service) ensureCloudAgentExecution(task *model.Task, initial cloudAgent
if err != nil {
return err
}
state := cloudAgentRuntime{Request: initial.Request, Policy: initial.Policy, ParentID: initial.ParentID, Fingerprint: initial.Fingerprint, CreativeAnchor: initial.CreativeAnchor, TextHistory: input.TextHistory, Skills: initial.Skills, Profile: initial.Profile, Canonical: canonical, ActiveTaskID: task.ID, TaskIDs: []string{task.ID}, Step: 1, Decisions: map[string]string{}, Plan: initial.Plan, ConfirmationRounds: initial.ConfirmationRounds, Events: []CloudAgentEvent{}, StepLimits: limits}
state := cloudAgentRuntime{Request: initial.Request, Policy: initial.Policy, ParentID: initial.ParentID, Fingerprint: initial.Fingerprint, CreativeAnchor: initial.CreativeAnchor, TextHistory: input.TextHistory, Skills: initial.Skills, Profile: initial.Profile, Canonical: canonical, ActiveTaskID: task.ID, TaskIDs: []string{task.ID}, Step: 1, Decisions: map[string]string{}, Plan: initial.Plan, ConfirmationRounds: initial.ConfirmationRounds, ConfirmationFingerprints: append([]string(nil), initial.ConfirmationFingerprints...), PendingConfirmationFingerprint: "", Events: []CloudAgentEvent{}, StepLimits: limits}
if len(initial.Skills) > 0 {
// skillIds makes the enablement auditable: usage telemetry can attribute a
// run to the skills it actually loaded instead of only counting the total.
@@ -387,6 +389,27 @@ func validateCloudAgentRuntime(run *model.CloudAgentExecution, state *cloudAgent
if state.ConfirmationRounds < 0 || state.ConfirmationRounds > cloudAgentMaxConfirmationRounds {
return errors.New("Agent runtime confirmation round is invalid")
}
if len(state.ConfirmationFingerprints) > cloudAgentMaxConfirmationRounds {
return errors.New("Agent runtime confirmation fingerprint history is invalid")
}
seenConfirmationPoints := make(map[string]struct{}, len(state.ConfirmationFingerprints))
for _, fingerprint := range state.ConfirmationFingerprints {
if !cloudAgentSHA256(fingerprint) {
return errors.New("Agent runtime confirmation fingerprint is invalid")
}
if _, exists := seenConfirmationPoints[fingerprint]; exists {
return errors.New("Agent runtime confirmation fingerprint history contains duplicates")
}
seenConfirmationPoints[fingerprint] = struct{}{}
}
if state.PendingConfirmationFingerprint != "" {
if !cloudAgentSHA256(state.PendingConfirmationFingerprint) {
return errors.New("Agent runtime pending confirmation fingerprint is invalid")
}
if _, exists := seenConfirmationPoints[state.PendingConfirmationFingerprint]; !exists {
return errors.New("Agent runtime pending confirmation fingerprint is not recorded")
}
}
if state.ImageInspectCalls < 0 {
return errors.New("Agent runtime image inspection budget is invalid")
}

View File

@@ -201,6 +201,9 @@ func TestCloudAgentTerminalRunWithOlderCapabilityCanContinueOnCurrentContract(t
if childState.ParentID != parent.ID || len(childState.TextHistory) != 2 || childState.TextHistory[1].Content != "旧合同下的可信回复" {
t.Fatalf("historical conversation context was not preserved: %+v", childState)
}
if childState.ConfirmationRounds != 0 || len(childState.ConfirmationFingerprints) != 0 {
t.Fatalf("normal completed parent incorrectly inherited confirmation budget: rounds=%d fingerprints=%v", childState.ConfirmationRounds, childState.ConfirmationFingerprints)
}
var storedParent model.CloudAgentExecution
if err = db.First(&storedParent, "id = ?", parent.ID).Error; err != nil {
t.Fatal(err)