Files
smartmate/backend/newAgent/node/order_guard.go
LoveLosita 821c2cde5d Version: 0.9.10.dev.260409
后端:
1. newAgent 运行态重置双保险落地,并补齐写工具后的实时排程预览刷新
   - 更新 model/common_state.go:新增 ResetForNextRun,统一清理 round/plan/rough_build/allow_reorder/terminal 等执行期临时状态
   - 更新 node/chat.go + service/agentsvc/agent_newagent.go:在“无 pending 且上一轮已 done”时分别于 chat 主入口与 loadOrCreateRuntimeState 冷加载处执行兜底重置,覆盖正常新一轮对话与断线恢复场景
   - 更新 model/graph_run_state.go + node/agent_nodes.go + node/execute.go:写工具执行后立即刷新 Redis 排程预览,Deliver 继续保留最终覆盖写,保证前端能及时看到最新操作结果
2. 顺序守卫从“直接中止”改为“优先自动复原 suggested 相对顺序”
   - 更新 node/order_guard.go:检测到 suggested 顺序被打乱后,不再直接 abort;改为复用当前坑位按 baseline 自动回填,并在复原失败时仅记录诊断日志后继续交付
   - 更新 tools/state.go:ScheduleState 新增 RuntimeQueue 运行态快照字段,支持队列化处理与断线恢复
3. 多任务微调工具链升级:新增筛选/队列工具并替换首空位查询口径
   - 新建 tools/read_filter_tools.go + tools/runtime_queue.go + tools/queue_tools.go:新增 query_available_slots / query_target_tasks / queue_pop_head / queue_apply_head_move / queue_skip_head / queue_status,支持“先筛选目标,再逐项处理”的稳定微调链路
   - 更新 tools/registry.go + tools/write_tools.go + tools/read_helpers.go:移除 find_first_free 注册口径;batch_move 限制为最多 2 条,超过时引导改走队列逐项处理;queue_apply_head_move 纳入写工具集合
4. 复合规划工具扩充,并改为在 newAgent/tools 本地实现以规避循环导入
   - 更新 tools/compound_tools.go + tools/registry.go:spread_even 正式接入,并与 min_context_switch 一起作为复合写工具保留在 newAgent/tools 内部实现,不再依赖外层 logic
5. prompt 与工具文档同步升级,明确当前用户诉求锚点与队列化执行约束
   - 更新 prompt/execute.go + prompt/execute_context.go + prompt/plan.go:执行提示默认引导 query_target_tasks(enqueue=true) → queue_pop_head → query_available_slots → queue_apply_head_move / queue_skip_head;补齐 batch_move 上限、spread_even 使用边界、顺序策略与工具 JSON 返回示例
   - 更新 prompt/execute_context.go:将“初始用户目标”改为“当前用户诉求”,并保留首轮目标来源;旧 observation 折叠文案改为“当前工具调用结果已经被使用过,当前无需使用,为节省上下文空间,已折叠”
   - 更新 tools/SCHEDULE_TOOLS.md:同步补齐 query_* / queue_* / spread_even / min_context_switch 的说明、限制与返回示例
6. 同步更新调试日志文件
前端:无
仓库:无
2026-04-09 16:17:56 +08:00

