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. 同步更新调试日志文件
前端:无
仓库:无
This commit is contained in:
LoveLosita
2026-04-09 16:17:56 +08:00
parent 21b864390b
commit 821c2cde5d
20 changed files with 5809 additions and 2685 deletions

View File

@@ -218,6 +218,7 @@ func (n *AgentNodes) Execute(ctx context.Context, st *newagentmodel.AgentGraphSt
ToolRegistry: st.Deps.ToolRegistry,
ScheduleState: scheduleState,
SchedulePersistor: st.Deps.SchedulePersistor,
WriteSchedulePreview: st.Deps.WriteSchedulePreview,
OriginalScheduleState: st.OriginalScheduleState,
AlwaysExecute: st.Request.AlwaysExecute,
},

View File

@@ -67,6 +67,17 @@ func RunChatNode(ctx context.Context, input ChatNodeInput) error {
// 2. 无 pending → 路由决策(一次快速 LLM 调用,不开 thinking
flowState := runtimeState.EnsureCommonState()
if !runtimeState.HasPendingInteraction() && flowState.Phase == newagentmodel.PhaseDone {
terminalBefore := flowState.TerminalStatus()
roundBefore := flowState.RoundUsed
flowState.ResetForNextRun()
log.Printf(
"[DEBUG] chat reset runtime for next run chat=%s round_before=%d terminal_before=%s",
flowState.ConversationID,
roundBefore,
terminalBefore,
)
}
messages := newagentprompt.BuildChatRoutingMessages(conversationContext, input.UserInput, flowState)
decision, rawResult, err := newagentllm.GenerateJSON[newagentmodel.ChatRoutingDecision](

View File

@@ -51,6 +51,7 @@ type ExecuteNodeInput struct {
ToolRegistry *newagenttools.ToolRegistry
ScheduleState *newagenttools.ScheduleState
SchedulePersistor newagentmodel.SchedulePersistor
WriteSchedulePreview newagentmodel.WriteSchedulePreviewFunc
OriginalScheduleState *newagenttools.ScheduleState
AlwaysExecute bool // true 时写工具跳过确认闸门直接执行
}
@@ -100,13 +101,31 @@ func RunExecuteNode(ctx context.Context, input ExecuteNodeInput) error {
// 1.5. 确认执行分支:如果用户已确认写操作,直接执行工具。
if runtimeState.PendingConfirmTool != nil {
return executePendingTool(ctx, runtimeState, conversationContext, input.ToolRegistry, input.ScheduleState, input.SchedulePersistor, input.OriginalScheduleState, emitter)
return executePendingTool(
ctx,
runtimeState,
conversationContext,
input.ToolRegistry,
input.ScheduleState,
input.SchedulePersistor,
input.OriginalScheduleState,
input.WriteSchedulePreview,
emitter,
)
}
// 1.6. 顺序守卫基线初始化:
// 1) 仅在未授权打乱顺序时记录 suggested 顺序基线;
// 2) 只在基线为空时初始化,避免执行循环中反复覆盖;
// 3) 后续由 order_guard 节点基于该基线做相对顺序校验。
//
// 同时在“本轮 execute 首轮”重置一次临时队列,避免上一轮残留队列污染新请求。
// 判定依据:
// 1. RoundUsed==0 说明当前还未消耗执行预算;
// 2. 此时清理不会影响断线恢复中的中间进度(恢复场景通常 RoundUsed>0
if input.ScheduleState != nil && flowState.RoundUsed == 0 {
newagenttools.ResetTaskProcessingQueue(input.ScheduleState)
}
if !flowState.AllowReorder && len(flowState.SuggestedOrderBaseline) == 0 {
flowState.SuggestedOrderBaseline = buildSuggestedOrderSnapshot(input.ScheduleState)
}
@@ -329,7 +348,16 @@ func RunExecuteNode(ctx context.Context, input ExecuteNodeInput) error {
// 继续当前步骤的 ReAct 循环。
// 若有工具调用意图,则执行工具并记录证据。
if decision.ToolCall != nil {
return executeToolCall(ctx, flowState, conversationContext, decision.ToolCall, emitter, input.ToolRegistry, input.ScheduleState)
return executeToolCall(
ctx,
flowState,
conversationContext,
decision.ToolCall,
emitter,
input.ToolRegistry,
input.ScheduleState,
input.WriteSchedulePreview,
)
}
// 无工具调用且 speak 为空speak 非空时已在步骤 6 写入历史)。
// 若 history 本轮完全没有更新,下一轮 LLM 会收到完全相同的上下文,容易死循环。
@@ -351,7 +379,16 @@ func RunExecuteNode(ctx context.Context, input ExecuteNodeInput) error {
case newagentmodel.ExecuteActionConfirm:
// AlwaysExecute=true跳过确认闸门直接执行内存写工具不走 confirm 节点。
if input.AlwaysExecute && decision.ToolCall != nil {
return executeToolCall(ctx, flowState, conversationContext, decision.ToolCall, emitter, input.ToolRegistry, input.ScheduleState)
return executeToolCall(
ctx,
flowState,
conversationContext,
decision.ToolCall,
emitter,
input.ToolRegistry,
input.ScheduleState,
input.WriteSchedulePreview,
)
}
// AlwaysExecute=false默认暂存工具意图设 Phase → 下游 confirm 节点接管。
return handleExecuteActionConfirm(decision, runtimeState, flowState)
@@ -552,6 +589,7 @@ func executeToolCall(
emitter *newagentstream.ChunkEmitter,
registry *newagenttools.ToolRegistry,
scheduleState *newagenttools.ScheduleState,
writePreview newagentmodel.WriteSchedulePreviewFunc,
) error {
if toolCall == nil {
return nil
@@ -700,6 +738,14 @@ func executeToolCall(
ToolName: toolName,
})
// 4. 写工具实时预览:每次写工具执行后都尝试刷新 Redis 预览,确保前端可见“最新操作结果”。
//
// 步骤化说明:
// 1. 仅写工具触发实时预览刷新,读工具不触发,避免无意义放大写流量;
// 2. 这里采用“失败不阻断主流程”策略:预览写失败只记日志,不影响当前执行链路;
// 3. Deliver 节点仍保留最终覆盖写,保证 order_guard/收口后的最终态一致。
tryWritePreviewAfterWriteTool(ctx, flowState, scheduleState, registry, toolName, writePreview)
return nil
}
@@ -732,6 +778,7 @@ func executePendingTool(
scheduleState *newagenttools.ScheduleState,
persistor newagentmodel.SchedulePersistor,
originalState *newagenttools.ScheduleState,
writePreview newagentmodel.WriteSchedulePreviewFunc,
emitter *newagentstream.ChunkEmitter,
) error {
pending := runtimeState.PendingConfirmTool
@@ -804,12 +851,53 @@ func executePendingTool(
ToolName: pending.ToolName,
})
// 5. 写工具实时预览confirm accept 后真实执行写工具时,立即刷新一次预览缓存。
tryWritePreviewAfterWriteTool(ctx, flowState, scheduleState, registry, pending.ToolName, writePreview)
// 6. 清空临时邮箱,避免重复执行。
runtimeState.PendingConfirmTool = nil
return nil
}
// tryWritePreviewAfterWriteTool 在写工具执行后尝试刷新一次排程预览缓存。
//
// 职责边界:
// 1. 只负责“写工具后实时可见”的旁路写入,不负责最终收口;
// 2. 只在 write tool 命中时执行,读工具直接跳过;
// 3. 失败只记日志,不影响主流程,避免因为缓存抖动打断执行。
func tryWritePreviewAfterWriteTool(
ctx context.Context,
flowState *newagentmodel.CommonState,
scheduleState *newagenttools.ScheduleState,
registry *newagenttools.ToolRegistry,
toolName string,
writePreview newagentmodel.WriteSchedulePreviewFunc,
) {
if flowState == nil || scheduleState == nil || registry == nil || writePreview == nil {
return
}
if !registry.IsWriteTool(toolName) {
return
}
if err := writePreview(ctx, scheduleState, flowState.UserID, flowState.ConversationID, flowState.TaskClassIDs); err != nil {
log.Printf(
"[WARN] execute realtime preview write failed chat=%s tool=%s err=%v",
flowState.ConversationID,
toolName,
err,
)
return
}
log.Printf(
"[DEBUG] execute realtime preview write success chat=%s tool=%s",
flowState.ConversationID,
toolName,
)
}
// listItemRe 匹配被粘连在一起的列表序号(如 "2. " "水课3. "),用于自动补换行。
// 规则:非换行字符后紧跟 2-9 的序号("2. " "3、" 等),说明 LLM 漏写了换行。
var listItemRe = regexp.MustCompile(`([^\n])([2-9][\.、]\s)`)

View File

@@ -3,6 +3,7 @@ package newagentnode
import (
"context"
"fmt"
"log"
"sort"
"strings"
@@ -20,6 +21,13 @@ type suggestedOrderItem struct {
Day int
SlotStart int
SlotEnd int
Slots []newagenttools.TaskSlot
}
type orderRestoreResult struct {
Restored bool
Changed int
Detail string
}
// RunOrderGuardNode 负责在收口前校验 suggested 任务相对顺序是否被打乱。
@@ -27,7 +35,7 @@ type suggestedOrderItem struct {
// 职责边界:
// 1. 只做“相对顺序守卫”这一件事,不负责执行调度工具,也不负责写库;
// 2. 仅当 AllowReorder=false 时生效,用户明确授权可打乱顺序时直接放行;
// 3. 校验失败只写入统一终止结果Abort,由 Deliver 节点统一收口文案
// 3. 校验失败时优先“自动复原相对顺序”,由 Deliver 节点继续交付,不再直接终止
func RunOrderGuardNode(ctx context.Context, st *newagentmodel.AgentGraphState) error {
if st == nil {
return fmt.Errorf("order_guard node: state is nil")
@@ -65,7 +73,7 @@ func RunOrderGuardNode(ctx context.Context, st *newagentmodel.AgentGraphState) e
return nil
}
// 4. 基线存在时做逆序检测;一旦发现逆序,立即终止本轮自动微调
// 4. 基线存在时做逆序检测;发现逆序后优先自动复原,而不是直接中止
violated, detail := detectRelativeOrderViolation(flowState.SuggestedOrderBaseline, currentOrder)
if !violated {
_ = st.EnsureChunkEmitter().EmitStatus(
@@ -78,19 +86,36 @@ func RunOrderGuardNode(ctx context.Context, st *newagentmodel.AgentGraphState) e
return nil
}
userMessage := "检测到当前方案打乱了原有建议任务顺序,本轮先停止自动微调。若你确认可以打乱顺序,请明确说明“允许打乱顺序”。"
flowState.Abort(
orderGuardStageName,
"relative_order_violation",
userMessage,
fmt.Sprintf("baseline=%v current=%v detail=%s", flowState.SuggestedOrderBaseline, currentOrder, detail),
)
// 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_failed",
userMessage,
true,
"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
}
@@ -102,6 +127,21 @@ func RunOrderGuardNode(ctx context.Context, st *newagentmodel.AgentGraphState) e
// 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
}
@@ -118,6 +158,7 @@ func buildSuggestedOrderSnapshot(state *newagenttools.ScheduleState) []int {
Day: day,
SlotStart: slotStart,
SlotEnd: slotEnd,
Slots: cloneTaskSlots(task.Slots),
})
}
@@ -134,11 +175,7 @@ func buildSuggestedOrderSnapshot(state *newagenttools.ScheduleState) []int {
return items[i].StateID < items[j].StateID
})
order := make([]int, 0, len(items))
for _, item := range items {
order = append(order, item.StateID)
}
return order
return items
}
func earliestTaskSlot(slots []newagenttools.TaskSlot) (day int, slotStart int, slotEnd int) {
@@ -205,3 +242,221 @@ func detectRelativeOrderViolation(baseline []int, current []int) (bool, string)
}
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
}