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:
@@ -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
272
backend/newAgent/tools/queue_tools.go
Normal file
272
backend/newAgent/tools/queue_tools.go
Normal 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)
|
||||
}
|
||||
931
backend/newAgent/tools/read_filter_tools.go
Normal file
931
backend/newAgent/tools/read_filter_tools.go
Normal 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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
177
backend/newAgent/tools/runtime_queue.go
Normal file
177
backend/newAgent/tools/runtime_queue.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user