463 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package newagentnode
import (
"context"
"fmt"
"log"
"sort"
"strings"
newagentmodel "github.com/LoveLosita/smartflow/backend/newAgent/model"
newagenttools "github.com/LoveLosita/smartflow/backend/newAgent/tools"
)
const (
orderGuardStageName = "order_guard"
orderGuardStatusBlock = "order_guard.status"
)
type suggestedOrderItem struct {
StateID int
Day int
SlotStart int
SlotEnd int
Slots []newagenttools.TaskSlot
}
type orderRestoreResult struct {
Restored bool
Changed int
Detail string
}
// RunOrderGuardNode 负责在收口前校验 suggested 任务相对顺序是否被打乱。
//
// 职责边界:
// 1. 只做“相对顺序守卫”这一件事,不负责执行调度工具,也不负责写库;
// 2. 仅当 AllowReorder=false 时生效,用户明确授权可打乱顺序时直接放行;
// 3. 校验失败时优先“自动复原相对顺序”,由 Deliver 节点继续交付,不再直接终止。
func RunOrderGuardNode(ctx context.Context, st *newagentmodel.AgentGraphState) error {
if st == nil {
return fmt.Errorf("order_guard node: state is nil")
}
flowState := st.EnsureFlowState()
if flowState == nil {
return fmt.Errorf("order_guard node: flow state is nil")
}
// 1. 用户明确授权可打乱顺序时,顺序守卫节点直接放行。
if flowState.AllowReorder {
return nil
}
// 2. 读取当前 ScheduleState提取 suggested 任务的“时间顺序快照”。
scheduleState, err := st.EnsureScheduleState(ctx)
if err != nil {
return fmt.Errorf("order_guard node: load schedule state failed: %w", err)
}
if scheduleState == nil {
return nil
}
currentOrder := buildSuggestedOrderSnapshot(scheduleState)
// 3. 基线为空时,仅初始化基线并放行,避免第一次进入守卫就误判。
if len(flowState.SuggestedOrderBaseline) == 0 {
flowState.SuggestedOrderBaseline = append([]int(nil), currentOrder...)
_ = st.EnsureChunkEmitter().EmitStatus(
orderGuardStatusBlock,
orderGuardStageName,
"order_guard_initialized",
"已记录本轮建议任务顺序基线,继续交付当前结果。",
false,
)
return nil
}
// 4. 基线存在时做逆序检测;发现逆序后优先自动复原,而不是直接中止。
violated, detail := detectRelativeOrderViolation(flowState.SuggestedOrderBaseline, currentOrder)
if !violated {
_ = st.EnsureChunkEmitter().EmitStatus(
orderGuardStatusBlock,
orderGuardStageName,
"order_guard_passed",
"顺序守卫校验通过,保持原有相对顺序。",
false,
)
return nil
}
// 4.1 违序后进入自动复原:
// 1) 复用“当前坑位集合”,按 baseline 相对顺序回填任务;
// 2) 成功则继续 completed 路径,保证预览可写入;
// 3) 若复原条件不满足,保守放行并输出诊断,避免再次把整轮流程打成 aborted。
restore := restoreSuggestedOrderByBaseline(scheduleState, flowState.SuggestedOrderBaseline)
if restore.Restored {
_ = st.EnsureChunkEmitter().EmitStatus(
orderGuardStatusBlock,
orderGuardStageName,
"order_guard_restored",
fmt.Sprintf("检测到建议任务顺序被打乱,已自动复原(调整 %d 个任务)。", restore.Changed),
false,
)
return nil
}
_ = st.EnsureChunkEmitter().EmitStatus(
orderGuardStatusBlock,
orderGuardStageName,
"order_guard_restore_skipped",
"检测到顺序异常,但本次未执行自动复原,已继续交付当前结果。详情见日志。",
false,
)
log.Printf(
"[WARN] order_guard restore skipped chat=%s baseline=%v current=%v detail=%s restore_detail=%s",
flowState.ConversationID,
flowState.SuggestedOrderBaseline,
currentOrder,
detail,
restore.Detail,
)
return nil
}
// buildSuggestedOrderSnapshot 生成 suggested 任务的相对顺序快照(按时间坐标排序)。
//
// 说明:
// 1. 这里只关心 suggested 任务,因为顺序守卫目标是约束“本轮建议层”的相对次序;
// 2. 多 slot 任务取“最早 slot”作为排序锚点保证排序键稳定
// 3. 返回值是 state_id 列表,便于写入 CommonState 做跨节点持久化。
func buildSuggestedOrderSnapshot(state *newagenttools.ScheduleState) []int {
items := buildSuggestedOrderItems(state)
order := make([]int, 0, len(items))
for _, item := range items {
order = append(order, item.StateID)
}
return order
}
// buildSuggestedOrderItems 生成 suggested 任务的排序明细。
//
// 职责边界:
// 1. 统一封装顺序守卫和自动复原都需要的排序素材,避免两处逻辑口径漂移;
// 2. 排序键保持与历史实现一致day -> slot_start -> slot_end -> state_id
// 3. 每项附带完整 slots 快照,供“坑位复用式复原”直接使用。
func buildSuggestedOrderItems(state *newagenttools.ScheduleState) []suggestedOrderItem {
if state == nil || len(state.Tasks) == 0 {
return nil
}
items := make([]suggestedOrderItem, 0, len(state.Tasks))
for i := range state.Tasks {
task := state.Tasks[i]
if !newagenttools.IsSuggestedTask(task) || len(task.Slots) == 0 {
continue
}
day, slotStart, slotEnd := earliestTaskSlot(task.Slots)
items = append(items, suggestedOrderItem{
StateID: task.StateID,
Day: day,
SlotStart: slotStart,
SlotEnd: slotEnd,
Slots: cloneTaskSlots(task.Slots),
})
}
sort.SliceStable(items, func(i, j int) bool {
if items[i].Day != items[j].Day {
return items[i].Day < items[j].Day
}
if items[i].SlotStart != items[j].SlotStart {
return items[i].SlotStart < items[j].SlotStart
}
if items[i].SlotEnd != items[j].SlotEnd {
return items[i].SlotEnd < items[j].SlotEnd
}
return items[i].StateID < items[j].StateID
})
return items
}
func earliestTaskSlot(slots []newagenttools.TaskSlot) (day int, slotStart int, slotEnd int) {
if len(slots) == 0 {
return 0, 0, 0
}
best := slots[0]
for i := 1; i < len(slots); i++ {
current := slots[i]
if current.Day < best.Day {
best = current
continue
}
if current.Day == best.Day && current.SlotStart < best.SlotStart {
best = current
continue
}
if current.Day == best.Day && current.SlotStart == best.SlotStart && current.SlotEnd < best.SlotEnd {
best = current
}
}
return best.Day, best.SlotStart, best.SlotEnd
}
// detectRelativeOrderViolation 检查 current 是否破坏 baseline 的相对顺序。
//
// 规则:
// 1. 仅比较 baseline 与 current 的交集任务,避免新增/删除任务引发误报;
// 2. 一旦出现 rank 逆序即判定为 violation
// 3. detail 只用于内部排查,不直接给用户。
func detectRelativeOrderViolation(baseline []int, current []int) (bool, string) {
if len(baseline) == 0 || len(current) == 0 {
return false, ""
}
rankByID := make(map[int]int, len(baseline))
for idx, id := range baseline {
rankByID[id] = idx
}
filtered := make([]int, 0, len(current))
for _, id := range current {
if _, ok := rankByID[id]; ok {
filtered = append(filtered, id)
}
}
if len(filtered) < 2 {
return false, ""
}
prevID := filtered[0]
prevRank := rankByID[prevID]
for i := 1; i < len(filtered); i++ {
id := filtered[i]
rank := rankByID[id]
if rank < prevRank {
return true, strings.TrimSpace(fmt.Sprintf(
"reverse pair detected: prev_id=%d prev_rank=%d current_id=%d current_rank=%d",
prevID, prevRank, id, rank,
))
}
prevID = id
prevRank = rank
}
return false, ""
}
// restoreSuggestedOrderByBaseline 在“默认不允许打乱顺序”场景下自动复原 suggested 相对顺序。
//
// 步骤化说明:
// 1. 先提取 baseline 与 current 的交集任务,确保只修复本轮可比对对象;
// 2. 复用 current 的“坑位序列”(时段集合),按 baseline 顺序重新回填任务;
// 3. 回填前校验时长兼容,避免把长任务塞进短坑位;
// 4. 回填后再次校验顺序;若失败则回滚,保证状态不会半成功。
func restoreSuggestedOrderByBaseline(state *newagenttools.ScheduleState, baseline []int) orderRestoreResult {
if state == nil {
return orderRestoreResult{Restored: false, Detail: "schedule_state=nil"}
}
if len(baseline) == 0 {
return orderRestoreResult{Restored: true}
}
items := buildSuggestedOrderItems(state)
if len(items) < 2 {
return orderRestoreResult{Restored: true}
}
itemByID := make(map[int]suggestedOrderItem, len(items))
currentInScope := make([]int, 0, len(items))
for _, item := range items {
itemByID[item.StateID] = item
}
for _, item := range items {
if _, ok := itemByID[item.StateID]; ok {
currentInScope = append(currentInScope, item.StateID)
}
}
baselineInScope := make([]int, 0, len(baseline))
for _, id := range baseline {
if _, ok := itemByID[id]; ok {
baselineInScope = append(baselineInScope, id)
}
}
if len(baselineInScope) < 2 {
return orderRestoreResult{Restored: true}
}
// currentInScope 只保留 baseline 交集,保证两边长度一致且语义可比。
baselineSet := make(map[int]struct{}, len(baselineInScope))
for _, id := range baselineInScope {
baselineSet[id] = struct{}{}
}
filteredCurrent := make([]int, 0, len(currentInScope))
for _, id := range currentInScope {
if _, ok := baselineSet[id]; ok {
filteredCurrent = append(filteredCurrent, id)
}
}
if sameIDOrder(filteredCurrent, baselineInScope) {
return orderRestoreResult{Restored: true}
}
if len(filteredCurrent) != len(baselineInScope) {
return orderRestoreResult{
Restored: false,
Detail: fmt.Sprintf("size_mismatch baseline=%d current=%d", len(baselineInScope), len(filteredCurrent)),
}
}
// 1. 先构建“当前坑位序列”。
slotPool := make([][]newagenttools.TaskSlot, 0, len(filteredCurrent))
for _, currentID := range filteredCurrent {
item, ok := itemByID[currentID]
if !ok {
return orderRestoreResult{
Restored: false,
Detail: fmt.Sprintf("current_id_missing id=%d", currentID),
}
}
slotPool = append(slotPool, cloneTaskSlots(item.Slots))
}
// 2. 回填前做兼容性校验:默认要求“目标任务时长 == 坑位时长”。
for i, targetID := range baselineInScope {
targetTask := state.TaskByStateID(targetID)
if targetTask == nil {
return orderRestoreResult{
Restored: false,
Detail: fmt.Sprintf("target_task_missing id=%d", targetID),
}
}
if !isSlotsCompatibleWithTask(*targetTask, slotPool[i]) {
return orderRestoreResult{
Restored: false,
Detail: fmt.Sprintf(
"slot_incompatible target=%d expected_duration=%d slot_duration=%d expected_segments=%d slot_segments=%d",
targetID,
expectedTaskDuration(*targetTask),
totalSlotDuration(slotPool[i]),
len(targetTask.Slots),
len(slotPool[i]),
),
}
}
}
// 3. 执行回填,并在失败时支持回滚。
beforeSlots := make(map[int][]newagenttools.TaskSlot, len(baselineInScope))
changed := 0
for i, targetID := range baselineInScope {
task := state.TaskByStateID(targetID)
if task == nil {
continue
}
beforeSlots[targetID] = cloneTaskSlots(task.Slots)
targetSlots := cloneTaskSlots(slotPool[i])
if !equalTaskSlots(task.Slots, targetSlots) {
task.Slots = targetSlots
changed++
}
}
afterOrder := buildSuggestedOrderSnapshot(state)
afterFiltered := make([]int, 0, len(afterOrder))
for _, id := range afterOrder {
if _, ok := baselineSet[id]; ok {
afterFiltered = append(afterFiltered, id)
}
}
if !sameIDOrder(afterFiltered, baselineInScope) {
// 回滚,避免保留半成功状态。
for _, targetID := range baselineInScope {
task := state.TaskByStateID(targetID)
if task == nil {
continue
}
task.Slots = cloneTaskSlots(beforeSlots[targetID])
}
return orderRestoreResult{
Restored: false,
Detail: fmt.Sprintf(
"restore_verify_failed expected=%v actual=%v",
baselineInScope, afterFiltered,
),
}
}
return orderRestoreResult{
Restored: true,
Changed: changed,
}
}
func sameIDOrder(left, right []int) bool {
if len(left) != len(right) {
return false
}
for i := range left {
if left[i] != right[i] {
return false
}
}
return true
}
func cloneTaskSlots(slots []newagenttools.TaskSlot) []newagenttools.TaskSlot {
if len(slots) == 0 {
return nil
}
copied := make([]newagenttools.TaskSlot, len(slots))
copy(copied, slots)
return copied
}
func equalTaskSlots(left, right []newagenttools.TaskSlot) bool {
if len(left) != len(right) {
return false
}
for i := range left {
if left[i].Day != right[i].Day {
return false
}
if left[i].SlotStart != right[i].SlotStart {
return false
}
if left[i].SlotEnd != right[i].SlotEnd {
return false
}
}
return true
}
func expectedTaskDuration(task newagenttools.ScheduleTask) int {
if task.Duration > 0 {
return task.Duration
}
if len(task.Slots) > 0 {
return totalSlotDuration(task.Slots)
}
return 0
}
func totalSlotDuration(slots []newagenttools.TaskSlot) int {
total := 0
for _, slot := range slots {
total += slot.SlotEnd - slot.SlotStart + 1
}
return total
}
func isSlotsCompatibleWithTask(task newagenttools.ScheduleTask, slots []newagenttools.TaskSlot) bool {
if len(slots) == 0 {
return false
}
expectedDuration := expectedTaskDuration(task)
if expectedDuration > 0 && expectedDuration != totalSlotDuration(slots) {
return false
}
// 兼容策略:当前任务已有多段落位时,要求目标坑位段数一致,避免跨段语义被破坏。
if len(task.Slots) > 0 && len(task.Slots) != len(slots) {
return false
}
return true
}