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

File diff suppressed because it is too large Load Diff

View File

@@ -184,6 +184,37 @@ func (s *CommonState) RejectPlan() {
s.ClearTerminalOutcome()
}
// ResetForNextRun 在“上一轮已经收口,且本轮准备开始新请求”时重置执行期临时状态。
//
// 职责边界:
// 1. 负责清理会污染新一轮执行的临时字段(轮次、修正计数、计划游标、粗排开关、顺序基线、终止结果);
// 2. 不负责清理会话身份与跨轮共享数据ConversationID/UserID/TaskClassIDs/TaskClasses/历史上下文/ScheduleState
// 3. 该方法是幂等操作:重复调用不会引入额外副作用,便于在“加载兜底 + chat 入口”双保险场景下复用。
func (s *CommonState) ResetForNextRun() {
if s == nil {
return
}
// 1. 先把阶段回收为 planning确保新一轮从可路由的干净入口开始。
// 2. 这样即使后续还有兜底重置判断,也不会因为仍处于 done 而重复触发。
s.Phase = PhasePlanning
// 3. 清理执行轮次与连续修正计数,避免上一轮预算/异常计数污染本轮。
s.RoundUsed = 0
s.ConsecutiveCorrections = 0
// 4. 清理计划执行游标与粗排相关临时标记,确保新请求不会误沿用旧计划。
s.PlanSteps = nil
s.CurrentStep = 0
s.NeedsRoughBuild = false
s.NeedsRefineAfterRoughBuild = false
// 5. 重置顺序约束临时态与终止结果,避免上一轮 completed/aborted/exhausted 语义串到下一轮。
s.AllowReorder = false
s.SuggestedOrderBaseline = nil
s.ClearTerminalOutcome()
}
// AdvanceStep 推进到下一个计划步骤,并返回是否仍有剩余步骤。
func (s *CommonState) AdvanceStep() bool {
s.CurrentStep++

View File

@@ -45,7 +45,9 @@ type RoughBuildPlacement struct {
type RoughBuildFunc func(ctx context.Context, userID int, taskClassIDs []int) ([]RoughBuildPlacement, error)
// WriteSchedulePreviewFunc 是排程预览写入的依赖注入签名。
// 由 service 层封装 cacheDAO 后注入deliver 节点在任务完成时调用,保证只有真正完成的结果才写入缓存。
// 由 service 层封装 cacheDAO 后注入,execute/deliver 节点可按需调用:
// 1. execute 写工具后可实时刷新,保障前端及时看到最新调整;
// 2. deliver 结束时再做最终覆盖写,保障收口状态一致。
type WriteSchedulePreviewFunc func(ctx context.Context, state *newagenttools.ScheduleState, userID int, conversationID string, taskClassIDs []int) error
// AgentGraphDeps 描述 graph/node 层运行时真正依赖的可插拔能力。

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
}

View File

@@ -17,6 +17,7 @@ const executeSystemPromptWithPlan = `
3. 需要写操作时输出 action=confirm 并附带 tool_call等待用户确认。
4. 若用户给出了“二次微调方向”(如负载均衡、某天减负、某类任务后移),优先围绕该方向推进,并在 goal_check 说明满足情况。
5. 只有在用户明确允许打乱顺序时,才可使用 min_context_switch 做重排。
6. 多任务微调时默认走队列链路query_target_tasks(enqueue=true) → queue_pop_head → query_available_slots → queue_apply_head_move / queue_skip_head。
你不要做什么:
1. 不要跳到其他 plan 步骤,不要越级执行。
@@ -30,6 +31,8 @@ const executeSystemPromptWithPlan = `
9. list_tasks.category 只接受任务类名称,不接受 task_class_ids如 "1,2,3")。
10. 不要忽略用户最新补充的微调方向;若与旧目标冲突,以最新用户要求为准。
11. 若当前顺序策略是“默认保持顺序”,禁止调用 min_context_switch。
12. 不要把超过 2 条任务打包到 batch_move大批量调整请改走队列逐项处理。
13. 不要在未获取队首queue_pop_head时直接调用 queue_apply_head_move。
执行规则:
1. 只输出严格 JSON不要输出 markdown不要在 JSON 外补充文本。
@@ -51,10 +54,11 @@ const executeSystemPromptReAct = `
你可以做什么:
1. 你可以基于用户给定的二次微调方向,对 suggested 做定向微调。
2. existing 属于已安排事实层,可用于冲突判断和参考,不作为 move/batch_move 的目标。
3. 你可以先调用读工具补充必要事实(例如 get_overview/list_tasks/find_first_free/get_task_info
4. 你可以在需要改动时提出 confirmmove/swap/unplace/batch_move
2. existing 属于已安排事实层,可用于冲突判断和参考,不作为 move/batch_move/spread_even 的目标。
3. 你可以先调用读工具补充必要事实(例如 get_overview/list_tasks/query_target_tasks/query_available_slots/get_task_info
4. 你可以在需要改动时提出 confirmmove/swap/unplace/batch_move/spread_even)。
5. 只有用户明确允许打乱顺序时,才可使用 min_context_switch。
6. 多任务处理默认使用队列链路:先 query_target_tasks(enqueue=true) 入队,再 queue_pop_head 逐项处理。
你不要做什么:
1. 不要假设任务还没排进去,然后改成逐个手动 place。
@@ -64,9 +68,11 @@ const executeSystemPromptReAct = `
5. 若工具结果与已知事实明显冲突如无写操作却从“有任务”变成“0任务”先自我纠错并重查一次不要直接 ask_user。
6. 不要连续两轮调用“同一读工具 + 等价 arguments”若上一轮已成功返回下一轮必须换工具或进入 confirm。
7. list_tasks.category 只接受任务类名称,不接受 task_class_ids如 "1,2,3")。
8. 若已明确“本轮先收口”,不要继续调用 list_tasks/find_first_free/move 做无目标微调。
8. 若已明确“本轮先收口”,不要继续调用 list_tasks/query_available_slots/move 做无目标微调。
9. 若用户明确了微调方向,不要只做“局部看起来更空”的随机调整;每次改动都要能对应到该方向。
10. 若顺序策略为“保持顺序”,禁止调用 min_context_switch。
11. 不要在同一轮构造大规模 batch_movebatch_move 最多 2 条,超过请走队列逐项处理。
12. 未调用 queue_pop_head 获取 current 前,不要调用 queue_apply_head_move。
执行规则:
1. 只输出严格 JSON不要输出 markdown不要在 JSON 外补充文本。
@@ -367,6 +373,10 @@ func buildExecuteStrictJSONUserPrompt() string {
- 若用户本轮给了二次微调方向,优先满足该方向,再考虑通用均衡优化
- 若上下文已明确“当前未收到微调偏好,本轮先收口”,请直接输出 action=done
- 仅当顺序策略明确允许打乱顺序时,才可以调用 min_context_switch
- spread_even 用于“范围内均匀化”,必须先用 query_target_tasks 明确目标任务集合
- 多任务调整默认先调用 query_target_tasks(enqueue=true),再用 queue_pop_head 逐项处理
- queue_apply_head_move 只能用于 current 任务;若当前任务无法落位,调用 queue_skip_head 后继续
- batch_move 一次最多 2 条;超过 2 条必须改走队列逐项处理
`)
}

View File

@@ -21,7 +21,7 @@ const (
// executeTrimmedObservationText 是重复工具压缩后的 observation 占位文案。
// 当同工具在窗口内出现多次时,只保留最新一条真实结果,其余旧结果统一替换为该文案。
executeTrimmedObservationText = "当前工具调用结果过于久远,已经被删除。"
executeTrimmedObservationText = "当前工具调用结果已经被使用过,当前无需使用,为节省上下文空间,已折叠"
)
type executeToolSchemaDoc struct {
@@ -159,20 +159,23 @@ func buildExecuteMessage3(state *newagentmodel.CommonState, ctx *newagentmodel.C
"- 当前模式:"+modeText,
)
goal := extractExecuteInitialGoal(ctx)
if goal == "" {
goal = "暂无可用目标描述,请按当前上下文稳步推进。"
initialGoal, currentGoal := extractExecuteGoalAnchors(ctx)
if currentGoal == "" {
currentGoal = "暂无可用目标描述,请按当前上下文稳步推进。"
}
lines = append(lines, "执行锚点:")
lines = append(lines, "- 初始用户目标"+goal)
lines = append(lines, "- 当前用户诉求"+currentGoal)
if initialGoal != "" && initialGoal != currentGoal {
lines = append(lines, "- 首轮目标来源:"+initialGoal)
}
if taskClassText := renderExecuteTaskClassIDs(state); taskClassText != "" {
lines = append(lines, "- 目标任务类:"+taskClassText)
}
lines = append(lines, "- 啥时候结束Loop你可以根据工具调用记录自行判断。")
lines = append(lines, "- 非目标:不重新粗排、不修改无关任务类。")
if hasExecuteRoughBuildDone(ctx) {
lines = append(lines, "- 阶段约束:粗排已完成,本轮只微调 suggestedexisting 仅作已安排事实参考,不做 move/batch_move。")
lines = append(lines, "- 阶段约束:粗排已完成,本轮只微调 suggestedexisting 仅作已安排事实参考,不做 move/batch_move/spread_even。")
}
if state != nil {
if state.AllowReorder {
@@ -230,7 +233,7 @@ func renderExecuteToolCatalogCompact(ctx *newagentmodel.ConversationContext) str
// renderExecuteToolReturnHint 返回工具的“返回类型 + 最小示例”。
//
// 说明:
// 1. 所有工具当前都返回 string(自然语言),这里主要补“内容形态示例”,减少模型盲猜;
// 1. 所有工具当前都返回 string但部分是“JSON 字符串”,这里补齐内容形态示例减少模型盲猜;
// 2. 示例只保留最小片段,避免工具说明过长挤占上下文窗口。
func renderExecuteToolReturnHint(toolName string) (returnType string, sample string) {
returnType = "string自然语言文本"
@@ -241,8 +244,18 @@ func renderExecuteToolReturnHint(toolName string) (returnType string, sample str
return returnType, "已预排任务共24个 [35]第一章随机事件与概率 — 已预排至 第3天第5-6节..."
case "get_task_info":
return returnType, "[35]第一章随机事件与概率 | 状态:已预排(suggested) | 占用时段第3天第5-6节"
case "find_first_free":
return returnType, "首个可用位置第5天第1-2节可直接放置| 当日负载总占6/12..."
case "query_available_slots":
return "stringJSON字符串", `{"tool":"query_available_slots","count":12,"strict_count":8,"embedded_count":4,"slots":[{"day":5,"week":12,"day_of_week":3,"slot_start":1,"slot_end":2,"slot_type":"empty"}]}`
case "query_target_tasks":
return "stringJSON字符串", `{"tool":"query_target_tasks","count":6,"status":"suggested","enqueue":true,"enqueued":6,"queue":{"pending_count":6},"items":[{"task_id":35,"name":"示例任务","status":"suggested","slots":[{"day":3,"week":12,"day_of_week":1,"slot_start":5,"slot_end":6}]}]}`
case "queue_pop_head":
return "stringJSON字符串", `{"tool":"queue_pop_head","has_head":true,"pending_count":5,"current":{"task_id":35,"name":"示例任务","status":"suggested","slots":[{"day":3,"week":12,"day_of_week":1,"slot_start":5,"slot_end":6}]}}`
case "queue_status":
return "stringJSON字符串", `{"tool":"queue_status","pending_count":5,"completed_count":1,"skipped_count":0,"current_task_id":35,"current_attempt":1}`
case "queue_apply_head_move":
return "stringJSON字符串", `{"tool":"queue_apply_head_move","success":true,"task_id":35,"pending_count":4,"completed_count":2,"result":"已将 [35]... 从第3天第5-6节移至第5天第3-4节。"}`
case "queue_skip_head":
return "stringJSON字符串", `{"tool":"queue_skip_head","success":true,"skipped_task_id":35,"pending_count":4,"skipped_count":1}`
case "query_range":
return returnType, "第5天第3-6节第3节空、第4节空..."
case "place":
@@ -252,7 +265,9 @@ func renderExecuteToolReturnHint(toolName string) (returnType string, sample str
case "swap":
return returnType, "交换完成:[35]... ↔ [36]..."
case "batch_move":
return returnType, "批量移动完成2个任务全部成功。"
return returnType, "批量移动完成2个任务全部成功。单次最多2条"
case "spread_even":
return returnType, "均匀化调整完成:共处理 6 个任务,候选坑位 24 个。"
case "min_context_switch":
return returnType, "最少上下文切换重排完成:共处理 6 个任务,上下文切换次数 5 -> 2。"
case "unplace":
@@ -496,13 +511,13 @@ func buildEarlyExecuteReactSummary(records []executeLoopRecord, windowLimit int)
return fmt.Sprintf("已折叠 %d 条旧记录,涉及:%s。", len(early), strings.Join(parts, "、"))
}
func extractExecuteInitialGoal(ctx *newagentmodel.ConversationContext) string {
func extractExecuteGoalAnchors(ctx *newagentmodel.ConversationContext) (initial string, current string) {
if ctx == nil {
return ""
return "", ""
}
history := ctx.HistorySnapshot()
firstUser, _ := pickExecuteUserInputs(history)
return firstUser
firstUser, lastUser := pickExecuteUserInputs(history)
return firstUser, lastUser
}
func hasExecuteRoughBuildDone(ctx *newagentmodel.ConversationContext) bool {

View File

@@ -27,7 +27,7 @@ const planSystemPrompt = `
条件2用户意图明确是"批量安排/帮我排课/把任务类排进日程"等批量调度需求。
满足时:后端会在用户确认计划后自动运行粗排算法(硬性约束已由算法保证,无需 LLM 校验)。
你的 plan_steps 应聚焦于"用读写工具优化方案",建议两步:
第1步用 get_overview / find_first_free 等读工具审视粗排结果,找出可优化的点(时段分布不均、空位未利用等);
第1步用 get_overview / query_target_tasks / query_available_slots 等读工具审视粗排结果,找出可优化的点(时段分布不均、空位未利用等);
第2步用 move / batch_move 等写工具微调后,将最终方案展示给用户确认。
禁止安排任何"校验/验证约束"步骤——硬性约束由算法兜底LLM 不需要操心。

View File

@@ -251,32 +251,54 @@ DB 记录:
---
### 4.3 find_first_free
### 4.3 query_available_slots
按天顺序查找“首个可用位”(先纯空位,再可嵌入位),并返回该日详细信息
查询候选坑位池(结构化返回):默认先返回“纯空位”,不足时再补“可嵌入位”
**入参:**
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| duration | int | | 需要的连续时段 |
| day | int | 否 | 限定某天;与 `day_start/day_end` 互斥 |
| day_start | int | 否 | 搜索起始天(闭区间) |
| day_end | int | 否 | 搜索结束天(闭区间 |
| span / duration | int | | 目标连续时段长度,默认 2 |
| limit | int | 否 | 返回候选上限,默认 12 |
| allow_embed | bool | 否 | 是否允许补可嵌入位,默认 true |
| day / day_start / day_end | int | 否 | 天级范围过滤(`day` 与区间互斥 |
| day_scope | string | 否 | `all` / `workday` / `weekend` |
| day_of_week | []int | 否 | 星期过滤1-7 |
| week / week_filter / week_from / week_to | int / []int | 否 | 周级过滤 |
| slot_type / slot_types | string / []string | 否 | `pure/empty/strict` 会强制只返回纯空位 |
| exclude_sections | []int | 否 | 排除节次1-12 |
| after_section / before_section | int | 否 | 只返回区间之后/之前的候选 |
| section_from + section_to | int | 否 | 精确节次区间查询(需同时提供) |
**返回示例:**
```
首个可用位置第5天第1-2节可直接放置
匹配条件需要2个连续时段。
当日负载总占6/12课程占2/12任务占4/12
当日任务明细(全量,已过滤课程):
- [35]第一章随机事件与概率 | 状态:suggested | 类别:概率论 | 时段:第3-4节
- [36]第二章随机变量 | 状态:suggested | 类别:概率论 | 时段:第7-8节
当日连续空闲区:
- 第1-2节2时段连续空闲
- 第5-6节2时段连续空闲
- 第9-12节4时段连续空闲
```json
{
"tool": "query_available_slots",
"count": 12,
"strict_count": 8,
"embedded_count": 4,
"fallback_used": true,
"day_scope": "all",
"day_of_week": [],
"week_filter": [12],
"week_from": 12,
"week_to": 12,
"span": 2,
"allow_embed": true,
"exclude_sections": [],
"slots": [
{
"day": 5,
"week": 12,
"day_of_week": 3,
"slot_start": 1,
"slot_end": 2,
"slot_type": "empty"
}
]
}
```
---
@@ -487,7 +509,7 @@ DB 记录:
### 5.4 batch_move
批量原子移动多个任务(仅 suggested要么全部成功要么全部回滚。
批量原子移动多个任务(仅 suggested**单次最多 2 条**),要么全部成功,要么全部回滚。
**入参:**
@@ -498,13 +520,17 @@ DB 记录:
**成功返回:**
```
批量移动完成,3个任务全部成功:
批量移动完成,2个任务全部成功:
[2]英语 → 第3天第1-2节
[6]线代 → 第5天第3-4节
[8]程序设计 → 第9天第5-6节
第3天当前占用[2]英语(1-2节)占用2/12。
第5天当前占用[6]线代(3-4节)占用2/12。
第9天当前占用[8]程序设计(5-6节)占用2/12。
```
**失败返回(超出上限):**
```
批量移动失败:当前最多支持 2 条移动请求。请改用队列化逐项处理queue_pop_head + queue_apply_head_move
```
**失败返回:**
@@ -578,6 +604,114 @@ DB 记录:
---
### 5.7 queue_pop_head
弹出并返回当前队首任务;若已有 current 则复用,保证一次只处理一个任务。
**入参:**
**返回示例:**
```json
{"tool":"queue_pop_head","has_head":true,"pending_count":5,"current":{"task_id":35,"name":"示例任务","status":"suggested","slots":[{"day":3,"week":12,"day_of_week":1,"slot_start":5,"slot_end":6}]}}
```
---
### 5.8 queue_apply_head_move
将当前队首任务移动到指定位置并自动出队。仅作用于 current不接受 task_id。
**入参:**
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| new_day | int | 是 | 目标 day |
| new_slot_start | int | 是 | 目标起始节次 |
**返回示例:**
```json
{"tool":"queue_apply_head_move","success":true,"task_id":35,"pending_count":4,"completed_count":2,"result":"已将 [35]... 从第3天第5-6节移至第5天第3-4节。"}
```
---
### 5.9 queue_skip_head
跳过当前队首任务(不改日程),标记为 skipped 并继续后续队列。
**入参:**
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| reason | string | 否 | 跳过原因 |
**返回示例:**
```json
{"tool":"queue_skip_head","success":true,"skipped_task_id":35,"pending_count":4,"skipped_count":1}
```
---
### 5.10 queue_status
查看当前待处理队列状态pending/current/completed/skipped
**入参:**
**返回示例:**
```json
{"tool":"queue_status","pending_count":5,"completed_count":1,"skipped_count":0,"current_task_id":35,"current_attempt":1}
```
---
### 5.11 spread_even
在给定任务集合内执行“均匀化铺开”:
先按筛选条件收集候选坑位,再用确定性规划器生成移动方案并原子提交。
**入参:**
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| task_ids | array[int] | 是 | 参与均匀化的任务 ID 列表(至少 2 个) |
| task_id | int | 否 | 兼容单值参数,不建议新调用使用 |
| day/day_start/day_end | int | 否 | 天级范围过滤 |
| day_scope | string | 否 | `all` / `workday` / `weekend` |
| day_of_week | array[int] | 否 | 星期过滤1~7 |
| week/week_filter/week_from/week_to | int/array[int] | 否 | 周级过滤 |
| limit | int | 否 | 每个跨度的候选坑位上限(内部会按任务数自动放大) |
| allow_embed | bool | 否 | 是否允许补充可嵌入位,默认 true |
| exclude_sections | array[int] | 否 | 排除节次 |
| after_section/before_section | int | 否 | 节次边界过滤 |
**成功返回:**
```
均匀化调整完成:共处理 6 个任务,候选坑位 24 个。
本次调整:
[35]第一章复习第3天(星期3)第5-6节 -> 第5天(星期5)第1-2节
[41]第二章练习第4天(星期4)第5-6节 -> 第6天(星期6)第1-2节
第5天当前占用...
第6天当前占用...
```
**失败返回(候选不足):**
```
均匀化调整失败:跨度=2 可用坑位不足required=4, got=2。
```
---
## 6. 公共规则
### 冲突检测
@@ -591,8 +725,8 @@ DB 记录:
### 状态约束
- pending 任务只能 place不能 move / swap / unplace
- suggested 任务可以 move / swap / unplace / min_context_switch
- existing 任务不能 move / batch_move / min_context_switch仅作已安排事实层
- suggested 任务可以 move / swap / unplace / spread_even / min_context_switch
- existing 任务不能 move / batch_move / spread_even / min_context_switch仅作已安排事实层
- 状态不符时返回明确错误信息
### 返回格式
@@ -609,7 +743,7 @@ DB 记录:
### 嵌入任务规则
- `can_embed=true` 的任务(水课)允许其他任务嵌入到同一时段
- 嵌入任务占位时不触发冲突检测(与宿主共存)
- `find_first_free` 返回首个命中位,并附当日详细负载
- `query_available_slots` 返回候选坑位池(先纯空位,必要时补可嵌入位)
- `place` 到可嵌入时段时,若已有宿主任务,自动标记 embed_host 关系
- 嵌入任务的 locked 继承宿主:宿主不可移动时,嵌入任务也不可单独移动

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,272 @@
package newagenttools
import (
"encoding/json"
"fmt"
"strings"
)
type queueTaskSlot struct {
Day int `json:"day"`
Week int `json:"week"`
DayOfWeek int `json:"day_of_week"`
SlotStart int `json:"slot_start"`
SlotEnd int `json:"slot_end"`
}
type queueTaskItem struct {
TaskID int `json:"task_id"`
Name string `json:"name"`
Category string `json:"category,omitempty"`
Status string `json:"status"`
Duration int `json:"duration,omitempty"`
TaskClassID int `json:"task_class_id,omitempty"`
Slots []queueTaskSlot `json:"slots,omitempty"`
}
type queuePopHeadResult struct {
Tool string `json:"tool"`
HasHead bool `json:"has_head"`
PendingCount int `json:"pending_count"`
CompletedCount int `json:"completed_count"`
SkippedCount int `json:"skipped_count"`
Current *queueTaskItem `json:"current,omitempty"`
LastError string `json:"last_error,omitempty"`
}
type queueApplyHeadMoveResult struct {
Tool string `json:"tool"`
Success bool `json:"success"`
TaskID int `json:"task_id,omitempty"`
CurrentAttempt int `json:"current_attempt,omitempty"`
PendingCount int `json:"pending_count"`
CompletedCount int `json:"completed_count"`
SkippedCount int `json:"skipped_count"`
Result string `json:"result"`
}
type queueSkipHeadResult struct {
Tool string `json:"tool"`
Success bool `json:"success"`
SkippedTaskID int `json:"skipped_task_id,omitempty"`
PendingCount int `json:"pending_count"`
SkippedCount int `json:"skipped_count"`
Reason string `json:"reason,omitempty"`
}
type queueStatusResult struct {
Tool string `json:"tool"`
PendingCount int `json:"pending_count"`
CompletedCount int `json:"completed_count"`
SkippedCount int `json:"skipped_count"`
CurrentTaskID int `json:"current_task_id,omitempty"`
CurrentAttempt int `json:"current_attempt,omitempty"`
LastError string `json:"last_error,omitempty"`
NextTaskIDs []int `json:"next_task_ids,omitempty"`
Current *queueTaskItem `json:"current,omitempty"`
}
// QueuePopHead 从队列弹出队首任务(若已有 current 则复用),并返回当前处理对象。
//
// 步骤化说明:
// 1. 先保证队列容器存在,避免空指针;
// 2. 若 current 已存在,直接复用,确保 apply/skip 前不会切换处理对象;
// 3. 若 current 为空则从 pending 弹出队首;
// 4. 若没有可处理任务,返回 has_head=false由 LLM 收口或重筛选。
func QueuePopHead(state *ScheduleState, _ map[string]any) string {
if state == nil {
return `{"tool":"queue_pop_head","has_head":false,"error":"state is nil"}`
}
queue := ensureTaskProcessingQueue(state)
taskID := popOrGetCurrentTaskID(state)
result := queuePopHeadResult{
Tool: "queue_pop_head",
HasHead: taskID > 0,
PendingCount: len(queue.PendingTaskIDs),
CompletedCount: len(queue.CompletedTaskIDs),
SkippedCount: len(queue.SkippedTaskIDs),
LastError: strings.TrimSpace(queue.LastError),
}
if taskID > 0 {
result.Current = buildQueueTaskItem(state, taskID)
}
return mustJSON(result, "queue_pop_head")
}
// QueueApplyHeadMove 将当前队首任务移动到指定位置,成功后自动完成并出队。
//
// 步骤化说明:
// 1. 只能处理 current 任务,禁止越级指定 task_id避免 LLM 绕过队列直接乱改;
// 2. 成功时标记 completed 并清空 current
// 3. 失败时保留 current 并累加 attempt让 LLM 继续换坑位重试或 skip。
func QueueApplyHeadMove(state *ScheduleState, args map[string]any) string {
if state == nil {
return `{"tool":"queue_apply_head_move","success":false,"result":"state is nil"}`
}
queue := ensureTaskProcessingQueue(state)
currentID := queue.CurrentTaskID
if currentID <= 0 {
return mustJSON(queueApplyHeadMoveResult{
Tool: "queue_apply_head_move",
Success: false,
PendingCount: len(queue.PendingTaskIDs),
CompletedCount: len(queue.CompletedTaskIDs),
SkippedCount: len(queue.SkippedTaskIDs),
Result: "队列中没有正在处理的任务。请先调用 queue_pop_head。",
}, "queue_apply_head_move")
}
newDay, ok := argsInt(args, "new_day")
if !ok {
return mustJSON(queueApplyHeadMoveResult{
Tool: "queue_apply_head_move",
Success: false,
TaskID: currentID,
CurrentAttempt: queue.CurrentAttempts,
PendingCount: len(queue.PendingTaskIDs),
CompletedCount: len(queue.CompletedTaskIDs),
SkippedCount: len(queue.SkippedTaskIDs),
Result: "缺少必填参数 new_day。",
}, "queue_apply_head_move")
}
newSlotStart, ok := argsInt(args, "new_slot_start")
if !ok {
return mustJSON(queueApplyHeadMoveResult{
Tool: "queue_apply_head_move",
Success: false,
TaskID: currentID,
CurrentAttempt: queue.CurrentAttempts,
PendingCount: len(queue.PendingTaskIDs),
CompletedCount: len(queue.CompletedTaskIDs),
SkippedCount: len(queue.SkippedTaskIDs),
Result: "缺少必填参数 new_slot_start。",
}, "queue_apply_head_move")
}
// 1. 真正执行仍复用既有 move 校验链路,避免重复实现一套冲突判断。
// 2. 失败时仅更新队列 attempt不改 current确保同一任务可继续重试。
resultText := Move(state, currentID, newDay, newSlotStart)
success := !strings.Contains(resultText, "移动失败")
if success {
markCurrentTaskCompleted(state)
} else {
bumpCurrentTaskAttempt(state, resultText)
}
queue = ensureTaskProcessingQueue(state)
return mustJSON(queueApplyHeadMoveResult{
Tool: "queue_apply_head_move",
Success: success,
TaskID: currentID,
CurrentAttempt: queue.CurrentAttempts,
PendingCount: len(queue.PendingTaskIDs),
CompletedCount: len(queue.CompletedTaskIDs),
SkippedCount: len(queue.SkippedTaskIDs),
Result: strings.TrimSpace(resultText),
}, "queue_apply_head_move")
}
// QueueSkipHead 跳过当前队首任务。
//
// 职责边界:
// 1. 只修改队列运行态,不改排程结果;
// 2. current 必须存在,否则返回失败提示;
// 3. 跳过后由下一轮 queue_pop_head 继续取下一项。
func QueueSkipHead(state *ScheduleState, args map[string]any) string {
if state == nil {
return `{"tool":"queue_skip_head","success":false,"reason":"state is nil"}`
}
queue := ensureTaskProcessingQueue(state)
currentID := queue.CurrentTaskID
if currentID <= 0 {
return mustJSON(queueSkipHeadResult{
Tool: "queue_skip_head",
Success: false,
PendingCount: len(queue.PendingTaskIDs),
SkippedCount: len(queue.SkippedTaskIDs),
Reason: "没有可跳过的 current 任务,请先 queue_pop_head。",
}, "queue_skip_head")
}
reason := ""
if raw, ok := argsString(args, "reason"); ok {
reason = strings.TrimSpace(raw)
}
markCurrentTaskSkipped(state)
queue = ensureTaskProcessingQueue(state)
return mustJSON(queueSkipHeadResult{
Tool: "queue_skip_head",
Success: true,
SkippedTaskID: currentID,
PendingCount: len(queue.PendingTaskIDs),
SkippedCount: len(queue.SkippedTaskIDs),
Reason: reason,
}, "queue_skip_head")
}
// QueueStatus 查询当前队列状态。
func QueueStatus(state *ScheduleState, _ map[string]any) string {
if state == nil {
return `{"tool":"queue_status","pending_count":0,"completed_count":0,"skipped_count":0,"last_error":"state is nil"}`
}
queue := ensureTaskProcessingQueue(state)
nextIDs := queue.PendingTaskIDs
if len(nextIDs) > 5 {
nextIDs = nextIDs[:5]
}
result := queueStatusResult{
Tool: "queue_status",
PendingCount: len(queue.PendingTaskIDs),
CompletedCount: len(queue.CompletedTaskIDs),
SkippedCount: len(queue.SkippedTaskIDs),
CurrentTaskID: queue.CurrentTaskID,
CurrentAttempt: queue.CurrentAttempts,
LastError: strings.TrimSpace(queue.LastError),
NextTaskIDs: append([]int(nil), nextIDs...),
}
if queue.CurrentTaskID > 0 {
result.Current = buildQueueTaskItem(state, queue.CurrentTaskID)
}
return mustJSON(result, "queue_status")
}
// buildQueueTaskItem 构造队列任务快照,供 pop/status 返回。
func buildQueueTaskItem(state *ScheduleState, taskID int) *queueTaskItem {
task := state.TaskByStateID(taskID)
if task == nil {
return nil
}
item := &queueTaskItem{
TaskID: task.StateID,
Name: strings.TrimSpace(task.Name),
Category: strings.TrimSpace(task.Category),
Status: buildTaskStatusLabel(*task),
Duration: task.Duration,
TaskClassID: task.TaskClassID,
Slots: make([]queueTaskSlot, 0, len(task.Slots)),
}
for _, slot := range task.Slots {
week, dayOfWeek, ok := state.DayToWeekDay(slot.Day)
if !ok {
continue
}
item.Slots = append(item.Slots, queueTaskSlot{
Day: slot.Day,
Week: week,
DayOfWeek: dayOfWeek,
SlotStart: slot.SlotStart,
SlotEnd: slot.SlotEnd,
})
}
return item
}
func mustJSON(v any, toolName string) string {
raw, err := json.Marshal(v)
if err != nil {
return fmt.Sprintf(`{"tool":"%s","success":false,"error":"json encode failed"}`, toolName)
}
return string(raw)
}

View File

@@ -0,0 +1,931 @@
package newagenttools
import (
"encoding/json"
"fmt"
"sort"
"strings"
)
// queryAvailableSlotsResult 描述 query_available_slots 的结构化返回。
type queryAvailableSlotsResult struct {
Tool string `json:"tool"`
Count int `json:"count"`
StrictCount int `json:"strict_count"`
EmbeddedCount int `json:"embedded_count"`
FallbackUsed bool `json:"fallback_used"`
DayScope string `json:"day_scope"`
DayOfWeek []int `json:"day_of_week"`
WeekFilter []int `json:"week_filter"`
WeekFrom int `json:"week_from"`
WeekTo int `json:"week_to"`
Span int `json:"span"`
AllowEmbed bool `json:"allow_embed"`
ExcludeSections []int `json:"exclude_sections"`
Slots []queryAvailableSlotItem `json:"slots"`
}
// queryAvailableSlotItem 描述单个候选坑位。
type queryAvailableSlotItem struct {
Day int `json:"day"`
Week int `json:"week"`
DayOfWeek int `json:"day_of_week"`
SlotStart int `json:"slot_start"`
SlotEnd int `json:"slot_end"`
SlotType string `json:"slot_type,omitempty"`
}
// queryTargetTasksResult 描述 query_target_tasks 的结构化返回。
type queryTargetTasksResult struct {
Tool string `json:"tool"`
Count int `json:"count"`
Status string `json:"status"`
DayScope string `json:"day_scope"`
DayOfWeek []int `json:"day_of_week"`
WeekFilter []int `json:"week_filter"`
WeekFrom int `json:"week_from"`
WeekTo int `json:"week_to"`
Enqueue bool `json:"enqueue"`
Enqueued int `json:"enqueued"`
Queue *queryTargetQueueInfo `json:"queue,omitempty"`
Items []queryTargetTaskItem `json:"items"`
}
// queryTargetQueueInfo 描述 query_target_tasks 入队后的队列摘要。
type queryTargetQueueInfo struct {
PendingCount int `json:"pending_count"`
CompletedCount int `json:"completed_count"`
SkippedCount int `json:"skipped_count"`
CurrentTaskID int `json:"current_task_id,omitempty"`
CurrentAttempt int `json:"current_attempt,omitempty"`
}
// queryTargetTaskItem 描述候选任务。
type queryTargetTaskItem struct {
TaskID int `json:"task_id"`
Name string `json:"name"`
Category string `json:"category,omitempty"`
Status string `json:"status"`
Duration int `json:"duration,omitempty"`
TaskClassID int `json:"task_class_id,omitempty"`
Slots []queryTargetTaskSlot `json:"slots,omitempty"`
}
// queryTargetTaskSlot 描述任务在工具状态中的坐标。
type queryTargetTaskSlot struct {
Day int `json:"day"`
Week int `json:"week"`
DayOfWeek int `json:"day_of_week"`
SlotStart int `json:"slot_start"`
SlotEnd int `json:"slot_end"`
}
// queryAvailableOptions 是 query_available_slots 的参数快照。
type queryAvailableOptions struct {
DayScope string
DayOfWeekSet map[int]struct{}
WeekSet map[int]struct{}
WeekFrom int
WeekTo int
Span int
Limit int
AllowEmbed bool
ExcludedSection map[int]struct{}
AfterSection *int
BeforeSection *int
ExactFrom *int
ExactTo *int
}
// queryTargetOptions 是 query_target_tasks 的参数快照。
type queryTargetOptions struct {
DayScope string
DayOfWeekSet map[int]struct{}
WeekSet map[int]struct{}
WeekFrom int
WeekTo int
Status string
Limit int
TaskIDSet map[int]struct{}
Category string
Enqueue bool
ResetQueue bool
}
// QueryAvailableSlots 返回“候选坑位池”。
//
// 职责边界:
// 1. 只负责读状态并返回结构化 JSON不做任何写入
// 2. 优先返回纯空位strict不足时再补可嵌入位embedded
// 3. 不负责移动策略决策,最终落点由模型结合目标再选择。
func QueryAvailableSlots(state *ScheduleState, args map[string]any) string {
// 1. 解析参数并做合法性校验。
options, err := parseQueryAvailableOptions(state, args)
if err != nil {
return fmt.Sprintf(`{"tool":"query_available_slots","success":false,"error":"%s"}`, err.Error())
}
// 2. 解析“可迭代天集合”:先解析 day/day_start/day_end再叠加 week/day_scope/day_of_week 过滤。
candidateDays, err := resolveCandidateDays(state, args, options.DayScope, options.DayOfWeekSet, options.WeekSet, options.WeekFrom, options.WeekTo)
if err != nil {
return fmt.Sprintf(`{"tool":"query_available_slots","success":false,"error":"%s"}`, err.Error())
}
// 3. 两阶段收集:
// 3.1 先收集 strict纯空位保证“先空位后嵌入”的默认策略
// 3.2 strict 不足 limit 时,再补 embed 候选(仅在 allow_embed=true 时)。
slots := make([]queryAvailableSlotItem, 0, options.Limit)
seen := make(map[string]struct{}, options.Limit*2)
collect := func(embedAllowed bool, slotType string) {
if len(slots) >= options.Limit {
return
}
for _, day := range candidateDays {
week, dayOfWeek, ok := state.DayToWeekDay(day)
if !ok {
continue
}
for slotStart := 1; slotStart+options.Span-1 <= 12; slotStart++ {
slotEnd := slotStart + options.Span - 1
if !matchSectionRange(slotStart, slotEnd, options.ExcludedSection, options.AfterSection, options.BeforeSection, options.ExactFrom, options.ExactTo) {
continue
}
accepted := false
if !embedAllowed {
accepted = isStrictSlotAvailable(state, day, slotStart, slotEnd)
} else {
accepted = isEmbeddableSlotAvailable(state, day, slotStart, slotEnd)
}
if !accepted {
continue
}
key := fmt.Sprintf("%d-%d-%d", day, slotStart, slotEnd)
if _, exists := seen[key]; exists {
continue
}
seen[key] = struct{}{}
slots = append(slots, queryAvailableSlotItem{
Day: day,
Week: week,
DayOfWeek: dayOfWeek,
SlotStart: slotStart,
SlotEnd: slotEnd,
SlotType: slotType,
})
if len(slots) >= options.Limit {
return
}
}
}
}
collect(false, "empty")
strictCount := len(slots)
if options.AllowEmbed && len(slots) < options.Limit {
collect(true, "embedded_candidate")
}
embeddedCount := len(slots) - strictCount
// 4. 组装结构化返回JSON 字符串)。
result := queryAvailableSlotsResult{
Tool: "query_available_slots",
Count: len(slots),
StrictCount: strictCount,
EmbeddedCount: embeddedCount,
FallbackUsed: embeddedCount > 0,
DayScope: options.DayScope,
DayOfWeek: sortedSetKeys(options.DayOfWeekSet),
WeekFilter: sortedSetKeys(options.WeekSet),
WeekFrom: options.WeekFrom,
WeekTo: options.WeekTo,
Span: options.Span,
AllowEmbed: options.AllowEmbed,
ExcludeSections: sortedSetKeys(options.ExcludedSection),
Slots: slots,
}
raw, err := json.Marshal(result)
if err != nil {
return `{"tool":"query_available_slots","success":false,"error":"query encode failed"}`
}
return string(raw)
}
// QueryTargetTasks 返回“候选任务集合”。
//
// 职责边界:
// 1. 只做筛选与结构化返回,不直接执行 move/swap
// 2. 默认 status=suggested减少模型误选 existing/pending
// 3. 仅返回状态事实,不做“该不该移动”的语义判断。
func QueryTargetTasks(state *ScheduleState, args map[string]any) string {
// 1. 解析参数。
options, err := parseQueryTargetOptions(state, args)
if err != nil {
return fmt.Sprintf(`{"tool":"query_target_tasks","success":false,"error":"%s"}`, err.Error())
}
// 2. 解析“可迭代天集合”过滤器。
candidateDays, err := resolveCandidateDays(state, args, options.DayScope, options.DayOfWeekSet, options.WeekSet, options.WeekFrom, options.WeekTo)
if err != nil {
return fmt.Sprintf(`{"tool":"query_target_tasks","success":false,"error":"%s"}`, err.Error())
}
calendarFilterActive := isQueryTargetCalendarFilterActive(args, options)
daySet := make(map[int]struct{}, len(candidateDays))
for _, d := range candidateDays {
daySet[d] = struct{}{}
}
// 3. 扫描任务并按筛选条件收敛。
items := make([]queryTargetTaskItem, 0, options.Limit)
for i := range state.Tasks {
task := state.Tasks[i]
if !matchTaskStatus(task, options.Status) {
continue
}
if len(options.TaskIDSet) > 0 {
if _, ok := options.TaskIDSet[task.StateID]; !ok {
continue
}
}
if options.Category != "" && task.Category != options.Category {
continue
}
taskSlots := make([]queryTargetTaskSlot, 0, len(task.Slots))
for _, slot := range task.Slots {
week, dayOfWeek, ok := state.DayToWeekDay(slot.Day)
if !ok {
continue
}
// 3.1 若存在日历过滤条件,只保留命中过滤的坐标。
if calendarFilterActive && len(daySet) > 0 {
if _, hit := daySet[slot.Day]; !hit {
continue
}
}
taskSlots = append(taskSlots, queryTargetTaskSlot{
Day: slot.Day,
Week: week,
DayOfWeek: dayOfWeek,
SlotStart: slot.SlotStart,
SlotEnd: slot.SlotEnd,
})
}
// 3.2 pending 任务默认无 slots当存在日历过滤条件时不应混入“未知坐标任务”。
if len(taskSlots) == 0 && calendarFilterActive {
continue
}
sort.Slice(taskSlots, func(i, j int) bool {
if taskSlots[i].Day != taskSlots[j].Day {
return taskSlots[i].Day < taskSlots[j].Day
}
if taskSlots[i].SlotStart != taskSlots[j].SlotStart {
return taskSlots[i].SlotStart < taskSlots[j].SlotStart
}
return taskSlots[i].SlotEnd < taskSlots[j].SlotEnd
})
items = append(items, queryTargetTaskItem{
TaskID: task.StateID,
Name: strings.TrimSpace(task.Name),
Category: strings.TrimSpace(task.Category),
Status: buildTaskStatusLabel(task),
Duration: task.Duration,
TaskClassID: task.TaskClassID,
Slots: taskSlots,
})
}
// 4. 稳定排序:先按最早坐标,再按 task_id。
sort.Slice(items, func(i, j int) bool {
leftHasSlot := len(items[i].Slots) > 0
rightHasSlot := len(items[j].Slots) > 0
if leftHasSlot != rightHasSlot {
return leftHasSlot
}
if leftHasSlot {
left := items[i].Slots[0]
right := items[j].Slots[0]
if left.Day != right.Day {
return left.Day < right.Day
}
if left.SlotStart != right.SlotStart {
return left.SlotStart < right.SlotStart
}
}
return items[i].TaskID < items[j].TaskID
})
if len(items) > options.Limit {
items = items[:options.Limit]
}
// 5. 队列化(可选):将筛选结果自动纳入“待处理队列”。
//
// 步骤化说明:
// 1. 默认 enqueue=true让 LLM 优先走“逐项处理”而不是一次性批量组合;
// 2. reset_queue=true 时会清空旧队列后再入队,适合开启新一轮筛选;
// 3. 入队仅保存 task_id不复制任务全文避免队列状态膨胀。
queueInfo := (*queryTargetQueueInfo)(nil)
enqueued := 0
if options.Enqueue {
taskIDs := make([]int, 0, len(items))
for _, item := range items {
taskIDs = append(taskIDs, item.TaskID)
}
if options.ResetQueue {
enqueued = ReplaceTaskProcessingQueue(state, taskIDs)
} else {
enqueued = appendTaskIDsToQueue(state, taskIDs)
}
queue := ensureTaskProcessingQueue(state)
queueInfo = &queryTargetQueueInfo{
PendingCount: len(queue.PendingTaskIDs),
CompletedCount: len(queue.CompletedTaskIDs),
SkippedCount: len(queue.SkippedTaskIDs),
CurrentTaskID: queue.CurrentTaskID,
CurrentAttempt: queue.CurrentAttempts,
}
}
// 6. 结构化返回。
result := queryTargetTasksResult{
Tool: "query_target_tasks",
Count: len(items),
Status: options.Status,
DayScope: options.DayScope,
DayOfWeek: sortedSetKeys(options.DayOfWeekSet),
WeekFilter: sortedSetKeys(options.WeekSet),
WeekFrom: options.WeekFrom,
WeekTo: options.WeekTo,
Enqueue: options.Enqueue,
Enqueued: enqueued,
Queue: queueInfo,
Items: items,
}
raw, err := json.Marshal(result)
if err != nil {
return `{"tool":"query_target_tasks","success":false,"error":"query encode failed"}`
}
return string(raw)
}
// parseQueryAvailableOptions 解析 query_available_slots 参数。
func parseQueryAvailableOptions(state *ScheduleState, args map[string]any) (queryAvailableOptions, error) {
scope := normalizeDayScope(readStringAny(args, "day_scope", "all"))
allowEmbed := readBoolAnyWithDefault(args, true, "allow_embed", "allow_embedding")
slotTypeHints := readStringSliceAny(args, "slot_types")
if single := strings.TrimSpace(readStringAny(args, "slot_type", "")); single != "" {
slotTypeHints = append(slotTypeHints, single)
}
for _, hint := range slotTypeHints {
normalized := strings.ToLower(strings.TrimSpace(hint))
if normalized == "pure" || normalized == "empty" || normalized == "strict" {
allowEmbed = false
break
}
}
span, ok := readIntAny(args, "span", "section_duration", "task_duration", "duration")
if !ok || span <= 0 {
span = 2
}
if span > 12 {
return queryAvailableOptions{}, fmt.Errorf("span=%d 非法,必须在 1~12", span)
}
limit, ok := readIntAny(args, "limit")
if !ok || limit <= 0 {
limit = 12
}
weekSet := intSliceToSet(readIntSliceAny(args, "week_filter", "weeks"))
weekFrom, hasWeekFrom := readIntAny(args, "week_from", "from_week")
weekTo, hasWeekTo := readIntAny(args, "week_to", "to_week")
if week, hasWeek := readIntAny(args, "week"); hasWeek {
weekFrom, weekTo = week, week
hasWeekFrom, hasWeekTo = true, true
}
if hasWeekFrom && hasWeekTo && weekFrom > weekTo {
weekFrom, weekTo = weekTo, weekFrom
}
defaultWeekFrom, defaultWeekTo := inferWeekBounds(state)
if !hasWeekFrom {
weekFrom = defaultWeekFrom
}
if !hasWeekTo {
weekTo = defaultWeekTo
}
excluded := intSliceToSet(readIntSliceAny(args, "exclude_sections", "exclude_section"))
afterSection, hasAfter := readIntAny(args, "after_section")
beforeSection, hasBefore := readIntAny(args, "before_section")
exactFrom, hasExactFrom := readIntAny(args, "section_from", "target_section_from")
exactTo, hasExactTo := readIntAny(args, "section_to", "target_section_to")
if hasExactFrom != hasExactTo {
return queryAvailableOptions{}, fmt.Errorf("精确节次查询需要同时提供 section_from 和 section_to")
}
if hasExactFrom {
if exactFrom < 1 || exactTo > 12 || exactFrom > exactTo {
return queryAvailableOptions{}, fmt.Errorf("精确节次区间非法:%d-%d", exactFrom, exactTo)
}
span = exactTo - exactFrom + 1
}
options := queryAvailableOptions{
DayScope: scope,
DayOfWeekSet: intSliceToSet(readIntSliceAny(args, "day_of_week", "days", "day_filter")),
WeekSet: weekSet,
WeekFrom: weekFrom,
WeekTo: weekTo,
Span: span,
Limit: limit,
AllowEmbed: allowEmbed,
ExcludedSection: excluded,
}
if hasAfter {
options.AfterSection = &afterSection
}
if hasBefore {
options.BeforeSection = &beforeSection
}
if hasExactFrom {
options.ExactFrom = &exactFrom
options.ExactTo = &exactTo
}
return options, nil
}
// parseQueryTargetOptions 解析 query_target_tasks 参数。
func parseQueryTargetOptions(state *ScheduleState, args map[string]any) (queryTargetOptions, error) {
scope := normalizeDayScope(readStringAny(args, "day_scope", "all"))
status := strings.ToLower(strings.TrimSpace(readStringAny(args, "status", "suggested")))
if status == "" {
status = "suggested"
}
switch status {
case "all", "existing", "suggested", "pending":
default:
return queryTargetOptions{}, fmt.Errorf("status=%q 非法,仅支持 all/existing/suggested/pending", status)
}
limit, ok := readIntAny(args, "limit")
if !ok || limit <= 0 {
limit = 16
}
weekSet := intSliceToSet(readIntSliceAny(args, "week_filter", "weeks"))
weekFrom, hasWeekFrom := readIntAny(args, "week_from", "from_week")
weekTo, hasWeekTo := readIntAny(args, "week_to", "to_week")
if week, hasWeek := readIntAny(args, "week"); hasWeek {
weekFrom, weekTo = week, week
hasWeekFrom, hasWeekTo = true, true
}
if hasWeekFrom && hasWeekTo && weekFrom > weekTo {
weekFrom, weekTo = weekTo, weekFrom
}
defaultWeekFrom, defaultWeekTo := inferWeekBounds(state)
if !hasWeekFrom {
weekFrom = defaultWeekFrom
}
if !hasWeekTo {
weekTo = defaultWeekTo
}
taskIDs := readIntSliceAny(args, "task_ids", "task_item_ids")
if singleTaskID, ok := readIntAny(args, "task_id", "task_item_id"); ok {
taskIDs = append(taskIDs, singleTaskID)
}
return queryTargetOptions{
DayScope: scope,
DayOfWeekSet: intSliceToSet(readIntSliceAny(args, "day_of_week", "days", "day_filter")),
WeekSet: weekSet,
WeekFrom: weekFrom,
WeekTo: weekTo,
Status: status,
Limit: limit,
TaskIDSet: intSliceToSet(taskIDs),
Category: strings.TrimSpace(readStringAny(args, "category", "")),
Enqueue: readBoolAnyWithDefault(args, true, "enqueue"),
ResetQueue: readBoolAnyWithDefault(args, false, "reset_queue"),
}, nil
}
// resolveCandidateDays 解析并返回候选 day 列表。
//
// 处理规则:
// 1. 先解析 day / day_start / day_end互斥形成基础集合
// 2. 再叠加 day_scope / day_of_week / week_* 过滤;
// 3. 返回升序去重结果;若过滤后为空,返回空切片但不报错。
func resolveCandidateDays(
state *ScheduleState,
args map[string]any,
dayScope string,
dayOfWeekSet map[int]struct{},
weekSet map[int]struct{},
weekFrom int,
weekTo int,
) ([]int, error) {
if state == nil {
return nil, fmt.Errorf("state 为空")
}
day, hasDay := readIntAny(args, "day")
dayStart, hasDayStart := readIntAny(args, "day_start")
dayEnd, hasDayEnd := readIntAny(args, "day_end")
if hasDay && (hasDayStart || hasDayEnd) {
return nil, fmt.Errorf("day 与 day_start/day_end 不能同时传入")
}
baseDays := make([]int, 0, state.Window.TotalDays)
if hasDay {
if err := validateDay(state, day); err != nil {
return nil, err
}
baseDays = append(baseDays, day)
} else {
start := 1
end := state.Window.TotalDays
if hasDayStart {
start = dayStart
}
if hasDayEnd {
end = dayEnd
}
if start > end {
return nil, fmt.Errorf("day_start=%d 不能大于 day_end=%d", start, end)
}
if err := validateDay(state, start); err != nil {
return nil, err
}
if err := validateDay(state, end); err != nil {
return nil, err
}
for d := start; d <= end; d++ {
baseDays = append(baseDays, d)
}
}
result := make([]int, 0, len(baseDays))
for _, d := range baseDays {
week, dayOfWeek, ok := state.DayToWeekDay(d)
if !ok {
continue
}
if len(dayOfWeekSet) > 0 {
if _, hit := dayOfWeekSet[dayOfWeek]; !hit {
continue
}
} else if !matchDayScope(dayOfWeek, dayScope) {
continue
}
if len(weekSet) > 0 {
if _, hit := weekSet[week]; !hit {
continue
}
}
if week < weekFrom || week > weekTo {
continue
}
result = append(result, d)
}
sort.Ints(result)
return uniqueInts(result), nil
}
// matchSectionRange 判断候选节次是否满足过滤条件。
func matchSectionRange(
slotStart int,
slotEnd int,
excluded map[int]struct{},
after *int,
before *int,
exactFrom *int,
exactTo *int,
) bool {
if exactFrom != nil && exactTo != nil {
if slotStart != *exactFrom || slotEnd != *exactTo {
return false
}
}
if after != nil && slotStart <= *after {
return false
}
if before != nil && slotEnd >= *before {
return false
}
for section := slotStart; section <= slotEnd; section++ {
if _, hit := excluded[section]; hit {
return false
}
}
return true
}
// isStrictSlotAvailable 判断某段是否为“纯空位”。
func isStrictSlotAvailable(state *ScheduleState, day int, slotStart int, slotEnd int) bool {
for i := range state.Tasks {
task := state.Tasks[i]
if len(task.Slots) == 0 {
continue
}
if task.EmbedHost != nil {
continue
}
for _, slot := range task.Slots {
if slot.Day != day {
continue
}
if rangesOverlap(slotStart, slotEnd, slot.SlotStart, slot.SlotEnd) {
return false
}
}
}
return true
}
// isEmbeddableSlotAvailable 判断某段是否可作为“可嵌入候选位”。
//
// 判定规则:
// 1. 该段不能与不可嵌入任务冲突;
// 2. 该段必须完全落在某个 can_embed=true 且未被占用嵌入位的宿主中;
// 3. 若命中 can_embed 但宿主已被嵌入embedded_by!=nil视为不可用。
func isEmbeddableSlotAvailable(state *ScheduleState, day int, slotStart int, slotEnd int) bool {
hostFound := false
for i := range state.Tasks {
task := state.Tasks[i]
if len(task.Slots) == 0 {
continue
}
if task.EmbedHost != nil {
continue
}
for _, slot := range task.Slots {
if slot.Day != day {
continue
}
if !rangesOverlap(slotStart, slotEnd, slot.SlotStart, slot.SlotEnd) {
continue
}
if !task.CanEmbed {
return false
}
if task.EmbeddedBy != nil {
return false
}
if slotStart >= slot.SlotStart && slotEnd <= slot.SlotEnd {
hostFound = true
continue
}
// 与可嵌入宿主部分重叠但不被完全包含,也不能作为合法嵌入位。
return false
}
}
return hostFound
}
// matchTaskStatus 判断任务是否命中 status 过滤。
func matchTaskStatus(task ScheduleTask, status string) bool {
switch status {
case "all":
return true
case "existing":
return IsExistingTask(task)
case "suggested":
return IsSuggestedTask(task)
case "pending":
return IsPendingTask(task)
default:
return false
}
}
// isQueryTargetCalendarFilterActive 判断是否显式启用了日历坐标过滤。
func isQueryTargetCalendarFilterActive(args map[string]any, options queryTargetOptions) bool {
if _, ok := readIntAny(args, "day"); ok {
return true
}
if _, ok := readIntAny(args, "day_start"); ok {
return true
}
if _, ok := readIntAny(args, "day_end"); ok {
return true
}
if _, ok := readIntAny(args, "week"); ok {
return true
}
if _, ok := readIntAny(args, "week_from", "from_week"); ok {
return true
}
if _, ok := readIntAny(args, "week_to", "to_week"); ok {
return true
}
if len(readIntSliceAny(args, "week_filter", "weeks")) > 0 {
return true
}
if len(options.DayOfWeekSet) > 0 {
return true
}
scopeRaw := strings.TrimSpace(readStringAny(args, "day_scope"))
return normalizeDayScope(scopeRaw) != "all" && scopeRaw != ""
}
// buildTaskStatusLabel 返回任务状态标签。
func buildTaskStatusLabel(task ScheduleTask) string {
if IsPendingTask(task) {
return "pending"
}
if IsSuggestedTask(task) {
return "suggested"
}
return "existing"
}
// rangesOverlap 判断两个闭区间是否重叠。
func rangesOverlap(startA, endA, startB, endB int) bool {
return startA <= endB && endA >= startB
}
// normalizeDayScope 归一化 day_scope。
func normalizeDayScope(scope string) string {
scope = strings.ToLower(strings.TrimSpace(scope))
switch scope {
case "weekend", "workday", "all":
return scope
default:
return "all"
}
}
// matchDayScope 判断 day_of_week 是否命中 day_scope。
func matchDayScope(dayOfWeek int, scope string) bool {
switch scope {
case "weekend":
return dayOfWeek == 6 || dayOfWeek == 7
case "workday":
return dayOfWeek >= 1 && dayOfWeek <= 5
default:
return true
}
}
// inferWeekBounds 推导窗口内的最小/最大周。
func inferWeekBounds(state *ScheduleState) (int, int) {
if state == nil || len(state.Window.DayMapping) == 0 {
return 0, 0
}
minWeek := state.Window.DayMapping[0].Week
maxWeek := state.Window.DayMapping[0].Week
for _, mapping := range state.Window.DayMapping {
if mapping.Week < minWeek {
minWeek = mapping.Week
}
if mapping.Week > maxWeek {
maxWeek = mapping.Week
}
}
return minWeek, maxWeek
}
// readIntAny 按别名顺序读取 int 参数。
func readIntAny(args map[string]any, keys ...string) (int, bool) {
for _, key := range keys {
value, ok := argsInt(args, key)
if ok {
return value, true
}
}
return 0, false
}
// readStringAny 按别名顺序读取 string 参数。
func readStringAny(args map[string]any, keys ...string) string {
for _, key := range keys {
if value, ok := argsString(args, key); ok {
return value
}
}
return ""
}
// readBoolAnyWithDefault 按别名顺序读取 bool 参数。
func readBoolAnyWithDefault(args map[string]any, defaultValue bool, keys ...string) bool {
for _, key := range keys {
raw, exists := args[key]
if !exists {
continue
}
switch value := raw.(type) {
case bool:
return value
case string:
lower := strings.ToLower(strings.TrimSpace(value))
if lower == "true" {
return true
}
if lower == "false" {
return false
}
}
}
return defaultValue
}
// readIntSliceAny 按别名顺序读取 int 列表参数。
func readIntSliceAny(args map[string]any, keys ...string) []int {
for _, key := range keys {
if values, ok := argsIntSlice(args, key); ok {
return values
}
}
return nil
}
// readStringSliceAny 按别名顺序读取 string 列表参数。
func readStringSliceAny(args map[string]any, keys ...string) []string {
for _, key := range keys {
raw, exists := args[key]
if !exists {
continue
}
switch values := raw.(type) {
case []string:
out := make([]string, 0, len(values))
for _, item := range values {
trimmed := strings.TrimSpace(item)
if trimmed != "" {
out = append(out, trimmed)
}
}
return out
case []any:
out := make([]string, 0, len(values))
for _, item := range values {
text, ok := item.(string)
if !ok {
continue
}
trimmed := strings.TrimSpace(text)
if trimmed != "" {
out = append(out, trimmed)
}
}
return out
case string:
trimmed := strings.TrimSpace(values)
if trimmed == "" {
return nil
}
return []string{trimmed}
}
}
return nil
}
// intSliceToSet 将 int 列表转为集合。
func intSliceToSet(values []int) map[int]struct{} {
if len(values) == 0 {
return map[int]struct{}{}
}
set := make(map[int]struct{}, len(values))
for _, value := range values {
set[value] = struct{}{}
}
return set
}
// sortedSetKeys 返回集合的升序 key 切片。
func sortedSetKeys(set map[int]struct{}) []int {
if len(set) == 0 {
return []int{}
}
keys := make([]int, 0, len(set))
for key := range set {
keys = append(keys, key)
}
sort.Ints(keys)
return keys
}
// uniqueInts 对整数切片去重并保持升序。
func uniqueInts(values []int) []int {
if len(values) == 0 {
return values
}
seen := make(map[int]struct{}, len(values))
result := make([]int, 0, len(values))
for _, value := range values {
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
result = append(result, value)
}
sort.Ints(result)
return result
}

View File

@@ -184,7 +184,7 @@ func findFreeRangesOnDay(state *ScheduleState, day int) []freeRange {
}
// getEmbeddableTasks 获取所有可嵌入时段的任务列表。
// 条件CanEmbed == true用于 find_first_free 和 get_overview 输出可嵌入位置。
// 条件CanEmbed == true用于 query_available_slots 和 get_overview 输出可嵌入位置。
func getEmbeddableTasks(state *ScheduleState) []*ScheduleTask {
var result []*ScheduleTask
for i := range state.Tasks {

View File

@@ -7,24 +7,16 @@ import (
)
// ToolHandler 是所有工具的统一执行签名。
// 接收当前 ScheduleState + LLM 输出的原始参数,返回自然语言结果。
type ToolHandler func(state *ScheduleState, args map[string]any) string
// ToolSchemaEntry 是工具描述的轻量快照,用于 LLM prompt 注入。
// 在注入 ConversationContext 时转换为 model.ToolSchemaContext。
// ToolSchemaEntry 是注入给模型的工具说明快照
type ToolSchemaEntry struct {
Name string
Desc string
SchemaText string
}
// ToolRegistry 管理所有工具注册、查找执行。
//
// 职责边界:
// 1. 负责工具名 → handler 的映射;
// 2. 负责工具 schema 的存储(供 LLM prompt 注入);
// 3. 不负责 ScheduleState 的生命周期管理;
// 4. 不负责 confirm 流程(由 execute.go 的 action 分支处理)。
// ToolRegistry 管理工具注册、查找执行。
type ToolRegistry struct {
handlers map[string]ToolHandler
schemas []ToolSchemaEntry
@@ -38,7 +30,7 @@ func NewToolRegistry() *ToolRegistry {
}
}
// Register 注册一个工具及其 schema 描述
// Register 注册一个工具及其 schema。
func (r *ToolRegistry) Register(name, desc, schemaText string, handler ToolHandler) {
r.handlers[name] = handler
r.schemas = append(r.schemas, ToolSchemaEntry{
@@ -49,7 +41,6 @@ func (r *ToolRegistry) Register(name, desc, schemaText string, handler ToolHandl
}
// Execute 执行指定工具。
// 工具名不存在时返回错误提示字符串。
func (r *ToolRegistry) Execute(state *ScheduleState, toolName string, args map[string]any) string {
handler, ok := r.handlers[toolName]
if !ok {
@@ -64,36 +55,38 @@ func (r *ToolRegistry) HasTool(name string) bool {
return ok
}
// ToolNames 返回所有已注册工具名(按注册顺序)。
// ToolNames 返回已注册工具名(按 schema 顺序)。
func (r *ToolRegistry) ToolNames() []string {
names := make([]string, 0, len(r.handlers))
for _, s := range r.schemas {
names = append(names, s.Name)
for _, item := range r.schemas {
names = append(names, item.Name)
}
return names
}
// Schemas 返回所有工具的 schema 描述(供 LLM prompt 注入)
// Schemas 返回 schema 快照
func (r *ToolRegistry) Schemas() []ToolSchemaEntry {
result := make([]ToolSchemaEntry, len(r.schemas))
copy(result, r.schemas)
return result
}
// IsWriteTool 判断指定工具是否写工具(需要 confirm 流程)。
// IsWriteTool 判断工具是否写工具(需要 confirm
func (r *ToolRegistry) IsWriteTool(name string) bool {
return writeTools[name]
}
// ==================== 写工具集合 ====================
// ==================== 写工具集合 ====================
var writeTools = map[string]bool{
"place": true,
"move": true,
"swap": true,
"batch_move": true,
"min_context_switch": true,
"unplace": true,
"place": true,
"move": true,
"swap": true,
"batch_move": true,
"queue_apply_head_move": true,
"spread_even": true,
"min_context_switch": true,
"unplace": true,
}
// ==================== 默认注册表 ====================
@@ -123,20 +116,40 @@ func NewDefaultRegistry() *ToolRegistry {
},
)
r.Register("find_first_free",
"查找首个满足时长条件的可用位置并返回该日详细负载信息。duration 必填;可用 day 指定单天,或用 day_start/day_end 指定搜索范围(互斥)。",
`{"name":"find_first_free","parameters":{"duration":{"type":"int","required":true},"day":{"type":"int"},"day_start":{"type":"int"},"day_end":{"type":"int"}}}`,
r.Register("query_available_slots",
"查询候选空位池(先返回纯空位,不足再补可嵌入位),适合 move 前的落点筛选。",
`{"name":"query_available_slots","parameters":{"span":{"type":"int"},"duration":{"type":"int"},"limit":{"type":"int"},"allow_embed":{"type":"bool"},"day":{"type":"int"},"day_start":{"type":"int"},"day_end":{"type":"int"},"day_scope":{"type":"string","enum":["all","workday","weekend"]},"day_of_week":{"type":"array","items":{"type":"int"}},"week":{"type":"int"},"week_filter":{"type":"array","items":{"type":"int"}},"week_from":{"type":"int"},"week_to":{"type":"int"},"slot_type":{"type":"string"},"slot_types":{"type":"array","items":{"type":"string"}},"exclude_sections":{"type":"array","items":{"type":"int"}},"after_section":{"type":"int"},"before_section":{"type":"int"},"section_from":{"type":"int"},"section_to":{"type":"int"}}}`,
func(state *ScheduleState, args map[string]any) string {
duration, ok := argsInt(args, "duration")
if !ok {
return "查询失败:缺少必填参数 duration。"
}
return FindFirstFree(state, duration, argsIntPtr(args, "day"), argsIntPtr(args, "day_start"), argsIntPtr(args, "day_end"))
return QueryAvailableSlots(state, args)
},
)
r.Register("query_target_tasks",
"查询候选任务集合,可按 status/week/day/task_id/category 筛选;默认自动入队,供后续 queue_pop_head 逐项处理。",
`{"name":"query_target_tasks","parameters":{"status":{"type":"string","enum":["all","existing","suggested","pending"]},"category":{"type":"string"},"limit":{"type":"int"},"day_scope":{"type":"string","enum":["all","workday","weekend"]},"day":{"type":"int"},"day_start":{"type":"int"},"day_end":{"type":"int"},"day_of_week":{"type":"array","items":{"type":"int"}},"week":{"type":"int"},"week_filter":{"type":"array","items":{"type":"int"}},"week_from":{"type":"int"},"week_to":{"type":"int"},"task_ids":{"type":"array","items":{"type":"int"}},"task_id":{"type":"int"},"task_item_ids":{"type":"array","items":{"type":"int"}},"task_item_id":{"type":"int"},"enqueue":{"type":"bool"},"reset_queue":{"type":"bool"}}}`,
func(state *ScheduleState, args map[string]any) string {
return QueryTargetTasks(state, args)
},
)
r.Register("queue_pop_head",
"弹出并返回当前队首任务;若已有 current 则复用,保证一次只处理一个任务。",
`{"name":"queue_pop_head","parameters":{}}`,
func(state *ScheduleState, args map[string]any) string {
return QueuePopHead(state, args)
},
)
r.Register("queue_status",
"查看当前待处理队列状态pending/current/completed/skipped。",
`{"name":"queue_status","parameters":{}}`,
func(state *ScheduleState, args map[string]any) string {
return QueueStatus(state, args)
},
)
r.Register("list_tasks",
"列出任务清单可按类别和状态过滤。category 传任务类名称(非 ID 列表可选status 选填(默认 all仅支持单值 all/existing/suggested/pending。",
"列出任务清单可按类别和状态过滤。category 传任务类名称status 仅支持单值 all/existing/suggested/pending。",
`{"name":"list_tasks","parameters":{"category":{"type":"string"},"status":{"type":"string","enum":["all","existing","suggested","pending"]}}}`,
func(state *ScheduleState, args map[string]any) string {
return ListTasks(state, argsStringPtr(args, "category"), argsStringPtr(args, "status"))
@@ -144,7 +157,7 @@ func NewDefaultRegistry() *ToolRegistry {
)
r.Register("get_task_info",
"查询单个任务详细信息,包括类别、状态、占用时段、嵌入关系。",
"查询单个任务详细信息,包括类别、状态、占用时段、嵌入关系。",
`{"name":"get_task_info","parameters":{"task_id":{"type":"int","required":true}}}`,
func(state *ScheduleState, args map[string]any) string {
taskID, ok := argsInt(args, "task_id")
@@ -213,7 +226,7 @@ func NewDefaultRegistry() *ToolRegistry {
)
r.Register("batch_move",
"原子性批量移动多个任务(仅 suggested全部成功才生效。若含 existing/pending 将整批失败回滚。moves 数组必填。",
"原子性批量移动多个任务(仅 suggested最多2条),全部成功才生效。若含 existing/pending 或任一冲突将整批失败回滚。",
`{"name":"batch_move","parameters":{"moves":{"type":"array","required":true,"items":{"task_id":"int","new_day":"int","new_slot_start":"int"}}}}`,
func(state *ScheduleState, args map[string]any) string {
moves, err := argsMoveList(args)
@@ -224,6 +237,22 @@ func NewDefaultRegistry() *ToolRegistry {
},
)
r.Register("queue_apply_head_move",
"将当前队首任务移动到指定位置并自动出队。仅作用于 current不接受 task_id。new_day/new_slot_start 必填。",
`{"name":"queue_apply_head_move","parameters":{"new_day":{"type":"int","required":true},"new_slot_start":{"type":"int","required":true}}}`,
func(state *ScheduleState, args map[string]any) string {
return QueueApplyHeadMove(state, args)
},
)
r.Register("queue_skip_head",
"跳过当前队首任务(不改日程),将其标记为 skipped 并继续后续队列。",
`{"name":"queue_skip_head","parameters":{"reason":{"type":"string"}}}`,
func(state *ScheduleState, args map[string]any) string {
return QueueSkipHead(state, args)
},
)
r.Register("min_context_switch",
"在指定任务集合内重排 suggested 任务尽量让同类任务连续以减少上下文切换。仅在用户明确允许打乱顺序时使用。task_ids 必填(兼容 task_id。",
`{"name":"min_context_switch","parameters":{"task_ids":{"type":"array","required":true,"items":{"type":"int"}},"task_id":{"type":"int"}}}`,
@@ -236,6 +265,18 @@ func NewDefaultRegistry() *ToolRegistry {
},
)
r.Register("spread_even",
"在给定任务集合内做均匀化铺开先按筛选条件收集候选坑位再规划并原子落地。task_ids 必填(兼容 task_id。",
`{"name":"spread_even","parameters":{"task_ids":{"type":"array","required":true,"items":{"type":"int"}},"task_id":{"type":"int"},"limit":{"type":"int"},"allow_embed":{"type":"bool"},"day":{"type":"int"},"day_start":{"type":"int"},"day_end":{"type":"int"},"day_scope":{"type":"string","enum":["all","workday","weekend"]},"day_of_week":{"type":"array","items":{"type":"int"}},"week":{"type":"int"},"week_filter":{"type":"array","items":{"type":"int"}},"week_from":{"type":"int"},"week_to":{"type":"int"},"slot_type":{"type":"string"},"slot_types":{"type":"array","items":{"type":"string"}},"exclude_sections":{"type":"array","items":{"type":"int"}},"after_section":{"type":"int"},"before_section":{"type":"int"}}}`,
func(state *ScheduleState, args map[string]any) string {
taskIDs, err := parseSpreadEvenTaskIDs(args)
if err != nil {
return fmt.Sprintf("均匀化调整失败:%s。", err.Error())
}
return SpreadEven(state, taskIDs, args)
},
)
r.Register("unplace",
"将一个已落位任务移除恢复为待安排状态。会自动清理嵌入关系。task_id 必填。",
`{"name":"unplace","parameters":{"task_id":{"type":"int","required":true}}}`,
@@ -248,7 +289,7 @@ func NewDefaultRegistry() *ToolRegistry {
},
)
// 按 schema name 排序,保输出稳定。
// 按 schema name 排序,保输出稳定。
sort.Slice(r.schemas, func(i, j int) bool {
return r.schemas[i].Name < r.schemas[j].Name
})

View File

@@ -0,0 +1,177 @@
package newagenttools
// TaskProcessingQueue 表示 execute 阶段的“逐项处理队列”运行态。
//
// 职责边界:
// 1. PendingTaskIDs尚未开始处理的候选任务
// 2. CurrentTaskID当前正在处理的队首任务0 表示暂无);
// 3. CompletedTaskIDs / SkippedTaskIDs本轮处理结果归档
// 4. LastError最近一次 apply 失败的原因,供 LLM 下一轮决策参考。
type TaskProcessingQueue struct {
PendingTaskIDs []int `json:"pending_task_ids,omitempty"`
CurrentTaskID int `json:"current_task_id,omitempty"`
CurrentAttempts int `json:"current_attempts,omitempty"`
CompletedTaskIDs []int `json:"completed_task_ids,omitempty"`
SkippedTaskIDs []int `json:"skipped_task_ids,omitempty"`
LastError string `json:"last_error,omitempty"`
}
// ensureTaskProcessingQueue 确保 state 上有可用队列容器。
func ensureTaskProcessingQueue(state *ScheduleState) *TaskProcessingQueue {
if state == nil {
return nil
}
if state.RuntimeQueue == nil {
state.RuntimeQueue = &TaskProcessingQueue{}
}
return state.RuntimeQueue
}
// ResetTaskProcessingQueue 清空本轮临时队列,供“新一轮执行开始”时调用。
func ResetTaskProcessingQueue(state *ScheduleState) {
if state == nil {
return
}
state.RuntimeQueue = nil
}
// ReplaceTaskProcessingQueue 用新的任务 ID 列表覆盖队列。
//
// 步骤化说明:
// 1. 先重置队列,避免上一次处理结果残留;
// 2. 对输入任务 ID 去重,防止 LLM 重复筛选造成同任务重复入队;
// 3. 不自动弹出当前任务,保持“显式 queue_pop_head 才开始处理”的流程约束。
func ReplaceTaskProcessingQueue(state *ScheduleState, taskIDs []int) int {
queue := ensureTaskProcessingQueue(state)
if queue == nil {
return 0
}
queue.PendingTaskIDs = nil
queue.CurrentTaskID = 0
queue.CurrentAttempts = 0
queue.CompletedTaskIDs = nil
queue.SkippedTaskIDs = nil
queue.LastError = ""
return appendTaskIDsToQueue(state, taskIDs)
}
// appendTaskIDsToQueue 将任务追加到队列尾部并做去重,返回本次实际入队数量。
//
// 去重规则:
// 1. 与当前正在处理的任务去重;
// 2. 与 pending / completed / skipped 去重;
// 3. task_id<=0 直接忽略,避免无效数据污染队列。
func appendTaskIDsToQueue(state *ScheduleState, taskIDs []int) int {
queue := ensureTaskProcessingQueue(state)
if queue == nil || len(taskIDs) == 0 {
return 0
}
exists := make(map[int]struct{}, len(queue.PendingTaskIDs)+len(queue.CompletedTaskIDs)+len(queue.SkippedTaskIDs)+1)
if queue.CurrentTaskID > 0 {
exists[queue.CurrentTaskID] = struct{}{}
}
for _, id := range queue.PendingTaskIDs {
exists[id] = struct{}{}
}
for _, id := range queue.CompletedTaskIDs {
exists[id] = struct{}{}
}
for _, id := range queue.SkippedTaskIDs {
exists[id] = struct{}{}
}
added := 0
for _, id := range taskIDs {
if id <= 0 {
continue
}
if _, ok := exists[id]; ok {
continue
}
queue.PendingTaskIDs = append(queue.PendingTaskIDs, id)
exists[id] = struct{}{}
added++
}
return added
}
// popOrGetCurrentTaskID 返回当前可处理任务。
//
// 规则:
// 1. 若已有 CurrentTaskID直接复用保证 apply/skip 前不切换对象);
// 2. 若 current 为空且 pending 非空,则弹出队首并设为 current
// 3. 若队列为空,返回 0。
func popOrGetCurrentTaskID(state *ScheduleState) int {
queue := ensureTaskProcessingQueue(state)
if queue == nil {
return 0
}
if queue.CurrentTaskID > 0 {
return queue.CurrentTaskID
}
if len(queue.PendingTaskIDs) == 0 {
return 0
}
queue.CurrentTaskID = queue.PendingTaskIDs[0]
queue.PendingTaskIDs = queue.PendingTaskIDs[1:]
queue.CurrentAttempts = 0
queue.LastError = ""
return queue.CurrentTaskID
}
// markCurrentTaskCompleted 将 current 任务标记为完成并清空 current。
func markCurrentTaskCompleted(state *ScheduleState) {
queue := ensureTaskProcessingQueue(state)
if queue == nil || queue.CurrentTaskID <= 0 {
return
}
queue.CompletedTaskIDs = append(queue.CompletedTaskIDs, queue.CurrentTaskID)
queue.CurrentTaskID = 0
queue.CurrentAttempts = 0
queue.LastError = ""
}
// markCurrentTaskSkipped 将 current 任务标记为跳过并清空 current。
func markCurrentTaskSkipped(state *ScheduleState) {
queue := ensureTaskProcessingQueue(state)
if queue == nil || queue.CurrentTaskID <= 0 {
return
}
queue.SkippedTaskIDs = append(queue.SkippedTaskIDs, queue.CurrentTaskID)
queue.CurrentTaskID = 0
queue.CurrentAttempts = 0
queue.LastError = ""
}
// bumpCurrentTaskAttempt 记录 current 任务一次失败尝试。
func bumpCurrentTaskAttempt(state *ScheduleState, errText string) {
queue := ensureTaskProcessingQueue(state)
if queue == nil || queue.CurrentTaskID <= 0 {
return
}
queue.CurrentAttempts++
queue.LastError = errText
}
// cloneTaskProcessingQueue 深拷贝 RuntimeQueue。
func cloneTaskProcessingQueue(src *TaskProcessingQueue) *TaskProcessingQueue {
if src == nil {
return nil
}
dst := &TaskProcessingQueue{
CurrentTaskID: src.CurrentTaskID,
CurrentAttempts: src.CurrentAttempts,
LastError: src.LastError,
}
if len(src.PendingTaskIDs) > 0 {
dst.PendingTaskIDs = append([]int(nil), src.PendingTaskIDs...)
}
if len(src.CompletedTaskIDs) > 0 {
dst.CompletedTaskIDs = append([]int(nil), src.CompletedTaskIDs...)
}
if len(src.SkippedTaskIDs) > 0 {
dst.SkippedTaskIDs = append([]int(nil), src.SkippedTaskIDs...)
}
return dst
}

View File

@@ -69,6 +69,13 @@ type ScheduleState struct {
Window ScheduleWindow `json:"window"`
Tasks []ScheduleTask `json:"tasks"`
TaskClasses []TaskClassMeta `json:"task_classes,omitempty"` // 任务类约束元数据,供 LLM 排课参考
// RuntimeQueue 是“本轮 execute 微调”的临时待处理队列。
//
// 职责边界:
// 1. 负责承载 LLM 队列化微调时的运行态(待处理/当前处理/已完成/已跳过);
// 2. 只用于 newAgent 运行期,不参与数据库持久化;
// 3. 支持随 AgentStateSnapshot 一起快照,便于断线恢复后继续处理队首任务。
RuntimeQueue *TaskProcessingQueue `json:"runtime_queue,omitempty"`
}
// DayToWeekDay converts day_index to (week, day_of_week).
@@ -131,5 +138,6 @@ func (s *ScheduleState) Clone() *ScheduleState {
clone.Tasks[i].EmbedHost = &v
}
}
clone.RuntimeQueue = cloneTaskProcessingQueue(s.RuntimeQueue)
return clone
}

View File

@@ -18,6 +18,16 @@ type MoveRequest struct {
NewSlotStart int `json:"new_slot_start"`
}
const (
// maxBatchMoveSize 是 batch_move 的安全上限。
//
// 设计说明:
// 1. 旧链路中 batch_move 容易因组合冲突导致“整批回滚 + 连续重试”;
// 2. 先把批量规模限制在 2作为止血策略降低一次决策的冲突面
// 3. 更大规模的调整应优先走队列化逐项处理queue_pop_head + queue_apply_head_move
maxBatchMoveSize = 2
)
// ==================== Place ====================
// Place 将一个待安排任务预排到指定位置。
@@ -260,6 +270,9 @@ func BatchMove(state *ScheduleState, moves []MoveRequest) string {
if len(moves) == 0 {
return "批量移动失败:移动列表为空。"
}
if len(moves) > maxBatchMoveSize {
return fmt.Sprintf("批量移动失败:当前最多支持 %d 条移动请求。请改用队列化逐项处理queue_pop_head + queue_apply_head_move。", maxBatchMoveSize)
}
// 1. 全量校验阶段(不改 state
for i, m := range moves {

View File

@@ -245,8 +245,23 @@ func (s *AgentService) loadOrCreateRuntimeState(ctx context.Context, chatID stri
cs.UserID = userID
cs.ConversationID = chatID
// 不需要手动重置 Phase所有请求统一先过 Chat 节点Chat 会根据路由决策覆盖 Phase。
// 保留完整的 RuntimeStatePlanSteps、CurrentStep 等),支持连续对话调整日程。
// 1. 冷加载兜底:若上一轮已经收口且当前没有待恢复交互,说明本次是新一轮请求;
// 2. 这里先重置执行期临时字段,避免旧 round/terminal 状态污染 chat 路由和后续 execute
// 3. 即使 chat 节点也有同条件重置,这里仍保留兜底,覆盖断线恢复或入口绕行场景。
if !snapshot.RuntimeState.HasPendingInteraction() && cs.Phase == newagentmodel.PhaseDone {
terminalBefore := cs.TerminalStatus()
roundBefore := cs.RoundUsed
cs.ResetForNextRun()
log.Printf(
"[DEBUG] loadOrCreateRuntimeState reset runtime for next run chat=%s round_before=%d terminal_before=%s",
chatID,
roundBefore,
terminalBefore,
)
}
// 常规场景仍由 Chat 节点基于路由覆盖 Phase这里只在"上一轮已 done"时做一次前置清理兜底。
// 其余跨轮可复用状态(如任务类范围、会话历史、日程内存态)继续保留,支持连续对话调整日程。
originalScheduleState := snapshot.OriginalScheduleState
if snapshot.ScheduleState != nil && originalScheduleState == nil {
@@ -463,7 +478,7 @@ func (s *AgentService) makeRoughBuildFunc() newagentmodel.RoughBuildFunc {
}
}
// makeWriteSchedulePreviewFunc 封装 cacheDAO 写排程预览缓存的操作,供 Deliver 节点注入
// makeWriteSchedulePreviewFunc 封装 cacheDAO 写排程预览缓存的操作,供 Execute/Deliver 节点复用
func (s *AgentService) makeWriteSchedulePreviewFunc() newagentmodel.WriteSchedulePreviewFunc {
if s.cacheDAO == nil {
return nil
@@ -472,12 +487,12 @@ func (s *AgentService) makeWriteSchedulePreviewFunc() newagentmodel.WriteSchedul
stateDigest := summarizeScheduleStateForPreviewDebug(state)
preview := conv.ScheduleStateToPreview(state, userID, conversationID, taskClassIDs, "")
if preview == nil {
log.Printf("[WARN] deliver preview skipped chat=%s user=%d state=%s", conversationID, userID, stateDigest)
log.Printf("[WARN] schedule preview skipped chat=%s user=%d state=%s", conversationID, userID, stateDigest)
return nil
}
previewDigest := summarizeHybridEntriesForPreviewDebug(preview.HybridEntries)
log.Printf(
"[DEBUG] deliver preview write chat=%s user=%d state=%s preview=%s generated_at=%s",
"[DEBUG] schedule preview write chat=%s user=%d state=%s preview=%s generated_at=%s",
conversationID,
userID,
stateDigest,