fix(agent): 云端 Agent - 限制确认循环次数
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user