From 310cdfbcb700f9f76ed833951ea93a7db34e4eb5 Mon Sep 17 00:00:00 2001 From: Losita <2810873701@qq.com> Date: Tue, 7 Apr 2026 23:59:50 +0800 Subject: [PATCH] =?UTF-8?q?Version:=200.9.6.dev.260407=20=E5=90=8E?= =?UTF-8?q?=E7=AB=AF=EF=BC=9A=201.execute=20=E6=AD=A3=E5=BC=8F=E7=BB=88?= =?UTF-8?q?=E6=AD=A2=E5=8D=8F=E8=AE=AE=E8=A1=A5=E9=BD=90=EF=BC=88abort=20/?= =?UTF-8?q?=20exhausted=20/=20completed=20=E7=BB=9F=E4=B8=80=E5=BB=BA?= =?UTF-8?q?=E6=A8=A1=EF=BC=89=20=20=20-=20=E6=9B=B4=E6=96=B0model/common?= =?UTF-8?q?=5Fstate.go=EF=BC=9A=E6=96=B0=E5=A2=9E=20FlowTerminalStatus=20/?= =?UTF-8?q?=20FlowTerminalOutcome=EF=BC=9B=E8=A1=A5=E9=BD=90=20Abort/Exhau?= =?UTF-8?q?st/ClearTerminalOutcome/IsCompleted=20=E7=AD=89=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=E7=BB=88=E6=AD=A2=E8=AF=AD=E4=B9=89=20=20=20-=20?= =?UTF-8?q?=E6=9B=B4=E6=96=B0model/execute=5Fcontract.go=EF=BC=9A=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=20ExecuteActionAbort=20=E4=B8=8E=20AbortIntent?= =?UTF-8?q?=EF=BC=9B=E8=A1=A5=E9=BD=90=20action=20=E6=A0=A1=E9=AA=8C?= =?UTF-8?q?=E4=BA=92=E6=96=A5=E8=A7=84=E5=88=99=20=20=20-=20=E6=9B=B4?= =?UTF-8?q?=E6=96=B0prompt/execute.go=EF=BC=9APlan/ReAct=20=E4=B8=A4?= =?UTF-8?q?=E5=A5=97=20execute=20contract=20=E5=8D=87=E7=BA=A7=E5=88=B0=20?= =?UTF-8?q?V2=EF=BC=8C=E8=A1=A5=E5=85=85=20abort=20=E5=8D=8F=E8=AE=AE?= =?UTF-8?q?=E4=B8=8E=20JSON=20=E7=A4=BA=E4=BE=8B=202.graph=20=E8=B7=AF?= =?UTF-8?q?=E7=94=B1=E4=B8=8E=20deliver=20=E6=94=B6=E5=8F=A3=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=E5=9B=B4=E7=BB=95=20terminal=20outcome=20=20=20-=20?= =?UTF-8?q?=E6=9B=B4=E6=96=B0graph/common=5Fgraph.go=EF=BC=9ARoughBuild=20?= =?UTF-8?q?=E6=94=B9=20branch=EF=BC=9B=E7=B2=97=E6=8E=92=E5=BC=82=E5=B8=B8?= =?UTF-8?q?=E5=8F=AF=E7=9B=B4=E6=8E=A5=20Deliver=EF=BC=9BExecute=20?= =?UTF-8?q?=E8=B7=AF=E7=94=B1=E4=B8=8D=E5=86=8D=E6=8C=89=E2=80=9C=E6=9C=80?= =?UTF-8?q?=E5=90=8E=E4=B8=80=E8=BD=AE=E2=80=9D=E6=8F=90=E5=89=8D=E8=AF=AF?= =?UTF-8?q?=E6=94=B6=E5=8F=A3=20=20=20-=20=E6=9B=B4=E6=96=B0node/execute.g?= =?UTF-8?q?o=EF=BC=9A=E8=BD=AE=E6=AC=A1=E8=80=97=E5=B0=BD=E6=94=B9?= =?UTF-8?q?=E5=86=99=E4=B8=BA=20Exhaust=EF=BC=9B=E6=8E=A5=E5=85=A5=20handl?= =?UTF-8?q?eExecuteActionAbort=EF=BC=9Babort=20=E4=B8=8D=E5=9C=A8=20execut?= =?UTF-8?q?e=20=E7=9B=B4=E6=8E=A5=E5=AF=B9=E7=94=A8=E6=88=B7=E6=94=B6?= =?UTF-8?q?=E5=8F=A3=20=20=20-=20=E6=9B=B4=E6=96=B0node/deliver.go?= =?UTF-8?q?=EF=BC=9Adeliver=20summary=20=E4=BC=98=E5=85=88=E6=8C=89=20abor?= =?UTF-8?q?t/exhausted=20=E6=94=B6=E5=8F=A3=EF=BC=9B=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E6=97=A0=E8=84=91=20Done=EF=BC=9B=E6=9C=80=E7=BB=88=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E6=96=87=E6=A1=88=E6=94=B9=E4=B8=BA=E2=80=9C=E6=9C=AC?= =?UTF-8?q?=E8=BD=AE=E6=B5=81=E7=A8=8B=E5=B7=B2=E7=BB=93=E6=9D=9F=E2=80=9D?= =?UTF-8?q?=20=20=20-=20=E6=9B=B4=E6=96=B0node/agent=5Fnodes.go=EF=BC=9A?= =?UTF-8?q?=E4=BB=85=20completed=20=E8=B7=AF=E5=BE=84=E5=86=99=20schedule?= =?UTF-8?q?=20preview=EF=BC=8Caborted/exhausted=20=E8=B7=B3=E8=BF=87=203.?= =?UTF-8?q?=E6=8F=90=E7=A4=BA=E4=B8=8E=E7=8A=B6=E6=80=81=E6=91=98=E8=A6=81?= =?UTF-8?q?=E5=90=8C=E6=AD=A5=E7=BB=88=E6=AD=A2=E8=AF=AD=E4=B9=89=20=20=20?= =?UTF-8?q?-=20=E6=9B=B4=E6=96=B0prompt/base.go=EF=BC=9Astate=20summary=20?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=20terminal=20outcome=20=E5=B1=95=E7=A4=BA=20?= =?UTF-8?q?=E5=89=8D=E7=AB=AF=EF=BC=9A=E6=97=A0=20=E4=BB=93=E5=BA=93?= =?UTF-8?q?=EF=BC=9A=E6=97=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/newAgent/graph/common_graph.go | 41 ++++++- backend/newAgent/model/common_state.go | 129 +++++++++++++++++++++ backend/newAgent/model/execute_contract.go | 61 ++++++++++ backend/newAgent/node/agent_nodes.go | 10 +- backend/newAgent/node/deliver.go | 49 +++++++- backend/newAgent/node/execute.go | 88 ++++++++++---- backend/newAgent/prompt/base.go | 12 ++ backend/newAgent/prompt/execute.go | 129 ++++++++++++++++++++- 8 files changed, 483 insertions(+), 36 deletions(-) diff --git a/backend/newAgent/graph/common_graph.go b/backend/newAgent/graph/common_graph.go index cc20088..8dd9010 100644 --- a/backend/newAgent/graph/common_graph.go +++ b/backend/newAgent/graph/common_graph.go @@ -102,8 +102,17 @@ func RunAgentGraph(ctx context.Context, input newagentmodel.AgentGraphRunInput) )); err != nil { return nil, err } - // RoughBuild -> Execute:粗排完成后直接进入执行阶段微调。 - if err := g.AddEdge(NodeRoughBuild, NodeExecute); err != nil { + // RoughBuild -> Execute / Deliver: + // 1. 正常粗排完成后进入 execute 微调; + // 2. 若粗排阶段已写入正式终止结果(如粗排异常 abort),则直接进入 deliver 收口。 + if err := g.AddBranch(NodeRoughBuild, compose.NewGraphBranch( + branchAfterRoughBuild, + map[string]bool{ + NodeExecute: true, + NodeDeliver: true, + NodeInterrupt: true, + }, + )); err != nil { return nil, err } // Execute -> Execute(继续 ReAct) / Confirm(写操作待确认) / Deliver(完成) / Interrupt(需要追问用户) @@ -224,11 +233,31 @@ func branchAfterConfirm(_ context.Context, st *newagentmodel.AgentGraphState) (s // confirm 节点产出确认请求后,当前连接必须进入 interrupt 收口。 // 真正的用户确认结果应由外部回调写回状态,再重新进入 graph。 return NodeInterrupt, nil + case newagentmodel.PhaseDone: + return NodeDeliver, nil default: return NodePlan, nil } } +func branchAfterRoughBuild(_ context.Context, st *newagentmodel.AgentGraphState) (string, error) { + if st == nil { + return NodeExecute, nil + } + if nextNode, interrupted := branchIfInterrupted(st); interrupted { + return nextNode, nil + } + + flowState := st.EnsureFlowState() + if flowState == nil { + return NodeExecute, nil + } + if flowState.Phase == newagentmodel.PhaseDone { + return NodeDeliver, nil + } + return NodeExecute, nil +} + func branchAfterExecute(_ context.Context, st *newagentmodel.AgentGraphState) (string, error) { if st == nil { return NodeExecute, nil @@ -244,7 +273,13 @@ func branchAfterExecute(_ context.Context, st *newagentmodel.AgentGraphState) (s if flowState.Phase == newagentmodel.PhaseWaitingConfirm { return NodeConfirm, nil } - if flowState.Phase == newagentmodel.PhaseDone || flowState.Exhausted() { + // 1. 这里只围绕“是否已经写入正式终止结果”做路由,避免把“刚好用完最后一轮预算” + // 误判成已经 exhausted 收口; + // 2. 真正的 exhausted 语义应由下一次 Execute 入口在 NextRound() 失败时统一写入, + // 这样 rough_build / execute / deliver 才都围绕同一份 terminal outcome 工作; + // 3. 若此处直接按 RoundUsed>=MaxRounds 跳 Deliver,会绕过 Execute 内的 Exhaust 写入, + // 导致 deliver 收口和后续预览落盘语义不一致。 + if flowState.Phase == newagentmodel.PhaseDone { return NodeDeliver, nil } return NodeExecute, nil diff --git a/backend/newAgent/model/common_state.go b/backend/newAgent/model/common_state.go index c6fd48b..f20bce7 100644 --- a/backend/newAgent/model/common_state.go +++ b/backend/newAgent/model/common_state.go @@ -1,6 +1,8 @@ package model import ( + "strings" + newagenttools "github.com/LoveLosita/smartflow/backend/newAgent/tools" ) @@ -14,6 +16,47 @@ const ( PhaseDone Phase = "done" ) +// FlowTerminalStatus 表示本轮流程最终是如何结束的。 +// +// 说明: +// 1. completed 表示任务按预期完成,允许走正常交付与预览落盘; +// 2. aborted 表示业务语义上的主动终止,例如粗排异常、执行期明确中止; +// 3. exhausted 表示安全边界触发的被动停止,例如执行轮次耗尽。 +type FlowTerminalStatus string + +const ( + FlowTerminalStatusCompleted FlowTerminalStatus = "completed" + FlowTerminalStatusAborted FlowTerminalStatus = "aborted" + FlowTerminalStatusExhausted FlowTerminalStatus = "exhausted" +) + +// FlowTerminalOutcome 保存“流程为什么结束”的最终结果快照。 +// +// 职责边界: +// 1. Stage 说明终止发生在哪个阶段,便于 graph/deliver/debug 统一收口; +// 2. Code 作为稳定机器码,便于后续前端或埋点按类型识别; +// 3. UserMessage 是最终给用户看的收口文案; +// 4. InternalReason 只用于日志与排查,不直接暴露给用户。 +type FlowTerminalOutcome struct { + Status FlowTerminalStatus `json:"status"` + Stage string `json:"stage,omitempty"` + Code string `json:"code,omitempty"` + UserMessage string `json:"user_message,omitempty"` + InternalReason string `json:"internal_reason,omitempty"` +} + +// Normalize 统一清洗终止结果里的字符串字段。 +func (o *FlowTerminalOutcome) Normalize() { + if o == nil { + return + } + o.Status = FlowTerminalStatus(strings.TrimSpace(string(o.Status))) + o.Stage = strings.TrimSpace(o.Stage) + o.Code = strings.TrimSpace(o.Code) + o.UserMessage = strings.TrimSpace(o.UserMessage) + o.InternalReason = strings.TrimSpace(o.InternalReason) +} + const DefaultMaxRounds = 30 // CommonState 承载可持久化的主流程状态。 @@ -54,6 +97,10 @@ type CommonState struct { // NeedsRoughBuild 由 Plan 节点在 plan_done 时写入,标记 Confirm 后是否需要走粗排节点。 // 粗排节点执行完毕后会将此字段重置为 false。 NeedsRoughBuild bool `json:"needs_rough_build,omitempty"` + + // TerminalOutcome 保存“本轮流程最终如何结束”的统一收口结果。 + // 第二轮开始,rough_build / execute / deliver 都应围绕这份快照判断收口语义。 + TerminalOutcome *FlowTerminalOutcome `json:"terminal_outcome,omitempty"` } func NewCommonState(traceID string, userID int, conversationID string) *CommonState { @@ -87,11 +134,13 @@ func (s *CommonState) FinishPlan(steps []PlanStep) { s.PlanSteps = steps s.CurrentStep = 0 s.Phase = PhaseWaitingConfirm + s.ClearTerminalOutcome() } // ConfirmPlan 表示用户已确认计划,流程进入执行阶段。 func (s *CommonState) ConfirmPlan() { s.Phase = PhaseExecuting + s.ClearTerminalOutcome() } // StartDirectExecute 进入无 plan 的直接执行(ReAct)模式。 @@ -102,6 +151,7 @@ func (s *CommonState) StartDirectExecute() { s.PlanSteps = nil s.CurrentStep = 0 s.Phase = PhaseExecuting + s.ClearTerminalOutcome() } // RejectPlan 表示用户拒绝当前计划,清空计划并回退到 planning。 @@ -109,6 +159,7 @@ func (s *CommonState) RejectPlan() { s.PlanSteps = nil s.CurrentStep = 0 s.Phase = PhasePlanning + s.ClearTerminalOutcome() } // AdvanceStep 推进到下一个计划步骤,并返回是否仍有剩余步骤。 @@ -118,8 +169,86 @@ func (s *CommonState) AdvanceStep() bool { } // Done 标记整个任务流程已经结束。 +// +// 说明: +// 1. 若此前已经写入 aborted / exhausted 等终止结果,这里只负责兜底维持 PhaseDone,不覆盖已有语义; +// 2. 只有在尚未写入任何终止结果时,才默认补成 completed。 func (s *CommonState) Done() { s.Phase = PhaseDone + if s.TerminalOutcome != nil { + s.TerminalOutcome.Normalize() + return + } + s.TerminalOutcome = &FlowTerminalOutcome{ + Status: FlowTerminalStatusCompleted, + } +} + +// Abort 将当前流程标记为“业务语义上的主动终止”。 +// +// 步骤说明: +// 1. 统一写入 PhaseDone,保证 graph 后续直接进入 deliver 收口; +// 2. UserMessage 作为最终可见文案,必须尽量完整,避免 deliver 再二次猜测; +// 3. InternalReason 只用于排查,允许比用户文案更技术化。 +func (s *CommonState) Abort(stage, code, userMessage, internalReason string) { + s.Phase = PhaseDone + s.TerminalOutcome = &FlowTerminalOutcome{ + Status: FlowTerminalStatusAborted, + Stage: stage, + Code: code, + UserMessage: userMessage, + InternalReason: internalReason, + } + s.TerminalOutcome.Normalize() +} + +// Exhaust 将当前流程标记为“安全边界触发的被动停止”。 +func (s *CommonState) Exhaust(stage, userMessage, internalReason string) { + s.Phase = PhaseDone + s.TerminalOutcome = &FlowTerminalOutcome{ + Status: FlowTerminalStatusExhausted, + Stage: stage, + Code: "round_exhausted", + UserMessage: userMessage, + InternalReason: internalReason, + } + s.TerminalOutcome.Normalize() +} + +// ClearTerminalOutcome 清空上一轮遗留的终止结果。 +func (s *CommonState) ClearTerminalOutcome() { + if s == nil { + return + } + s.TerminalOutcome = nil +} + +// HasTerminalOutcome 判断当前是否已经写入正式终止结果。 +func (s *CommonState) HasTerminalOutcome() bool { + return s != nil && s.TerminalOutcome != nil +} + +// TerminalStatus 返回当前终止结果的状态枚举。 +func (s *CommonState) TerminalStatus() FlowTerminalStatus { + if s == nil || s.TerminalOutcome == nil { + return "" + } + return s.TerminalOutcome.Status +} + +// IsCompleted 判断当前是否属于“正常完成”。 +func (s *CommonState) IsCompleted() bool { + return s.TerminalStatus() == FlowTerminalStatusCompleted +} + +// IsAborted 判断当前是否属于“主动中止”。 +func (s *CommonState) IsAborted() bool { + return s.TerminalStatus() == FlowTerminalStatusAborted +} + +// IsExhaustedTerminal 判断当前是否属于“轮次耗尽收口”。 +func (s *CommonState) IsExhaustedTerminal() bool { + return s.TerminalStatus() == FlowTerminalStatusExhausted } // HasPlan 判断当前 state 是否已经持有一份完整计划。 diff --git a/backend/newAgent/model/execute_contract.go b/backend/newAgent/model/execute_contract.go index 23b6a4f..5fd5305 100644 --- a/backend/newAgent/model/execute_contract.go +++ b/backend/newAgent/model/execute_contract.go @@ -28,6 +28,9 @@ const ( // ExecuteActionDone 表示整个任务已完成,可以进入最终交付。 ExecuteActionDone ExecuteAction = "done" + + // ExecuteActionAbort 表示本轮流程应立即终止,并进入 deliver 做正式收口。 + ExecuteActionAbort ExecuteAction = "abort" ) // ExecuteDecision 是 execute prompt 单轮产出的统一决策结构。 @@ -43,6 +46,7 @@ type ExecuteDecision struct { Reason string `json:"reason,omitempty"` GoalCheck string `json:"goal_check,omitempty"` ToolCall *ToolCallIntent `json:"tool_call,omitempty"` + Abort *AbortIntent `json:"abort,omitempty"` } // Normalize 统一清洗 execute 决策中的字符串字段。 @@ -57,6 +61,9 @@ func (d *ExecuteDecision) Normalize() { if d.ToolCall != nil { d.ToolCall.Normalize() } + if d.Abort != nil { + d.Abort.Normalize() + } } // Validate 校验 execute 决策的最小合法性。 @@ -77,6 +84,9 @@ func (d *ExecuteDecision) Validate() error { switch d.Action { case ExecuteActionContinue: + if d.Abort != nil { + return fmt.Errorf("continue 动作不应携带 abort") + } if d.ToolCall != nil { return d.ToolCall.Validate() } @@ -85,22 +95,73 @@ func (d *ExecuteDecision) Validate() error { if d.ToolCall != nil { return fmt.Errorf("ask_user 动作不应携带 tool_call") } + if d.Abort != nil { + return fmt.Errorf("ask_user 动作不应携带 abort") + } return nil case ExecuteActionConfirm: if d.ToolCall == nil { return fmt.Errorf("confirm 动作必须携带待确认的 tool_call") } + if d.Abort != nil { + return fmt.Errorf("confirm 动作不应同时携带 abort") + } return d.ToolCall.Validate() case ExecuteActionNextPlan, ExecuteActionDone: if d.ToolCall != nil { return fmt.Errorf("%s 动作不应携带 tool_call", d.Action) } + if d.Abort != nil { + return fmt.Errorf("%s 动作不应携带 abort", d.Action) + } return nil + case ExecuteActionAbort: + if d.ToolCall != nil { + return fmt.Errorf("abort 动作不应携带 tool_call") + } + if d.Abort == nil { + return fmt.Errorf("abort 动作必须携带 abort 字段") + } + return d.Abort.Validate() default: return fmt.Errorf("未知 execute action: %s", d.Action) } } +// AbortIntent 表示 execute 阶段声明的正式终止意图。 +// +// 说明: +// 1. code 是稳定机器码,便于后续前端/埋点识别终止类型; +// 2. user_message 是最终给用户看的收口文案; +// 3. internal_reason 只用于日志排查,允许更技术化。 +type AbortIntent struct { + Code string `json:"code,omitempty"` + UserMessage string `json:"user_message"` + InternalReason string `json:"internal_reason,omitempty"` +} + +// Normalize 清洗终止意图中的稳定字段。 +func (a *AbortIntent) Normalize() { + if a == nil { + return + } + a.Code = strings.TrimSpace(a.Code) + a.UserMessage = strings.TrimSpace(a.UserMessage) + a.InternalReason = strings.TrimSpace(a.InternalReason) +} + +// Validate 校验终止意图的最小可用性。 +func (a *AbortIntent) Validate() error { + if a == nil { + return fmt.Errorf("abort 不能为空") + } + a.Normalize() + if a.UserMessage == "" { + return fmt.Errorf("abort.user_message 不能为空") + } + return nil +} + // ToolCallIntent 表示 execute 阶段申报的工具调用意图。 // // 设计目的: diff --git a/backend/newAgent/node/agent_nodes.go b/backend/newAgent/node/agent_nodes.go index b4d1111..0812dc5 100644 --- a/backend/newAgent/node/agent_nodes.go +++ b/backend/newAgent/node/agent_nodes.go @@ -257,9 +257,13 @@ func (n *AgentNodes) Deliver(ctx context.Context, st *newagentmodel.AgentGraphSt // 中断(confirm/ask_user)路径不写,避免把中间态暴露给前端。 if st.Deps.WriteSchedulePreview != nil && st.ScheduleState != nil { flowState := st.EnsureFlowState() - if err := st.Deps.WriteSchedulePreview(ctx, st.ScheduleState, flowState.UserID, flowState.ConversationID, flowState.TaskClassIDs); err != nil { - // 写缓存失败不阻断主流程,降级为仅 log。 - log.Printf("[WARN] deliver: 写入排程预览缓存失败 chat=%s: %v", flowState.ConversationID, err) + if flowState != nil && flowState.IsCompleted() { + if err := st.Deps.WriteSchedulePreview(ctx, st.ScheduleState, flowState.UserID, flowState.ConversationID, flowState.TaskClassIDs); err != nil { + // 写缓存失败不阻断主流程,降级为仅 log。 + log.Printf("[WARN] deliver: 写入排程预览缓存失败 chat=%s: %v", flowState.ConversationID, err) + } + } else if flowState != nil { + log.Printf("[DEBUG] deliver: skip schedule preview chat=%s terminal_status=%s", flowState.ConversationID, flowState.TerminalStatus()) } } diff --git a/backend/newAgent/node/deliver.go b/backend/newAgent/node/deliver.go index 8d91455..8cf03fd 100644 --- a/backend/newAgent/node/deliver.go +++ b/backend/newAgent/node/deliver.go @@ -85,12 +85,10 @@ func RunDeliverNode(ctx context.Context, input DeliverNodeInput) error { deliverStatusBlockID, deliverStageName, "done", - "任务已完成。", + "本轮流程已结束。", true, ) - // 5. 标记流程结束。 - flowState.Done() return nil } @@ -101,6 +99,15 @@ func generateDeliverSummary( flowState *newagentmodel.CommonState, conversationContext *newagentmodel.ConversationContext, ) string { + if flowState != nil { + switch { + case flowState.IsAborted(): + return normalizeSpeak(buildAbortSummary(flowState)) + case flowState.IsExhaustedTerminal(): + return normalizeSpeak(buildExhaustedSummary(flowState)) + } + } + if client == nil { return buildMechanicalSummary(flowState) } @@ -125,6 +132,38 @@ func generateDeliverSummary( return normalizeSpeak(result.Text) } +// buildAbortSummary 生成“流程已终止”的统一交付文案。 +// +// 说明: +// 1. 第二轮开始,abort 的用户可见文案由终止方提前写入 CommonState; +// 2. deliver 不再重新猜测或改写业务异常,只做最终收口; +// 3. 若历史快照缺失 user_message,则回退到一份通用说明,避免前端收到空白结果。 +func buildAbortSummary(state *newagentmodel.CommonState) string { + if state == nil || state.TerminalOutcome == nil { + return "本轮流程已终止。" + } + if msg := strings.TrimSpace(state.TerminalOutcome.UserMessage); msg != "" { + return msg + } + return "本轮流程已终止,请根据当前提示检查后再继续。" +} + +// buildExhaustedSummary 生成“轮次耗尽”的统一收口文案。 +func buildExhaustedSummary(state *newagentmodel.CommonState) string { + if state == nil { + return "本轮执行已达到安全轮次上限,当前先停止继续操作。" + } + + prefix := "本轮执行已达到安全轮次上限,当前先停止继续操作。" + if state.TerminalOutcome != nil && strings.TrimSpace(state.TerminalOutcome.UserMessage) != "" { + prefix = strings.TrimSpace(state.TerminalOutcome.UserMessage) + } + if !state.HasPlan() { + return prefix + } + return prefix + "\n\n" + strings.TrimSpace(buildMechanicalSummary(state)) +} + // buildMechanicalSummary 在 LLM 不可用时,机械拼接一份最小可用总结。 func buildMechanicalSummary(state *newagentmodel.CommonState) string { if state == nil { @@ -138,7 +177,7 @@ func buildMechanicalSummary(state *newagentmodel.CommonState) string { return "任务流程已结束。" } - if state.Exhausted() { + if state.IsExhaustedTerminal() { sb.WriteString(fmt.Sprintf("任务因执行轮次耗尽提前结束,已完成 %d/%d 步。\n", current, total)) } else { sb.WriteString("所有计划步骤已执行完毕。\n") @@ -153,7 +192,7 @@ func buildMechanicalSummary(state *newagentmodel.CommonState) string { sb.WriteString(fmt.Sprintf("%s %s\n", marker, strings.TrimSpace(step.Content))) } - if state.Exhausted() && current < total { + if state.IsExhaustedTerminal() && current < total { sb.WriteString("\n如需继续完成剩余步骤,可以告诉我继续。") } diff --git a/backend/newAgent/node/execute.go b/backend/newAgent/node/execute.go index 9aca7c9..d86a75f 100644 --- a/backend/newAgent/node/execute.go +++ b/backend/newAgent/node/execute.go @@ -131,8 +131,14 @@ func RunExecuteNode(ctx context.Context, input ExecuteNodeInput) error { // 4. 消耗一轮预算,并检查是否耗尽。 if !flowState.NextRound() { - // 轮次耗尽,强制进入交付阶段。 - flowState.Done() + // 1. 轮次耗尽属于安全边界触发的被动停止,不应伪装成“正常完成”。 + // 2. 这里统一写入 exhausted 终止结果,让 deliver 阶段按未完成收口。 + // 3. 后续 graph 只需围绕 CommonState 的终止结果路由,无需再猜测原因。 + flowState.Exhaust( + executeStageName, + "本轮执行已达到安全轮次上限,当前先停止继续操作。如需继续,我可以在你确认后接着处理剩余步骤。", + "execute rounds exhausted before task completion", + ) return nil } @@ -232,7 +238,7 @@ func RunExecuteNode(ctx context.Context, input ExecuteNodeInput) error { conversationContext, rawText, fmt.Sprintf("你的执行决策不合法:%s", err.Error()), - "合法的 action 包括:continue(继续当前步骤)、ask_user(追问用户)、confirm(写操作确认)、next_plan(推进到下一步)、done(任务完成)。", + "合法的 action 包括:continue(继续当前步骤)、ask_user(追问用户)、confirm(写操作确认)、next_plan(推进到下一步)、done(任务完成)、abort(正式终止本轮流程)。", ) return nil } @@ -279,8 +285,9 @@ func RunExecuteNode(ctx context.Context, input ExecuteNodeInput) error { if speakText != "" { isConfirmWithCard := decision.Action == newagentmodel.ExecuteActionConfirm && !input.AlwaysExecute isAskUser := decision.Action == newagentmodel.ExecuteActionAskUser + isAbort := decision.Action == newagentmodel.ExecuteActionAbort - if !isConfirmWithCard && !isAskUser { + if !isConfirmWithCard && !isAskUser && !isAbort { // 推流给前端 if err := emitter.EmitPseudoAssistantText( ctx, @@ -292,11 +299,15 @@ func RunExecuteNode(ctx context.Context, input ExecuteNodeInput) error { return fmt.Errorf("执行文案推送失败: %w", err) } } - // 始终写入历史(confirm 卡片场景下也写,保证上下文连续) - conversationContext.AppendHistory(&schema.Message{ - Role: schema.Assistant, - Content: speakText, - }) + // 1. confirm / ask_user 的 speak 仍要写入历史,避免下一轮 LLM 丢失自己的执行上下文。 + // 2. abort 不在这里写历史,避免先输出中间 speak,再在 deliver 收到第二份终止文案。 + // 3. ask_user 只是不在这里伪流式推送,真正的对外展示仍由 PendingInteraction.DisplayText 承担。 + if !isAbort { + conversationContext.AppendHistory(&schema.Message{ + Role: schema.Assistant, + Content: speakText, + }) + } } // 7. 按 LLM 决策执行动作,后端信任 LLM 判断,不做语义校验。 @@ -347,6 +358,12 @@ func RunExecuteNode(ctx context.Context, input ExecuteNodeInput) error { flowState.Done() return nil + case newagentmodel.ExecuteActionAbort: + // 1. abort 是 execute 层的正式终止协议。 + // 2. 这里只负责把终止结果写入 CommonState,真正的用户收口统一交给 deliver。 + // 3. 这样 rough_build / execute / 后续其他 stop 条件都能走同一套图内收口。 + return handleExecuteActionAbort(decision, flowState) + default: // 1. LLM 输出了不支持的 action,不应直接报错终止,而应给它修正机会。 // 2. 使用通用修正函数追加错误反馈,让 Graph 继续循环。 @@ -359,7 +376,7 @@ func RunExecuteNode(ctx context.Context, input ExecuteNodeInput) error { conversationContext, llmOutput, fmt.Sprintf("你输出的 action \"%s\" 不是合法的执行动作。", decision.Action), - "合法的 action 包括:continue(继续当前步骤)、ask_user(追问用户)、next_plan(推进到下一步)、done(任务完成)。", + "合法的 action 包括:continue(继续当前步骤)、ask_user(追问用户)、confirm(写操作确认)、next_plan(推进到下一步)、done(任务完成)、abort(正式终止本轮流程)。", ) return nil } @@ -441,6 +458,37 @@ func handleExecuteActionConfirm( return nil } +// handleExecuteActionAbort 处理 execute 阶段声明的正式终止请求。 +// +// 职责边界: +// 1. 这里只负责把 abort 协议落到 CommonState; +// 2. 不直接向用户发最终文案,避免和 deliver 收口重复; +// 3. 若模型未提供 internal_reason,则回退到 decision.Reason 作为排查信息。 +func handleExecuteActionAbort( + decision *newagentmodel.ExecuteDecision, + flowState *newagentmodel.CommonState, +) error { + if decision == nil || decision.Abort == nil { + return fmt.Errorf("abort 动作缺少终止信息") + } + if flowState == nil { + return fmt.Errorf("abort 动作缺少流程状态") + } + + internalReason := strings.TrimSpace(decision.Abort.InternalReason) + if internalReason == "" { + internalReason = strings.TrimSpace(decision.Reason) + } + + flowState.Abort( + executeStageName, + decision.Abort.Code, + decision.Abort.UserMessage, + internalReason, + ) + return nil +} + // executeToolCall 执行工具调用并记录证据。 // // 职责边界: @@ -698,7 +746,7 @@ func summarizeScheduleStateForDebug(state *newagenttools.ScheduleState) string { total := len(state.Tasks) pendingNoSlot := 0 - pendingWithSlot := 0 + suggestedTotal := 0 existingTotal := 0 taskItemWithSlot := 0 eventWithSlot := 0 @@ -707,14 +755,12 @@ func summarizeScheduleStateForDebug(state *newagenttools.ScheduleState) string { t := &state.Tasks[i] hasSlot := len(t.Slots) > 0 - switch t.Status { - case "pending": - if hasSlot { - pendingWithSlot++ - } else { - pendingNoSlot++ - } - case "existing": + switch { + case newagenttools.IsPendingTask(*t): + pendingNoSlot++ + case newagenttools.IsSuggestedTask(*t): + suggestedTotal++ + case newagenttools.IsExistingTask(*t): existingTotal++ } @@ -729,10 +775,10 @@ func summarizeScheduleStateForDebug(state *newagenttools.ScheduleState) string { } return fmt.Sprintf( - "tasks=%d pending_no_slot=%d pending_with_slot=%d existing=%d task_item_with_slot=%d event_with_slot=%d", + "tasks=%d pending=%d suggested=%d existing=%d task_item_with_slot=%d event_with_slot=%d", total, pendingNoSlot, - pendingWithSlot, + suggestedTotal, existingTotal, taskItemWithSlot, eventWithSlot, diff --git a/backend/newAgent/prompt/base.go b/backend/newAgent/prompt/base.go index 9c343c8..f46c0f0 100644 --- a/backend/newAgent/prompt/base.go +++ b/backend/newAgent/prompt/base.go @@ -76,6 +76,18 @@ func renderStateSummary(state *newagentmodel.CommonState) string { sb.WriteString(fmt.Sprintf("当前阶段:%s\n", state.Phase)) sb.WriteString(fmt.Sprintf("当前轮次:%d/%d\n", state.RoundUsed, state.MaxRounds)) + if state.HasTerminalOutcome() && state.TerminalOutcome != nil { + sb.WriteString(fmt.Sprintf("终止结果:%s\n", state.TerminalOutcome.Status)) + if strings.TrimSpace(state.TerminalOutcome.Stage) != "" { + sb.WriteString(fmt.Sprintf("终止阶段:%s\n", state.TerminalOutcome.Stage)) + } + if strings.TrimSpace(state.TerminalOutcome.Code) != "" { + sb.WriteString(fmt.Sprintf("终止代码:%s\n", state.TerminalOutcome.Code)) + } + if strings.TrimSpace(state.TerminalOutcome.UserMessage) != "" { + sb.WriteString(fmt.Sprintf("终止说明:%s\n", state.TerminalOutcome.UserMessage)) + } + } if !state.HasPlan() { sb.WriteString("当前完整 plan:暂无。\n") diff --git a/backend/newAgent/prompt/execute.go b/backend/newAgent/prompt/execute.go index 3d5106a..fd13f10 100644 --- a/backend/newAgent/prompt/execute.go +++ b/backend/newAgent/prompt/execute.go @@ -167,6 +167,125 @@ func BuildExecuteReActContractText() string { )) } +// BuildExecuteDecisionContractTextV2 返回第二轮 abort 协议补齐后的执行输出契约。 +func BuildExecuteDecisionContractTextV2() string { + return strings.TrimSpace(fmt.Sprintf(` +输出协议(严格 JSON): +- speak:给用户看的话;若 action=%s,通常留空,最终收口交给 deliver +- action:只能是 %s / %s / %s / %s / %s / %s +- reason:给后端和日志看的简短说明 +- goal_check:输出 %s 或 %s 时必填,对照 done_when 逐条验证 +- tool_call:输出 %s 时可附带写工具意图(需 confirm),输出 %s 时可附带读工具调用 +- abort:仅在输出 %s 时必填,格式为 {"code":"稳定机器码","user_message":"给用户看的终止说明","internal_reason":"给日志看的原因"} +- tool_call 与 abort 互斥,禁止同时出现 + +合法示例: +{ + "speak": "我来查一下本周的安排。", + "action": "%s", + "reason": "需要先调用 get_overview 获取当前数据", + "tool_call": { + "name": "get_overview", + "arguments": {} + } +} + +{ + "speak": "查询完成。", + "action": "%s", + "reason": "已拿到当前周课程列表", + "goal_check": "已通过 get_overview 确认本周课程列表,满足完成条件" +} + +{ + "speak": "", + "action": "%s", + "reason": "粗排结果存在业务异常,当前不应继续微调", + "abort": { + "code": "rough_build_pending_remaining", + "user_message": "初始排课方案构建异常:粗排后仍有任务未获得初始落位。本轮先终止,请检查粗排算法或任务数据。", + "internal_reason": "pending tasks remain after rough build" + } +} +`, + newagentmodel.ExecuteActionAbort, + newagentmodel.ExecuteActionContinue, + newagentmodel.ExecuteActionAskUser, + newagentmodel.ExecuteActionConfirm, + newagentmodel.ExecuteActionNextPlan, + newagentmodel.ExecuteActionDone, + newagentmodel.ExecuteActionAbort, + newagentmodel.ExecuteActionNextPlan, + newagentmodel.ExecuteActionDone, + newagentmodel.ExecuteActionConfirm, + newagentmodel.ExecuteActionContinue, + newagentmodel.ExecuteActionAbort, + newagentmodel.ExecuteActionContinue, + newagentmodel.ExecuteActionNextPlan, + newagentmodel.ExecuteActionAbort, + )) +} + +// BuildExecuteReActContractTextV2 返回第二轮 abort 协议补齐后的 ReAct 输出契约。 +func BuildExecuteReActContractTextV2() string { + return strings.TrimSpace(fmt.Sprintf(` +输出协议(严格 JSON): +- speak:给用户看的话(可以是分析结果、中间进展、或最终回复);若 action=%s,通常留空 +- action:只能是 %s / %s / %s / %s / %s +- reason:给后端和日志看的简短说明 +- goal_check:输出 %s 时必填,总结任务完成证据 +- tool_call:输出 %s 时可附带写工具意图(需 confirm),输出 %s 时可附带读工具调用 +- abort:仅在输出 %s 时必填,格式为 {"code":"稳定机器码","user_message":"给用户看的终止说明","internal_reason":"给日志看的原因"} +- tool_call 与 abort 互斥,禁止同时出现 + +合法示例: +{ + "speak": "我来查一下今天的安排。", + "action": "%s", + "reason": "需要调用 get_overview 查询", + "tool_call": { + "name": "get_overview", + "arguments": {} + } +} + +{ + "speak": "已将概率论移到周三第1-2节。", + "action": "%s", + "reason": "用户要求移动课程,写操作需确认", + "tool_call": { + "name": "move", + "arguments": {"task_id": 5, "new_day": 3, "new_slot_start": 1} + } +} + +{ + "speak": "", + "action": "%s", + "reason": "当前流程不应继续执行,需要正式终止", + "abort": { + "code": "domain_abort", + "user_message": "当前流程无法继续执行,本轮先终止。", + "internal_reason": "execute declared abort" + } +} +`, + newagentmodel.ExecuteActionAbort, + newagentmodel.ExecuteActionContinue, + newagentmodel.ExecuteActionAskUser, + newagentmodel.ExecuteActionConfirm, + newagentmodel.ExecuteActionDone, + newagentmodel.ExecuteActionAbort, + newagentmodel.ExecuteActionDone, + newagentmodel.ExecuteActionConfirm, + newagentmodel.ExecuteActionContinue, + newagentmodel.ExecuteActionAbort, + newagentmodel.ExecuteActionContinue, + newagentmodel.ExecuteActionConfirm, + newagentmodel.ExecuteActionAbort, + )) +} + // BuildExecuteMessages 组装执行阶段的 messages。 func BuildExecuteMessages(state *newagentmodel.CommonState, ctx *newagentmodel.ConversationContext) []*schema.Message { if state != nil && state.HasPlan() { @@ -215,9 +334,10 @@ func BuildExecuteUserPrompt(state *newagentmodel.CommonState) string { sb.WriteString("3. 若当前步骤已完成,请输出 action=next_plan,并填写 goal_check 说明完成依据。\n") sb.WriteString("4. 若整个任务已完成,请输出 action=done,并填写 goal_check 总结整体证据。\n") sb.WriteString("5. 若缺少关键用户信息且现有上下文无法补足,请输出 action=ask_user。\n") - sb.WriteString("6. 输出 next_plan 或 done 时,goal_check 不能为空,必须对照 done_when 逐条验证。\n") + sb.WriteString("6. 若你判断当前流程应正式终止,而不是继续执行、追问或写工具,请输出 action=abort,并附带 abort 字段。\n") + sb.WriteString("7. 输出 next_plan 或 done 时,goal_check 不能为空,必须对照 done_when 逐条验证。\n") sb.WriteString("\n") - sb.WriteString(BuildExecuteDecisionContractText()) + sb.WriteString(BuildExecuteDecisionContractTextV2()) } else { sb.WriteString("当前 plan 已存在,但当前步骤索引无效;请不要擅自执行其他步骤。\n") } @@ -248,9 +368,10 @@ func BuildExecuteReActUserPrompt(state *newagentmodel.CommonState) string { sb.WriteString("- 需要查询/读取数据 → action=continue + tool_call(读工具)\n") sb.WriteString("- 需要修改/写入数据 → action=confirm + tool_call(写工具,需用户确认)\n") sb.WriteString("- 缺少关键信息 → action=ask_user\n") - sb.WriteString("- 任务完成 → action=done + goal_check\n\n") + sb.WriteString("- 任务完成 → action=done + goal_check\n") + sb.WriteString("- 当前流程应正式终止 → action=abort + abort\n\n") - sb.WriteString(BuildExecuteReActContractText()) + sb.WriteString(BuildExecuteReActContractTextV2()) return strings.TrimSpace(sb.String()) }