Version: 0.9.8.dev.260408

后端:
1.execute 上下文瘦身第一版落地(固定 4 消息骨架 + ReAct 窗口压缩 + JSON 输出约束)
  - 新建 prompt/execute_context.go:
    execute 阶段改为 message[0..3] 固定结构;
    加入历史摘要、当轮 ReAct 绑定展示、同工具 observation 压缩(保留最新)与工具简表返回示例提示
  - 更新 prompt/execute.go:
    重写 plan/ReAct 执行提示词;
    补齐“可做/不可做”约束;
    统一严格 JSON 指令;
    补充 tool_call.arguments/abort/speak 非空等格式护栏
  - 更新 model/execute_contract.go:
    新增 ExecuteDecision/ToolCallIntent 自定义 Unmarshal;
    兼容空字符串占位与 tool_call.parameters→arguments 回退解析
  - 更新 node/correction.go:
    为 correction 注入 history kind 标记,避免被当作真实用户输入污染摘要
  - 更新 node/execute.go:
    补齐 continue/ask_user/confirm 的 speak 兜底;
    移除工具结果写入前 3000 字截断

2.工具层微调语义重构(任务视角概览 + 首个空位查询 + 移动权限收紧)
  - 更新 tools/read_tools.go:
    get_overview 改为任务视角全量输出(课程仅占位统计);
    新增 find_first_free(首个命中位 + 当日负载明细);
    find_free 保留兼容别名;
    list_tasks 增加 status/category 校验与空结果纠偏文案
  - 更新 tools/registry.go:
    注册 find_first_free;
    find_free 改兼容别名;
    同步 get_overview/list_tasks/move/batch_move 描述语义
  - 更新 tools/write_tools.go:
    move/batch_move 仅允许 suggested,existing/pending 明确拒绝并返回可读错误
  - 更新 tools/SCHEDULE_TOOLS.md:
    同步 get_overview/find_first_free/list_tasks/move/batch_move 的最新入参与返回示例
  - 更新 prompt/plan.go:
    读工具示例由 find_free 调整为 find_first_free

3.交接文档与阶段说明同步
  - 更新 newAgent/HANDOFF_粗排修复与Prompt重构.md:
    更新为 2026-04-08;
    补充“最新增量交接”章节(当前主矛盾、P0/P1、验证清单)
  - 更新 newAgent/阶段3_上下文瘦身设计.md:
    同步 existing/suggested 的 move/batch_move 约束口径
  - 更新 newAgent/Log.txt:
    追加本轮 execute 调试日志快照

前端:无
仓库:无
This commit is contained in:
LoveLosita
2026-04-08 21:35:05 +08:00
parent d3f65609f0
commit 4195e65cba
13 changed files with 4692 additions and 332 deletions

View File

@@ -2,24 +2,27 @@
以下内容可直接交给下一位助理继续做。 以下内容可直接交给下一位助理继续做。
本文档更新时间2026-04-07 本文档更新时间2026-04-08
## 0. 当前结论先说清 ## 0. 当前结论先说清
当前可以明确分成段看: 当前可以明确分成段看:
1. 第 1-2 阶段已经基本完成 1. 第 1-2 阶段已完成
粗排链路已经打通,且“粗排异常 -> 正式 abort -> deliver 收口”这条后端协议已经补齐 粗排链路`abort -> deliver` 正式终止协议已打通,真实链路已验证可跑
2. 第 3-4 阶段还没有做 2. 第 3 阶段第一版已落地
下游 `execute` 整体效果目前仍然很差,核心原因不是粗排本身,而是上下文过胖、提示词结构仍然混乱。 `execute` 上下文已改成固定 4 消息结构,并接入当轮 ReAct 窗口压缩(按工具去重保留最新 observation
所以现在**不能**用“整体排程效果”去评价第 1-2 阶段是否成功;必须先做第 3 阶段上下文瘦身,再看整体效果 工具结果在执行链路中已改为不截断prompt 也已加硬约束JSON、参数名、读写动作、重复读约束等
3. 当前主矛盾已转移到“工具收敛能力”
最新日志显示:不是上下文失控,而是工具输出对“何时停止微调”支持不足,导致模型持续 `list_tasks/find_first_free/move` 小步循环。
一句话总结: 一句话总结:
- 现在粗排已经能跑 - 粗排和执行框架已经能跑;
- 但下游本来就基本跑不通 - 上下文瘦身第一版已经到位
- 下一步应该直接做“上下文瘦身 + prompt 结构重构”,不要再继续围绕粗排补边角 - 下一棒应优先改工具层的“冲突表达 + 候选位质量 + 结束判据”,而不是再回头补粗排
--- ---
@@ -628,3 +631,97 @@ go test ./conv ./newAgent/node ./newAgent/model ./newAgent/graph ./newAgent/tool
## 11. 一句话交给下一位助理 ## 11. 一句话交给下一位助理
第 1-2 阶段已经把“粗排接入”和“正式 abort 收口”打通了,粗排真实链路也已经跑通;现在不要再围绕粗排打补丁,直接进入第 3 阶段做 execute 上下文瘦身,再做第 4 阶段 prompt 三层重构,完成后再评估整体链路效果。 第 1-2 阶段已经把“粗排接入”和“正式 abort 收口”打通了,粗排真实链路也已经跑通;现在不要再围绕粗排打补丁,直接进入第 3 阶段做 execute 上下文瘦身,再做第 4 阶段 prompt 三层重构,完成后再评估整体链路效果。
---
## 12. 2026-04-08 最新增量交接(以本节为准)
> 本节优先级高于前文历史描述。接手时请先读本节,再看上文细节。
### 12.1 本轮已完成的落地项
1. execute 上下文结构已固定为 4 条消息:
- `message[0]`:固定 prompt规则 + JSON 约束 + 工具简表)
- `message[1]`:历史上下文短摘要(聊天摘要 + 早期 ReAct 摘要)
- `message[2]`:当轮 ReAct Loop 窗口thought/reason + tool_call + observation 绑定)
- `message[3]`:当前执行状态(初始目标、结束判断、非目标)
2. 当轮 ReAct 压缩已接入:
- 窗口内同工具只保留最新 observation 原文;
- 被压缩旧结果替换为“当前工具调用结果过于久远,已经被删除。”
3. execute 输出稳态增强:
- `continue / ask_user / confirm``speak` 时会兜底回退 `reason`
- 工具结果写入 history 前的截断已删除(不再自动裁到 3000 字符)。
4. 工具能力已升级:
- `get_overview` 改为任务视角全量输出(课程仅占位统计,不展开课程明细);
- 新增 `find_first_free``find_free` 保留兼容别名;
- `move / batch_move` 限定仅允许 `suggested`
- `list_tasks` 增加输入约束(`status` 单值、`category` 不接受 task_class_ids 列表)。
5. prompt 与文档同步:
- execute prompt 已切换到 `find_first_free` 表达;
- `SCHEDULE_TOOLS.md` 已同步 `get_overview / find_first_free / move/batch_move` 新语义;
- plan prompt 中读工具示例也已从 `find_free` 更新为 `find_first_free`
### 12.2 最新日志结论(关键)
本轮问题已不是“上下文塞不下”,而是“工具不利于收敛”:
1. `find_first_free` 当前策略过于贪心
只返回最早可用位,模型会持续把任务向前挪,容易出现“局部改善但全局不收口”的微调循环。
2. `query_range` 把“可嵌入共存”和“硬冲突”混合输出
模型会把可嵌入并存也当成冲突,导致不必要移动。
3. 缺少“完成判据工具”
当前只有读写事实工具,没有明确“是否可结束”的评估口径,模型自然倾向继续优化。
4. 写工具冲突口径存在潜在不一致
`findConflict``CanEmbed` 的处理与 `findEmbedHost` 的可嵌入约束并非同一套判据,后续应统一。
### 12.3 下一棒建议优先级(按顺序做)
#### P0必须先做
1. 新增评估类只读工具(建议名:`evaluate_balance`
返回最少三项:
- `hard_conflict_count`
- `load_variance`(或等价离散指标)
- `done_suggestion`(可结束/建议继续 + 原因)
2.`find_first_free` 为“候选集”而非单点
建议支持 `top_k`(默认 3并返回每个候选的
- 位置
- 目标日负载变化
- 是否涉及可嵌入
3.`query_range` 输出结构
必须区分:
- `hard_conflict`
- `embeddable_overlap`
避免模型把可嵌入并存误判为硬冲突。
#### P1P0 后做)
4. prompt 增加收敛指引
明确要求模型在每次写操作后优先调用 `evaluate_balance`,满足条件就 `done`,避免“无限微调”。
### 12.4 接手后的最小验证清单
1. 跑一轮真实 execute确认不会长时间卡在 `list_tasks/find_first_free/move` 循环。
2. 确认 `query_range` 可区分硬冲突与可嵌入并存。
3. 确认 `evaluate_balance` 能触发 `done` 收口。
4. 每次 `go test` 后清理项目根目录 `.gocache`
### 12.5 关键文件(本轮增量相关)
- `backend/newAgent/prompt/execute_context.go`
- `backend/newAgent/prompt/execute.go`
- `backend/newAgent/prompt/plan.go`
- `backend/newAgent/node/execute.go`
- `backend/newAgent/tools/read_tools.go`
- `backend/newAgent/tools/write_tools.go`
- `backend/newAgent/tools/registry.go`
- `backend/newAgent/tools/SCHEDULE_TOOLS.md`

2748
backend/newAgent/Log.txt Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
package model package model
import ( import (
"encoding/json"
"fmt" "fmt"
"strings" "strings"
) )
@@ -49,6 +50,47 @@ type ExecuteDecision struct {
Abort *AbortIntent `json:"abort,omitempty"` Abort *AbortIntent `json:"abort,omitempty"`
} }
// UnmarshalJSON 兼容执行决策里几种模型高频跑偏但语义可恢复的写法。
//
// 职责边界:
// 1. 负责把“空字符串占位字段”归一化成未填写,避免 json 反序列化阶段直接失败;
// 2. 负责把 tool_call / abort 交给各自的兼容解析逻辑,尽量保留可恢复的信息;
// 3. 不负责业务合法性校验action 与字段互斥关系仍交给 Validate 判定。
func (d *ExecuteDecision) UnmarshalJSON(data []byte) error {
type rawExecuteDecision struct {
Speak string `json:"speak,omitempty"`
Action ExecuteAction `json:"action"`
Reason string `json:"reason,omitempty"`
GoalCheck string `json:"goal_check,omitempty"`
ToolCall json.RawMessage `json:"tool_call,omitempty"`
Abort json.RawMessage `json:"abort,omitempty"`
}
var raw rawExecuteDecision
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
d.Speak = raw.Speak
d.Action = raw.Action
d.Reason = raw.Reason
d.GoalCheck = raw.GoalCheck
toolCall, err := decodeOptionalJSONObject[ToolCallIntent](raw.ToolCall)
if err != nil {
return fmt.Errorf("tool_call 解析失败: %w", err)
}
d.ToolCall = toolCall
abortIntent, err := decodeOptionalJSONObject[AbortIntent](raw.Abort)
if err != nil {
return fmt.Errorf("abort 解析失败: %w", err)
}
d.Abort = abortIntent
return nil
}
// Normalize 统一清洗 execute 决策中的字符串字段。 // Normalize 统一清洗 execute 决策中的字符串字段。
func (d *ExecuteDecision) Normalize() { func (d *ExecuteDecision) Normalize() {
if d == nil { if d == nil {
@@ -173,6 +215,32 @@ type ToolCallIntent struct {
Arguments map[string]any `json:"arguments,omitempty"` Arguments map[string]any `json:"arguments,omitempty"`
} }
// UnmarshalJSON 兼容 tool_call 里“arguments / parameters”两种高频字段名。
//
// 职责边界:
// 1. 优先使用标准字段 arguments保持当前正式协议不变
// 2. 仅当 arguments 缺失时,回退复用 parameters兼容模型历史习惯
// 3. 不负责校验参数是否满足具体工具 schema后续仍由工具层负责。
func (t *ToolCallIntent) UnmarshalJSON(data []byte) error {
type rawToolCallIntent struct {
Name string `json:"name"`
Arguments map[string]any `json:"arguments,omitempty"`
Parameters map[string]any `json:"parameters,omitempty"`
}
var raw rawToolCallIntent
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
t.Name = raw.Name
t.Arguments = raw.Arguments
if len(t.Arguments) == 0 && len(raw.Parameters) > 0 {
t.Arguments = raw.Parameters
}
return nil
}
// Normalize 清洗工具调用意图中的稳定字段。 // Normalize 清洗工具调用意图中的稳定字段。
func (t *ToolCallIntent) Normalize() { func (t *ToolCallIntent) Normalize() {
if t == nil { if t == nil {
@@ -193,6 +261,36 @@ func (t *ToolCallIntent) Validate() error {
return nil return nil
} }
// decodeOptionalJSONObject 统一兼容“可选对象字段被模型写成空字符串”的情况。
//
// 步骤说明:
// 1. 字段缺失、null、空字符串都视为“未填写”返回 nil
// 2. 只有在确实出现对象内容时,才继续反序列化为目标结构;
// 3. 若模型传入了非空字符串等不可恢复内容,显式报错,避免把脏数据静默吞掉。
func decodeOptionalJSONObject[T any](raw json.RawMessage) (*T, error) {
trimmed := strings.TrimSpace(string(raw))
if trimmed == "" || trimmed == "null" {
return nil, nil
}
if strings.HasPrefix(trimmed, "\"") {
var text string
if err := json.Unmarshal(raw, &text); err != nil {
return nil, err
}
if strings.TrimSpace(text) == "" {
return nil, nil
}
return nil, fmt.Errorf("期望对象,实际收到非空字符串")
}
var out T
if err := json.Unmarshal(raw, &out); err != nil {
return nil, err
}
return &out, nil
}
// ExecuteEvidenceSource 表示“当前步骤完成证明”来自哪里。 // ExecuteEvidenceSource 表示“当前步骤完成证明”来自哪里。
type ExecuteEvidenceSource string type ExecuteEvidenceSource string

View File

@@ -8,6 +8,11 @@ import (
"github.com/cloudwego/eino/schema" "github.com/cloudwego/eino/schema"
) )
const (
correctionHistoryKindKey = "newagent_history_kind"
correctionHistoryKindCorrectionUser = "llm_correction_prompt"
)
// AppendLLMCorrection 追加 LLM 修正提示到对话历史。 // AppendLLMCorrection 追加 LLM 修正提示到对话历史。
// //
// 设计目的: // 设计目的:
@@ -56,6 +61,9 @@ func AppendLLMCorrection(
conversationContext.AppendHistory(&schema.Message{ conversationContext.AppendHistory(&schema.Message{
Role: schema.User, Role: schema.User,
Content: correctionContent, Content: correctionContent,
Extra: map[string]any{
correctionHistoryKindKey: correctionHistoryKindCorrectionUser,
},
}) })
} }
@@ -96,5 +104,8 @@ func AppendLLMCorrectionWithHint(
conversationContext.AppendHistory(&schema.Message{ conversationContext.AppendHistory(&schema.Message{
Role: schema.User, Role: schema.User,
Content: correctionContent, Content: correctionContent,
Extra: map[string]any{
correctionHistoryKindKey: correctionHistoryKindCorrectionUser,
},
}) })
} }

View File

@@ -246,6 +246,10 @@ func RunExecuteNode(ctx context.Context, input ExecuteNodeInput) error {
// 决策合法,重置连续修正计数。 // 决策合法,重置连续修正计数。
flowState.ConsecutiveCorrections = 0 flowState.ConsecutiveCorrections = 0
// speak 兜底continue / ask_user / confirm 三类动作对前端可读文案是强依赖。
// 若模型漏填 speak这里回退到 reason 或默认短句,避免前端出现“静默一轮”。
decision.Speak = buildExecuteSpeakWithFallback(decision)
// speak 后处理:补列表序号换行 + 末尾加 \n 防止连续 speak 在前端粘连。 // speak 后处理:补列表序号换行 + 末尾加 \n 防止连续 speak 在前端粘连。
decision.Speak = normalizeSpeak(decision.Speak) // 末尾已含 \n decision.Speak = normalizeSpeak(decision.Speak) // 末尾已含 \n
@@ -425,6 +429,42 @@ func resolveExecuteAskUserText(decision *newagentmodel.ExecuteDecision) string {
return "执行过程中遇到不确定的情况,需要向你确认。" return "执行过程中遇到不确定的情况,需要向你确认。"
} }
// buildExecuteSpeakWithFallback 统一为需要面向用户展示的动作补齐 speak 文案。
//
// 规则:
// 1. continue / ask_user / confirm 缺 speak 时,优先回退到 reason
// 2. 若 reason 也为空,再按动作使用最短默认文案;
// 3. next_plan / done / abort 不强制补 speak避免影响终止与收口语义。
func buildExecuteSpeakWithFallback(decision *newagentmodel.ExecuteDecision) string {
if decision == nil {
return ""
}
speak := strings.TrimSpace(decision.Speak)
if speak != "" {
return speak
}
switch decision.Action {
case newagentmodel.ExecuteActionContinue,
newagentmodel.ExecuteActionAskUser,
newagentmodel.ExecuteActionConfirm:
if reason := strings.TrimSpace(decision.Reason); reason != "" {
return reason
}
switch decision.Action {
case newagentmodel.ExecuteActionAskUser:
return "我还缺少一条关键信息,想先向你确认。"
case newagentmodel.ExecuteActionConfirm:
return "我先整理好这一步操作,等待你的确认。"
default:
return "我先继续这一步处理,马上给你结果。"
}
default:
return speak
}
}
// handleExecuteActionConfirm 处理 LLM 申报的写操作确认请求。 // handleExecuteActionConfirm 处理 LLM 申报的写操作确认请求。
// //
// 步骤: // 步骤:
@@ -566,12 +606,6 @@ func executeToolCall(
flattenForLog(result), flattenForLog(result),
) )
// 2.5 截断过大的工具结果,防止上下文膨胀导致后续 LLM 调用返回空或超限。
const maxToolResultLen = 3000
if len(result) > maxToolResultLen {
result = result[:maxToolResultLen] + fmt.Sprintf("\n...(结果已截断,原始长度 %d 字符)", len(result))
}
// 3. 将工具调用和结果以合法的 assistant+tool 消息对追加到对话历史。 // 3. 将工具调用和结果以合法的 assistant+tool 消息对追加到对话历史。
// //
// 修复说明: // 修复说明:

View File

@@ -2,7 +2,6 @@ package newagentprompt
import ( import (
"fmt" "fmt"
"strconv"
"strings" "strings"
newagentmodel "github.com/LoveLosita/smartflow/backend/newAgent/model" newagentmodel "github.com/LoveLosita/smartflow/backend/newAgent/model"
@@ -10,62 +9,73 @@ import (
) )
const executeSystemPromptWithPlan = ` const executeSystemPromptWithPlan = `
你是 SmartFlow NewAgent 的执行器。 你是 SmartFlow NewAgent 的执行器。你需要在“当前 plan 步骤”约束下推进任务。
你的职责是在"当前 plan 步骤"的约束下,进行思考、执行、观察,再决定下一步动作。
请遵守以下规则 你可以做什么
1. 只围绕当前步骤行动,不要擅自跳到其他 plan 步骤。 1. 只围绕当前步骤推进,先读后写,逐步完成当前步骤。
2. 只输出严格 JSON不要输出 markdown不要输出额外解释不要在 JSON 外再补文字 2. 可调用读工具补充事实,再决定下一步
3. 只有当你确认当前步骤已经完成时,才输出 action=next_plan且必须在 goal_check 中逐条对照 done_when 说明完成依据 3. 需要写操作时输出 action=confirm 并附带 tool_call等待用户确认
4. 只有当你确认整个任务已经完成时,才输出 action=done且必须在 goal_check 中总结整体完成证据。
5. 如果执行当前步骤缺少关键上下文,且无法通过已有历史或工具补齐,输出 action=ask_user。
6. 不要伪造工具结果;如果尚未真正拿到观察结果,就不要假装已经完成。
7. goal_check 是你输出 next_plan / done 时的强制字段,禁止为空;必须显式地逐条对照 done_when说明"哪些条件已满足、依据是什么"。
会看到 不要做什么
- 当前完整 plan 1. 不要跳到其他 plan 步骤,不要越级执行。
- 当前步骤 2. 不要伪造工具结果。
- 置顶上下文块 3. 如果上下文明确“粗排已完成/rough_build_done”不要把任务当成未排入不要重新逐个手动 place。
- 工具摘要 4. 不要连续重复同类查询而没有推进连续两轮同类读查询后必须转入执行、ask_user或明确阻塞原因。
- 历史对话与历史观察 5. list_tasks 的 status 只允许单值all / existing / suggested / pending。禁止使用 "existing,suggested" 这类拼接值。
6. 若工具结果与已知事实明显冲突如无写操作却从“有任务”变成“0任务”先自我纠错并重查一次不要直接 ask_user。
7. 不要连续两轮调用“同一读工具 + 等价 arguments”若上一轮已成功返回下一轮必须换工具或进入 confirm。
8. list_tasks.category 只接受任务类名称,不接受 task_class_ids如 "1,2,3")。
请把注意力聚焦在"当前步骤是否完成,以及下一步最合理的执行动作"上。 执行规则:
` 1. 只输出严格 JSON不要输出 markdown不要在 JSON 外补充文本。
2. 读操作action=continue + tool_call。
3. 写操作action=confirm + tool_call。
4. 缺关键上下文且无法通过工具补齐action=ask_user。
5. 仅当当前步骤完成时输出 action=next_plan并在 goal_check 对照 done_when 给出证据。
6. 仅当整体任务完成时输出 action=done并在 goal_check 总结完成证据。
7. 流程应正式终止时输出 action=abort。`
const executeSystemPromptReAct = ` const executeSystemPromptReAct = `
你是 SmartFlow NewAgent 的执行器,当前自由执行模式(无预定义计划步骤)。 你是 SmartFlow NewAgent 的执行器,当前处于自由执行模式(无预定义 plan 步骤)。
你需要根据用户意图,自主决定使用哪些工具来完成任务。
请遵守以下规则 阶段事实(强约束)
1. 每轮先分析当前情况,决定下一步动作 1. 若上下文给出“粗排已完成/rough_build_done”表示目标任务类已经进入 suggested/existing不是待排入状态
2. 只输出严格 JSON不要输出 markdown不要输出额外解释不要在 JSON 外再补文字 2. 当前阶段目标是“微调”,不是“重新粗排”
3. 需要查询数据 → 输出 action=continue 并附带 tool_call。
4. 需要修改数据(写操作)→ 输出 action=confirm 并附带 tool_call等待用户确认。
5. 缺少关键信息且无法通过工具补齐 → 输出 action=ask_user。
6. 任务完成 → 输出 action=done并在 goal_check 中总结完成证据。
7. 不要伪造工具结果;如果尚未真正拿到观察结果,就不要假装已经完成。
8. 尽量高效:能用一次工具调用完成的,不要分多轮。
会看到 可以做什么
- 用户原始请求 1. 你可以基于科学排程原则(负载均衡、学习连贯性、冲突最小化)对 suggested 做微调。
- 置顶上下文块(粗排结果等) 2. existing 属于已安排事实层,可用于冲突判断和参考,不作为 move/batch_move 的目标。
- 工具摘要 3. 你可以先调用读工具补充必要事实(例如 get_overview/list_tasks/find_first_free/get_task_info
- 历史对话与历史观察 4. 你可以在需要改动时提出 confirmmove/swap/unplace/batch_move
请直接行动,不要犹豫,不要重复已经做过的操作。 你不要做什么:
` 1. 不要假设任务还没排进去,然后改成逐个手动 place。
2. 不要伪造工具结果。
3. 不要重复做同类查询而没有新增结论连续两轮同类读查询后必须转入执行、ask_user或明确阻塞原因。
4. list_tasks 的 status 只允许单值all / existing / suggested / pending。禁止使用 "existing,suggested" 这类拼接值。
5. 若工具结果与已知事实明显冲突如无写操作却从“有任务”变成“0任务”先自我纠错并重查一次不要直接 ask_user。
6. 不要连续两轮调用“同一读工具 + 等价 arguments”若上一轮已成功返回下一轮必须换工具或进入 confirm。
7. list_tasks.category 只接受任务类名称,不接受 task_class_ids如 "1,2,3")。
// BuildExecuteSystemPrompt 返回执行阶段系统提示词。 执行规则:
1. 只输出严格 JSON不要输出 markdown不要在 JSON 外补充文本。
2. 读操作action=continue + tool_call。
3. 写操作action=confirm + tool_call。
4. 缺关键上下文且无法通过工具补齐action=ask_user。
5. 任务完成action=done并在 goal_check 总结完成证据。
6. 流程应正式终止action=abort。`
// BuildExecuteSystemPrompt 返回执行阶段系统提示词(有 plan 模式)。
func BuildExecuteSystemPrompt() string { func BuildExecuteSystemPrompt() string {
return strings.TrimSpace(executeSystemPromptWithPlan) return buildExecutePromptWithFormatGuard(executeSystemPromptWithPlan)
} }
// BuildExecuteReActSystemPrompt 返回纯 ReAct 模式的系统提示词 // BuildExecuteReActSystemPrompt 返回执行阶段系统提示词(自由执行模式)
func BuildExecuteReActSystemPrompt() string { func BuildExecuteReActSystemPrompt() string {
return strings.TrimSpace(executeSystemPromptReAct) return buildExecutePromptWithFormatGuard(executeSystemPromptReAct)
} }
// BuildExecuteDecisionContractText 返回执行阶段输出协议说明(有 plan 模式)。 // BuildExecuteDecisionContractText 返回执行阶段输出协议(有 plan 模式)。
func BuildExecuteDecisionContractText() string { func BuildExecuteDecisionContractText() string {
return strings.TrimSpace(fmt.Sprintf(` return strings.TrimSpace(fmt.Sprintf(`
输出协议(严格 JSON 输出协议(严格 JSON
@@ -73,14 +83,14 @@ func BuildExecuteDecisionContractText() string {
- action只能是 %s / %s / %s / %s / %s - action只能是 %s / %s / %s / %s / %s
- reason给后端和日志看的简短说明 - reason给后端和日志看的简短说明
- goal_check输出 %s 或 %s 时必填,对照 done_when 逐条验证 - goal_check输出 %s 或 %s 时必填,对照 done_when 逐条验证
- tool_call输出 %s 时可附带写工具意图(需 confirm输出 %s 时可附带读工具调用 - tool_call输出 %s(写操作,需 confirm或 %s读操作时可附带
- tool_call 格式:{"name": "工具名", "arguments": {...}} - tool_call 格式:{"name":"工具名","arguments":{...}}
合法示例: 示例:
{ {
"speak": "我来查一下本周的安排。", "speak": "我先查看当前整体安排。",
"action": "%s", "action": "%s",
"reason": "需要先调用 get_overview 获取当前数据", "reason": "需要先调用 get_overview 获取事实",
"tool_call": { "tool_call": {
"name": "get_overview", "name": "get_overview",
"arguments": {} "arguments": {}
@@ -88,16 +98,16 @@ func BuildExecuteDecisionContractText() string {
} }
{ {
"speak": "查询完成。", "speak": "当前步骤已完成。",
"action": "%s", "action": "%s",
"reason": "已拿到当前周课程列表", "reason": "已完成当前步骤所需查询与校验",
"goal_check": "已通过 get_overview 确认本周课程列表,满足完成条件" "goal_check": "已满足当前步骤 done_when 条件"
} }
{ {
"speak": "", "speak": "",
"action": "%s", "action": "%s",
"reason": "整任务已完成" "reason": "整任务已完成"
} }
`, `,
newagentmodel.ExecuteActionContinue, newagentmodel.ExecuteActionContinue,
@@ -115,22 +125,22 @@ func BuildExecuteDecisionContractText() string {
)) ))
} }
// BuildExecuteReActContractText 返回纯 ReAct 模式输出协议说明 // BuildExecuteReActContractText 返回自由执行模式输出协议。
func BuildExecuteReActContractText() string { func BuildExecuteReActContractText() string {
return strings.TrimSpace(fmt.Sprintf(` return strings.TrimSpace(fmt.Sprintf(`
输出协议(严格 JSON 输出协议(严格 JSON
- speak给用户看的话(可以是分析结果、中间进展、或最终回复) - speak给用户看的话
- action只能是 %s / %s / %s / %s - action只能是 %s / %s / %s / %s
- reason给后端和日志看的简短说明 - reason给后端和日志看的简短说明
- goal_check输出 %s 时必填,总结任务完成证据 - goal_check输出 %s 时必填,总结任务完成证据
- tool_call输出 %s 时可附带写工具意图(需 confirm输出 %s 时可附带读工具调用 - tool_call输出 %s(写操作,需 confirm或 %s读操作时可附带
- tool_call 格式:{"name": "工具名", "arguments": {...}} - tool_call 格式:{"name":"工具名","arguments":{...}}
合法示例: 示例:
{ {
"speak": "我来查一下今天的安排。", "speak": "我先看一下现在的安排分布。",
"action": "%s", "action": "%s",
"reason": "需要调用 get_overview 查询", "reason": "先读取概览再决定微调方向",
"tool_call": { "tool_call": {
"name": "get_overview", "name": "get_overview",
"arguments": {} "arguments": {}
@@ -138,20 +148,20 @@ func BuildExecuteReActContractText() string {
} }
{ {
"speak": "已将概率论移到周三第1-2节。", "speak": "我准备把两项任务对调位置,你确认后执行。",
"action": "%s", "action": "%s",
"reason": "用户要求移动课程,写操作需确认", "reason": "写操作需确认",
"tool_call": { "tool_call": {
"name": "move", "name": "swap",
"arguments": {"task_state_id": 5, "target_day": 3, "target_slot_start": 1, "target_slot_end": 2} "arguments": {"task_a": 1, "task_b": 2}
} }
} }
{ {
"speak": "今天共3节课分别是...", "speak": "已完成你的请求。",
"action": "%s", "action": "%s",
"reason": "查询完成,已回答用户", "reason": "微调执行完毕并已校验结果",
"goal_check": "已通过 get_overview 查到今天的课程并展示给用户" "goal_check": "目标任务类已完成微调,且关键约束满足"
} }
`, `,
newagentmodel.ExecuteActionContinue, newagentmodel.ExecuteActionContinue,
@@ -167,23 +177,23 @@ func BuildExecuteReActContractText() string {
)) ))
} }
// BuildExecuteDecisionContractTextV2 返回第二轮 abort 协议补齐后的执行输出契约。 // BuildExecuteDecisionContractTextV2 返回补齐 abort 协议后的执行输出契约(有 plan 模式)
func BuildExecuteDecisionContractTextV2() string { func BuildExecuteDecisionContractTextV2() string {
return strings.TrimSpace(fmt.Sprintf(` return strings.TrimSpace(fmt.Sprintf(`
输出协议(严格 JSON 输出协议(严格 JSON
- speak给用户看的话若 action=%s通常留空,最终收口交给 deliver - speak给用户看的话若 action=%s通常留空
- action只能是 %s / %s / %s / %s / %s / %s - action只能是 %s / %s / %s / %s / %s / %s
- reason给后端和日志看的简短说明 - reason给后端和日志看的简短说明
- goal_check输出 %s 或 %s 时必填,对照 done_when 逐条验证 - goal_check输出 %s 或 %s 时必填,对照 done_when 逐条验证
- tool_call输出 %s 时可附带写工具意图(需 confirm输出 %s 时可附带读工具调用 - tool_call输出 %s(写操作,需 confirm或 %s读操作时可附带
- abort仅在输出 %s 时必填,格式为 {"code":"稳定机器码","user_message":"给用户看的终止说明","internal_reason":"给日志看的原因"} - abort仅在 action=%s 时必填,格式为 {"code":"...","user_message":"...","internal_reason":"..."}
- tool_call 与 abort 互斥,禁止同时出现 - tool_call 与 abort 互斥,禁止同时出现
合法示例: 示例:
{ {
"speak": "我来查一下本周的安排。", "speak": "我先查看当前安排。",
"action": "%s", "action": "%s",
"reason": "需要先调用 get_overview 获取当前数据", "reason": "先读取事实再决策",
"tool_call": { "tool_call": {
"name": "get_overview", "name": "get_overview",
"arguments": {} "arguments": {}
@@ -191,20 +201,20 @@ func BuildExecuteDecisionContractTextV2() string {
} }
{ {
"speak": "查询完成。", "speak": "当前步骤完成。",
"action": "%s", "action": "%s",
"reason": "已拿到当前周课程列表", "reason": "步骤完成条件满足",
"goal_check": "已通过 get_overview 确认本周课程列表,满足完成条件" "goal_check": "已满足当前步骤 done_when"
} }
{ {
"speak": "", "speak": "",
"action": "%s", "action": "%s",
"reason": "粗排结果存在业务异常,当前不应继续微调", "reason": "流程不应继续执行",
"abort": { "abort": {
"code": "rough_build_pending_remaining", "code": "execute_abort",
"user_message": "初始排课方案构建异常:粗排后仍有任务未获得初始落位。本轮先终止,请检查粗排算法或任务数据。", "user_message": "当前流程无法继续执行,本轮先终止。",
"internal_reason": "pending tasks remain after rough build" "internal_reason": "execute declared abort"
} }
} }
`, `,
@@ -226,23 +236,23 @@ func BuildExecuteDecisionContractTextV2() string {
)) ))
} }
// BuildExecuteReActContractTextV2 返回第二轮 abort 协议补齐后的 ReAct 输出契约。 // BuildExecuteReActContractTextV2 返回补齐 abort 协议后的自由执行输出契约。
func BuildExecuteReActContractTextV2() string { func BuildExecuteReActContractTextV2() string {
return strings.TrimSpace(fmt.Sprintf(` return strings.TrimSpace(fmt.Sprintf(`
输出协议(严格 JSON 输出协议(严格 JSON
- speak给用户看的话(可以是分析结果、中间进展、或最终回复);若 action=%s通常留空 - speak给用户看的话若 action=%s通常留空
- action只能是 %s / %s / %s / %s / %s - action只能是 %s / %s / %s / %s / %s
- reason给后端和日志看的简短说明 - reason给后端和日志看的简短说明
- goal_check输出 %s 时必填,总结任务完成证据 - goal_check输出 %s 时必填,总结任务完成证据
- tool_call输出 %s 时可附带写工具意图(需 confirm输出 %s 时可附带读工具调用 - tool_call输出 %s(写操作,需 confirm或 %s读操作时可附带
- abort仅在输出 %s 时必填,格式为 {"code":"稳定机器码","user_message":"给用户看的终止说明","internal_reason":"给日志看的原因"} - abort仅在 action=%s 时必填,格式为 {"code":"...","user_message":"...","internal_reason":"..."}
- tool_call 与 abort 互斥,禁止同时出现 - tool_call 与 abort 互斥,禁止同时出现
合法示例: 示例:
{ {
"speak": "我来查一下今天的安排。", "speak": "我先读取当前安排。",
"action": "%s", "action": "%s",
"reason": "需要调用 get_overview 查询", "reason": "先获取事实再决策",
"tool_call": { "tool_call": {
"name": "get_overview", "name": "get_overview",
"arguments": {} "arguments": {}
@@ -250,9 +260,9 @@ func BuildExecuteReActContractTextV2() string {
} }
{ {
"speak": "已将概率论移到周三第1-2节。", "speak": "我准备执行写操作,等待你确认。",
"action": "%s", "action": "%s",
"reason": "用户要求移动课程,写操作需确认", "reason": "写操作需确认",
"tool_call": { "tool_call": {
"name": "move", "name": "move",
"arguments": {"task_id": 5, "new_day": 3, "new_slot_start": 1} "arguments": {"task_id": 5, "new_day": 3, "new_slot_start": 1}
@@ -262,7 +272,7 @@ func BuildExecuteReActContractTextV2() string {
{ {
"speak": "", "speak": "",
"action": "%s", "action": "%s",
"reason": "当前流程不应继续执行,需要正式终止", "reason": "当前流程不应继续执行",
"abort": { "abort": {
"code": "domain_abort", "code": "domain_abort",
"user_message": "当前流程无法继续执行,本轮先终止。", "user_message": "当前流程无法继续执行,本轮先终止。",
@@ -286,92 +296,90 @@ func BuildExecuteReActContractTextV2() string {
)) ))
} }
// BuildExecuteMessages 组装执行阶段的 messages // BuildExecuteMessages 组装执行阶段消息
func BuildExecuteMessages(state *newagentmodel.CommonState, ctx *newagentmodel.ConversationContext) []*schema.Message { func BuildExecuteMessages(state *newagentmodel.CommonState, ctx *newagentmodel.ConversationContext) []*schema.Message {
if state != nil && state.HasPlan() { if state != nil && state.HasPlan() {
return buildStageMessages( return buildExecuteStageMessages(
BuildExecuteSystemPrompt(), BuildExecuteSystemPrompt(),
state,
ctx, ctx,
BuildExecuteUserPrompt(state), buildExecuteStrictJSONUserPrompt(),
) )
} }
// 无 plan纯 ReAct 模式。
return buildStageMessages( return buildExecuteStageMessages(
BuildExecuteReActSystemPrompt(), BuildExecuteReActSystemPrompt(),
state,
ctx, ctx,
BuildExecuteReActUserPrompt(state), buildExecuteStrictJSONUserPrompt(),
) )
} }
// buildExecutePromptWithFormatGuard 统一补一层更硬的 JSON 输出约束。
func buildExecutePromptWithFormatGuard(base string) string {
base = strings.TrimSpace(base)
guard := strings.TrimSpace(`
补充 JSON 约束:
1. 只输出当前 action 真正需要的字段;无关字段直接省略,不要用 ""、{}、[]、null 占位。
2. 若输出 tool_call参数字段名只能是 arguments禁止写成 parameters。
3. tool_call 只能是单个对象:{"name":"工具名","arguments":{...}},不能输出数组。
4. 只有 action=abort 时才允许输出 abort 字段;非 abort 动作不要输出 abort。
5. action=continue / ask_user / confirm 时speak 必须是非空自然语言。`)
if base == "" {
return guard
}
return base + "\n\n" + guard
}
// buildExecuteStrictJSONUserPrompt 统一构造 execute 阶段面向模型的最终用户指令。
func buildExecuteStrictJSONUserPrompt() string {
return strings.TrimSpace(`
请继续当前任务的执行阶段,严格输出 JSON。
输出字段:
- speak
- action
- reason
- goal_check
- tool_call
- abort
补充格式要求:
- 与当前 action 无关的字段直接省略,不要输出空字符串、空对象、空数组或 null 占位
- tool_call 只能写 {"name":"工具名","arguments":{...}},且每轮最多一个
- 不要写 {"tool_call":{"name":"工具名","parameters":{...}}}
- 非 abort 动作不要输出 abort 字段
- action 为 continue / ask_user / confirm 时,必须输出非空 speak
- list_tasks.arguments.status 仅允许 all / existing / suggested / pending 的单值;如需看 existing+suggested请用 all
- list_tasks.arguments.category 仅接受任务类名称,不要传 task_class_ids如 "1,2,3"
- 若读工具结果与已知事实明显冲突,先修正参数并重查一次,再决定是否 ask_user
- 不要连续两轮调用“同一读工具 + 等价 arguments”若上一轮已成功返回下一轮必须换工具或进入 confirm
`)
}
// BuildExecuteUserPrompt 构造有 plan 模式的用户提示词。 // BuildExecuteUserPrompt 构造有 plan 模式的用户提示词。
func BuildExecuteUserPrompt(state *newagentmodel.CommonState) string { func BuildExecuteUserPrompt(_ *newagentmodel.CommonState) string {
var sb strings.Builder return strings.TrimSpace(`
请继续当前任务的执行阶段,严格输出 JSON。
sb.WriteString("请继续当前任务的执行阶段。\n") 输出字段:
sb.WriteString(renderStateSummary(state)) - speak
sb.WriteString("\n") - action
- reason
// 明确列出任务类 IDs与 Plan 阶段保持信息对称,避免 LLM 因 plan 步骤中引用了 ID - goal_check
// 而在 Execute 阶段找不到显式来源,误触 rule 5缺少关键上下文→ ask_user。 - tool_call
if state != nil && len(state.TaskClassIDs) > 0 { - abort
parts := make([]string, len(state.TaskClassIDs)) `)
for i, id := range state.TaskClassIDs {
parts[i] = strconv.Itoa(id)
}
sb.WriteString(fmt.Sprintf("本次排课请求涉及的任务类 ID[%s](上下文已完整,无需向用户追问)\n", strings.Join(parts, ", ")))
sb.WriteString("\n")
}
if state == nil || !state.HasPlan() {
sb.WriteString("当前没有可执行的完整 plan请不要盲目进入执行如有需要请回退到规划阶段。\n")
return strings.TrimSpace(sb.String())
}
if _, ok := state.CurrentPlanStep(); ok {
sb.WriteString("执行要求:\n")
sb.WriteString("1. 始终围绕上方「当前步骤内容」行动。\n")
sb.WriteString("2. 若当前步骤未完成,请继续思考-执行-观察循环。\n")
sb.WriteString("3. 若当前步骤已完成,请输出 action=next_plan并填写 goal_check 说明完成依据。\n")
sb.WriteString("4. 若整个任务已完成,请输出 action=done并填写 goal_check 总结整体证据。\n")
sb.WriteString("5. 若缺少关键用户信息且现有上下文无法补足,请输出 action=ask_user。\n")
sb.WriteString("6. 若你判断当前流程应正式终止,而不是继续执行、追问或写工具,请输出 action=abort并附带 abort 字段。\n")
sb.WriteString("7. 输出 next_plan 或 done 时goal_check 不能为空,必须对照 done_when 逐条验证。\n")
sb.WriteString("\n")
sb.WriteString(BuildExecuteDecisionContractTextV2())
} else {
sb.WriteString("当前 plan 已存在,但当前步骤索引无效;请不要擅自执行其他步骤。\n")
}
return strings.TrimSpace(sb.String())
} }
// BuildExecuteReActUserPrompt 构造纯 ReAct 模式的用户提示词。 // BuildExecuteReActUserPrompt 构造自由执行模式的用户提示词。
func BuildExecuteReActUserPrompt(state *newagentmodel.CommonState) string { func BuildExecuteReActUserPrompt(_ *newagentmodel.CommonState) string {
var sb strings.Builder return strings.TrimSpace(`
请继续当前任务的执行阶段,严格输出 JSON。
sb.WriteString("当前为自由执行模式,无预定义计划步骤。\n") 输出字段:
sb.WriteString("请根据用户意图直接使用工具完成请求。\n\n") - speak
- action
sb.WriteString(renderStateSummary(state)) - reason
sb.WriteString("\n") - goal_check
- tool_call
if state != nil && len(state.TaskClassIDs) > 0 { - abort
parts := make([]string, len(state.TaskClassIDs)) `)
for i, id := range state.TaskClassIDs {
parts[i] = strconv.Itoa(id)
}
sb.WriteString(fmt.Sprintf("本次排课请求涉及的任务类 ID[%s](上下文已完整,无需向用户追问)\n", strings.Join(parts, ", ")))
}
sb.WriteString("\n")
sb.WriteString("判断规则:\n")
sb.WriteString("- 需要查询/读取数据 → action=continue + tool_call读工具\n")
sb.WriteString("- 需要修改/写入数据 → action=confirm + tool_call写工具需用户确认\n")
sb.WriteString("- 缺少关键信息 → action=ask_user\n")
sb.WriteString("- 任务完成 → action=done + goal_check\n")
sb.WriteString("- 当前流程应正式终止 → action=abort + abort\n\n")
sb.WriteString(BuildExecuteReActContractTextV2())
return strings.TrimSpace(sb.String())
} }

View File

@@ -0,0 +1,589 @@
package newagentprompt
import (
"encoding/json"
"fmt"
"sort"
"strconv"
"strings"
newagentmodel "github.com/LoveLosita/smartflow/backend/newAgent/model"
"github.com/cloudwego/eino/schema"
)
const (
executeHistoryKindKey = "newagent_history_kind"
executeHistoryKindCorrectionUser = "llm_correction_prompt"
// executeLoopWindowLimit 控制“当轮 ReAct Loop 窗口”最多保留多少条记录。
// 采用固定窗口能避免上下文无上限增长,且可保持“最近行为”可追踪。
executeLoopWindowLimit = 8
// executeTrimmedObservationText 是重复工具压缩后的 observation 占位文案。
// 当同工具在窗口内出现多次时,只保留最新一条真实结果,其余旧结果统一替换为该文案。
executeTrimmedObservationText = "当前工具调用结果过于久远,已经被删除。"
)
type executeToolSchemaDoc struct {
Name string `json:"name"`
Parameters map[string]any `json:"parameters"`
}
type executeLoopRecord struct {
Thought string
ToolName string
ToolArgs string
Observation string
}
// buildExecuteStageMessages 组装 execute 阶段 4 条消息骨架。
//
// 消息结构(固定):
// 1. message[0] 固定 prompt规则 + 微调硬引导 + 输出约束 + 工具简表)
// 2. message[1] 历史上下文(聊天摘要 + 早期 ReAct 摘要)
// 3. message[2] 当轮 ReAct Loop 窗口thought/reason + tool_call + observation 绑定展示)
// 4. message[3] 当前执行状态(含初始目标、结束判断原则、非目标)
func buildExecuteStageMessages(
stageSystemPrompt string,
state *newagentmodel.CommonState,
ctx *newagentmodel.ConversationContext,
runtimeUserPrompt string,
) []*schema.Message {
msg0 := buildExecuteMessage0(stageSystemPrompt, ctx)
msg1 := buildExecuteMessage1(ctx)
msg2 := buildExecuteMessage2(ctx)
msg3 := buildExecuteMessage3(state, ctx, runtimeUserPrompt)
return []*schema.Message{
schema.SystemMessage(msg0),
{Role: schema.Assistant, Content: msg1},
{Role: schema.Assistant, Content: msg2},
schema.SystemMessage(msg3),
}
}
// buildExecuteMessage0 生成固定规则消息,并附带工具简表。
func buildExecuteMessage0(stageSystemPrompt string, ctx *newagentmodel.ConversationContext) string {
base := strings.TrimSpace(mergeSystemPrompts(ctx, stageSystemPrompt))
if base == "" {
base = "你是 SmartFlow NewAgent 执行器,请继续 execute 阶段。"
}
toolCatalog := renderExecuteToolCatalogCompact(ctx)
if toolCatalog == "" {
return base
}
return base + "\n\n" + toolCatalog
}
// buildExecuteMessage1 生成历史上下文短摘要。
func buildExecuteMessage1(ctx *newagentmodel.ConversationContext) string {
lines := []string{"历史上下文(仅供参考):"}
if ctx == nil {
lines = append(lines,
"- 用户目标:暂无可用历史输入。",
"- 阶段锚点:按当前工具事实推进执行。",
"- 早期 ReAct 摘要:暂无。",
)
return strings.Join(lines, "\n")
}
history := ctx.HistorySnapshot()
firstUser, lastUser := pickExecuteUserInputs(history)
switch {
case firstUser == "":
lines = append(lines, "- 用户目标:暂无可用历史输入。")
case lastUser != "" && lastUser != firstUser:
lines = append(lines, "- 用户目标:"+firstUser+";最近补充:"+lastUser)
default:
lines = append(lines, "- 用户目标:"+firstUser)
}
if hasExecuteRoughBuildDone(ctx) {
lines = append(lines, "- 阶段锚点:粗排已完成,本轮仅做微调,不重新 place。")
} else {
lines = append(lines, "- 阶段锚点:按当前工具事实推进,不做无依据操作。")
}
allLoops := collectExecuteLoopRecords(history)
lines = append(lines, "- 早期 ReAct 摘要:"+buildEarlyExecuteReactSummary(allLoops, executeLoopWindowLimit))
return strings.Join(lines, "\n")
}
// buildExecuteMessage2 生成当轮 ReAct Loop 窗口。
//
// 规则:
// 1. 每条记录都展示 thought/reason + tool_call + observation
// 2. 对窗口内重复工具应用压缩:同工具只保留最新一条真实 observation
// 3. 被压缩的旧 observation 统一替换为占位文案,避免语义断裂。
func buildExecuteMessage2(ctx *newagentmodel.ConversationContext) string {
lines := []string{"当轮 ReAct Loop 记录(窗口):"}
if ctx == nil {
lines = append(lines, "- 暂无可用 ReAct 记录。")
return strings.Join(lines, "\n")
}
allLoops := collectExecuteLoopRecords(ctx.HistorySnapshot())
if len(allLoops) == 0 {
lines = append(lines, "- 暂无可用 ReAct 记录。")
return strings.Join(lines, "\n")
}
windowLoops := tailExecuteLoops(allLoops, executeLoopWindowLimit)
windowLoops = compressExecuteLoopObservationsByTool(windowLoops)
for i, loop := range windowLoops {
lines = append(lines, fmt.Sprintf("%d) thought/reason%s", i+1, loop.Thought))
lines = append(lines, fmt.Sprintf(" tool_call%s", renderExecuteToolCallText(loop.ToolName, loop.ToolArgs)))
lines = append(lines, fmt.Sprintf(" observation%s", loop.Observation))
}
return strings.Join(lines, "\n")
}
// buildExecuteMessage3 生成当前执行状态与执行锚点。
func buildExecuteMessage3(state *newagentmodel.CommonState, ctx *newagentmodel.ConversationContext, runtimeUserPrompt string) string {
lines := []string{"当前执行状态:"}
roundUsed, maxRounds := 0, newagentmodel.DefaultMaxRounds
modeText := "自由执行(无预定义步骤)"
if state != nil {
roundUsed = state.RoundUsed
if state.MaxRounds > 0 {
maxRounds = state.MaxRounds
}
if state.HasPlan() {
modeText = "计划执行(有预定义步骤)"
}
}
lines = append(lines,
fmt.Sprintf("- 当前轮次:%d/%d", roundUsed, maxRounds),
"- 当前模式:"+modeText,
)
goal := extractExecuteInitialGoal(ctx)
if goal == "" {
goal = "暂无可用目标描述,请按当前上下文稳步推进。"
}
lines = append(lines, "执行锚点:")
lines = append(lines, "- 初始用户目标:"+goal)
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。")
}
// 兼容上层传入的执行指令;若为空则使用固定收口指令。
instruction := strings.TrimSpace(runtimeUserPrompt)
if instruction == "" {
instruction = "请继续当前任务执行阶段,严格输出 JSON。"
} else {
instruction = firstExecuteLine(instruction)
}
lines = append(lines, "本轮指令:"+instruction)
return strings.Join(lines, "\n")
}
// renderExecuteToolCatalogCompact 将工具 schema 渲染成简表,避免大段 JSON 示例占用上下文。
func renderExecuteToolCatalogCompact(ctx *newagentmodel.ConversationContext) string {
if ctx == nil {
return ""
}
schemas := ctx.ToolSchemasSnapshot()
if len(schemas) == 0 {
return ""
}
lines := []string{"可用工具(简表):"}
for i, schemaItem := range schemas {
name := strings.TrimSpace(schemaItem.Name)
desc := strings.TrimSpace(schemaItem.Desc)
if name == "" {
continue
}
if desc == "" {
desc = "无描述"
}
lines = append(lines, fmt.Sprintf("%d. %s%s", i+1, name, desc))
doc := parseExecuteToolSchema(schemaItem.SchemaText)
paramSummary := renderExecuteToolParamSummary(doc.Parameters)
lines = append(lines, " 参数:"+paramSummary)
returnType, returnSample := renderExecuteToolReturnHint(name)
lines = append(lines, " 返回类型:"+returnType)
lines = append(lines, " 返回示例:"+returnSample)
}
return strings.Join(lines, "\n")
}
// renderExecuteToolReturnHint 返回工具的“返回类型 + 最小示例”。
//
// 说明:
// 1. 所有工具当前都返回 string自然语言这里主要补“内容形态示例”减少模型盲猜
// 2. 示例只保留最小片段,避免工具说明过长挤占上下文窗口。
func renderExecuteToolReturnHint(toolName string) (returnType string, sample string) {
returnType = "string自然语言文本"
switch strings.ToLower(strings.TrimSpace(toolName)) {
case "get_overview":
return returnType, "规划窗口共27天...课程占位条目34个...任务清单(全量,已过滤课程)..."
case "list_tasks":
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 "find_free":
return returnType, "兼容别名,返回同 find_first_free。"
case "query_range":
return returnType, "第5天第3-6节第3节空、第4节空..."
case "place":
return returnType, "已将 [35]... 预排到第5天第3-4节。"
case "move":
return returnType, "已将 [35]... 从第3天第5-6节移至第5天第3-4节。"
case "swap":
return returnType, "交换完成:[35]... ↔ [36]..."
case "batch_move":
return returnType, "批量移动完成2个任务全部成功。"
case "unplace":
return returnType, "已将 [35]... 移除,恢复为待安排状态。"
default:
return returnType, "自然语言结果(成功/失败原因/关键数据摘要)。"
}
}
func parseExecuteToolSchema(schemaText string) executeToolSchemaDoc {
doc := executeToolSchemaDoc{Parameters: map[string]any{}}
schemaText = strings.TrimSpace(schemaText)
if schemaText == "" {
return doc
}
if err := json.Unmarshal([]byte(schemaText), &doc); err != nil {
return doc
}
if doc.Parameters == nil {
doc.Parameters = map[string]any{}
}
return doc
}
func renderExecuteToolParamSummary(parameters map[string]any) string {
if len(parameters) == 0 {
return "{}"
}
keys := make([]string, 0, len(parameters))
for key := range parameters {
keys = append(keys, key)
}
sort.Strings(keys)
parts := make([]string, 0, len(keys))
for _, key := range keys {
status := "可选"
typeText := ""
switch typed := parameters[key].(type) {
case string:
status = "必填"
typeText = strings.TrimSpace(typed)
case map[string]any:
if required, ok := typed["required"].(bool); ok && required {
status = "必填"
}
typeText = strings.TrimSpace(asExecuteString(typed["type"]))
if enumRaw, ok := typed["enum"].([]any); ok && len(enumRaw) > 0 {
enumText := make([]string, 0, len(enumRaw))
for _, item := range enumRaw {
enumText = append(enumText, fmt.Sprintf("%v", item))
}
if typeText == "" {
typeText = "enum"
}
typeText += ":" + strings.Join(enumText, "/")
}
}
if typeText == "" {
parts = append(parts, fmt.Sprintf("%s(%s)", key, status))
continue
}
parts = append(parts, fmt.Sprintf("%s(%s,%s)", key, status, typeText))
}
return strings.Join(parts, "")
}
// collectExecuteLoopRecords 从历史中提取 ReAct 记录。
//
// 提取策略:
// 1. 以 assistant tool_call 消息为主键;
// 2. 关联同 ToolCallID 的 tool result 作为 observation
// 3. 向前回溯最近一条 assistant 文本消息作为 thought/reason。
func collectExecuteLoopRecords(history []*schema.Message) []executeLoopRecord {
if len(history) == 0 {
return nil
}
toolResultByCallID := make(map[string]*schema.Message, len(history))
for _, msg := range history {
if msg == nil || msg.Role != schema.Tool {
continue
}
callID := strings.TrimSpace(msg.ToolCallID)
if callID == "" {
continue
}
toolResultByCallID[callID] = msg
}
records := make([]executeLoopRecord, 0, len(history))
for i, msg := range history {
if msg == nil || msg.Role != schema.Assistant || len(msg.ToolCalls) == 0 {
continue
}
thought := findExecuteThoughtBefore(history, i)
for _, call := range msg.ToolCalls {
toolName := strings.TrimSpace(call.Function.Name)
if toolName == "" {
toolName = "unknown_tool"
}
toolArgs := compactExecuteText(call.Function.Arguments, 160)
if toolArgs == "" {
toolArgs = "{}"
}
observation := "该工具调用尚未返回结果。"
callID := strings.TrimSpace(call.ID)
if callID != "" {
if resultMsg, ok := toolResultByCallID[callID]; ok && resultMsg != nil {
text := strings.TrimSpace(resultMsg.Content)
if text != "" {
observation = text
}
}
}
records = append(records, executeLoopRecord{
Thought: thought,
ToolName: toolName,
ToolArgs: toolArgs,
Observation: observation,
})
}
}
return records
}
func findExecuteThoughtBefore(history []*schema.Message, index int) string {
for i := index - 1; i >= 0; i-- {
msg := history[i]
if msg == nil || msg.Role != schema.Assistant {
continue
}
if len(msg.ToolCalls) > 0 {
continue
}
content := compactExecuteText(msg.Content, 140)
if content == "" {
continue
}
return content
}
return "(未记录)"
}
func tailExecuteLoops(records []executeLoopRecord, limit int) []executeLoopRecord {
if len(records) == 0 {
return nil
}
if limit <= 0 || len(records) <= limit {
result := make([]executeLoopRecord, len(records))
copy(result, records)
return result
}
result := make([]executeLoopRecord, limit)
copy(result, records[len(records)-limit:])
return result
}
// compressExecuteLoopObservationsByTool 对窗口内重复工具做 observation 压缩。
//
// 规则:
// 1. 以“工具名”作为压缩键;
// 2. 同工具仅保留最新一条 observation 原文;
// 3. 旧记录保持 thought/tool_call不丢记录仅替换 observation。
func compressExecuteLoopObservationsByTool(records []executeLoopRecord) []executeLoopRecord {
if len(records) == 0 {
return records
}
latestIndexByTool := make(map[string]int, len(records))
for i := len(records) - 1; i >= 0; i-- {
key := strings.ToLower(strings.TrimSpace(records[i].ToolName))
if key == "" {
key = "unknown_tool"
}
if _, exists := latestIndexByTool[key]; !exists {
latestIndexByTool[key] = i
}
}
result := make([]executeLoopRecord, len(records))
copy(result, records)
for i := range result {
key := strings.ToLower(strings.TrimSpace(result[i].ToolName))
if key == "" {
key = "unknown_tool"
}
if latestIndexByTool[key] != i {
result[i].Observation = executeTrimmedObservationText
}
}
return result
}
func renderExecuteToolCallText(toolName, toolArgs string) string {
toolName = strings.TrimSpace(toolName)
if toolName == "" {
toolName = "unknown_tool"
}
toolArgs = strings.TrimSpace(toolArgs)
if toolArgs == "" {
toolArgs = "{}"
}
return toolName + "(" + toolArgs + ")"
}
func buildEarlyExecuteReactSummary(records []executeLoopRecord, windowLimit int) string {
if len(records) == 0 {
return "暂无。"
}
if len(records) <= windowLimit {
return "无(当前窗口已覆盖全部 ReAct 记录)。"
}
early := records[:len(records)-windowLimit]
toolCounts := make(map[string]int, len(early))
for _, record := range early {
key := strings.TrimSpace(record.ToolName)
if key == "" {
key = "unknown_tool"
}
toolCounts[key]++
}
names := make([]string, 0, len(toolCounts))
for name := range toolCounts {
names = append(names, name)
}
sort.Strings(names)
parts := make([]string, 0, len(names))
for _, name := range names {
parts = append(parts, fmt.Sprintf("%s×%d", name, toolCounts[name]))
}
return fmt.Sprintf("已折叠 %d 条旧记录,涉及:%s。", len(early), strings.Join(parts, "、"))
}
func extractExecuteInitialGoal(ctx *newagentmodel.ConversationContext) string {
if ctx == nil {
return ""
}
history := ctx.HistorySnapshot()
firstUser, _ := pickExecuteUserInputs(history)
return firstUser
}
func hasExecuteRoughBuildDone(ctx *newagentmodel.ConversationContext) bool {
if ctx == nil {
return false
}
for _, block := range ctx.PinnedBlocksSnapshot() {
if strings.TrimSpace(block.Key) == "rough_build_done" {
return true
}
}
return false
}
func pickExecuteUserInputs(history []*schema.Message) (first string, last string) {
realUsers := make([]string, 0, 2)
for _, msg := range history {
if msg == nil || msg.Role != schema.User {
continue
}
if isExecuteCorrectionPrompt(msg) {
continue
}
text := compactExecuteText(msg.Content, 120)
if text == "" {
continue
}
realUsers = append(realUsers, text)
}
if len(realUsers) == 0 {
return "", ""
}
return realUsers[0], realUsers[len(realUsers)-1]
}
func isExecuteCorrectionPrompt(msg *schema.Message) bool {
if msg == nil || msg.Role != schema.User {
return false
}
if msg.Extra != nil {
if kind, ok := msg.Extra[executeHistoryKindKey].(string); ok && strings.TrimSpace(kind) == executeHistoryKindCorrectionUser {
return true
}
}
content := strings.TrimSpace(msg.Content)
return strings.Contains(content, "请重新分析当前状态,输出正确的内容。")
}
func compactExecuteText(content string, maxLen int) string {
content = firstExecuteLine(content)
content = strings.TrimSpace(content)
if content == "" {
return ""
}
runes := []rune(content)
if len(runes) <= maxLen {
return content
}
if maxLen <= 3 {
return string(runes[:maxLen])
}
return string(runes[:maxLen-3]) + "..."
}
func firstExecuteLine(content string) string {
content = strings.TrimSpace(content)
if content == "" {
return ""
}
lines := strings.Split(content, "\n")
return strings.TrimSpace(lines[0])
}
func asExecuteString(value any) string {
if text, ok := value.(string); ok {
return text
}
return ""
}
func renderExecuteTaskClassIDs(state *newagentmodel.CommonState) string {
if state == nil || len(state.TaskClassIDs) == 0 {
return ""
}
parts := make([]string, len(state.TaskClassIDs))
for i, id := range state.TaskClassIDs {
parts[i] = strconv.Itoa(id)
}
return fmt.Sprintf("task_class_ids=[%s]", strings.Join(parts, ","))
}

View File

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

View File

@@ -178,7 +178,12 @@ DB 记录:
### 4.1 get_overview ### 4.1 get_overview
获取规划窗口的粗粒度总览,用于建立全局感知 获取规划窗口总览(任务视角,全量返回)
行为约束:
- 保留课程占位统计例如“第1天占2/12”避免误判可用空间。
- 每日明细只展开任务(非课程),课程不进入任务明细列表。
- 在当前阶段(窗口通常不超过 30 天)直接全量返回,不做截断。
**入参:** **入参:**
@@ -186,25 +191,19 @@ DB 记录:
``` ```
规划窗口共13天每天12个时段总计156个时段。 规划窗口共13天每天12个时段总计156个时段。
当前已占用48个空闲108个。待安排任务3个。 当前已占用48个空闲108个。课程占位条目7个仅用于占位统计任务条目已安排(existing)1个、已预排(suggested)2个、待安排(pending)3个。
每日概况: 每日概况:
第1天占6/12 — [1]高等数学(1-2节) [2]英语(3-4节) [4]体育(5-6节) 第1天占6/12课程占6/12任务占0/12 — 任务:无
第2天占2/12 — [5]物理(3-4节) 第2天占2/12课程占2/12任务占0/12 — 任务:无
第3天占0/12 第3天总占2/12课程占0/12任务占2/12 — 任务:[35]第一章随机事件与概率(suggested,第5-6节)
第4天占8/12 — [1]高等数学(1-2节) [6]线代(3-4节) [8]程序设计(9-10节) 第4天总占4/12课程占2/12任务占2/12 — 任务:[36]第二章随机变量(suggested,第7-8节)
第5天占0/12 ...
第6天占2/12 — [2]英语(1-2节)
第7天占2/12 — [10]思政(1-2节,可嵌入)
第8天占4/12 — [1]高等数学(1-2节) [5]物理(3-4节)
第9天占0/12
第10天占0/12
第11天占0/12
第12天占0/12
第13天占0/12
可嵌入时段第7天 [10]思政(1-2节) 任务清单(全量,已过滤课程):
待安排:[3]复习线代(需3时段) [7]写实验报告(需2时段) [9]小组讨论(需2时段) [35]第一章随机事件与概率 | 状态:suggested | 类别:概率论 | 时段:第3天(5-6节)
[36]第二章随机变量 | 状态:suggested | 类别:概率论 | 时段:第4天(7-8节)
[37]第三章多维随机变量 | 状态:pending | 类别:概率论 | 需2个连续时段
``` ```
--- ---
@@ -252,9 +251,12 @@ DB 记录:
--- ---
### 4.3 find_free ### 4.3 find_first_free
查找满足指定连续时段长度的空闲位置 按天顺序查找“首个可用位”(先纯空位,再可嵌入位),并返回该日详细信息
兼容说明:
- `find_free` 仍保留为兼容别名,行为与 `find_first_free` 完全一致。
**入参:** **入参:**
@@ -266,18 +268,16 @@ DB 记录:
**返回示例:** **返回示例:**
``` ```
满足3个连续空闲时段的位置 首个可用位置第5天第1-2节可直接放置
匹配条件需要2个连续时段。
第2天 第5-8节4时段连续空闲 当日负载总占6/12课程占2/12任务占4/12
第3天 第1-6节6时段连续空闲 当日任务明细(全量,已过滤课程):
第3天 第7-12节6时段连续空闲 - [35]第一章随机事件与概率 | 状态:suggested | 类别:概率论 | 时段:第3-4节
第5天 第1-12节12时段连续空闲 - [36]第二章随机变量 | 状态:suggested | 类别:概率论 | 时段:第7-8节
第6天 第3-5节3时段连续空闲 当日连续空闲区:
第9天 第1-3节(3时段连续空闲) - 第1-2节(2时段连续空闲)
第10天 第5-7节(3时段连续空闲) - 第5-6节(2时段连续空闲)
- 第9-12节4时段连续空闲
可嵌入位置(水课时段,可叠加任务):
第7天 第1-2节[10]思政,当前无嵌入任务)
``` ```
--- ---
@@ -290,8 +290,8 @@ DB 记录:
| 字段 | 类型 | 必填 | 说明 | | 字段 | 类型 | 必填 | 说明 |
|------|------|------|------| |------|------|------|------|
| category | string | 否 | 过滤类别(对应 TaskClass.Name如"课程"、"学习" | | category | string | 否 | 过滤类别(对应 TaskClass.Name如"课程"、"学习";不支持 task_class_ids 列表 |
| status | string | 否 | existing / suggested / pending / all默认 all | | status | string | 否 | existing / suggested / pending / all默认 all(仅支持单值,不支持 `existing,suggested` 这类拼接) |
**返回示例(待安排):** **返回示例(待安排):**
@@ -408,7 +408,7 @@ DB 记录:
### 5.2 move ### 5.2 move
移动已落位任务到新位置。 移动已预排任务(仅 suggested到新位置。
**入参:** **入参:**
@@ -445,6 +445,10 @@ DB 记录:
移动失败:[3]复习线代 当前为待安排状态,请使用 place 放置。 移动失败:[3]复习线代 当前为待安排状态,请使用 place 放置。
``` ```
```
移动失败:[2]英语 当前为已安排existing任务不允许 move仅 suggested 任务可移动。
```
--- ---
### 5.3 swap ### 5.3 swap
@@ -484,7 +488,7 @@ DB 记录:
### 5.4 batch_move ### 5.4 batch_move
批量原子移动多个任务,要么全部成功,要么全部回滚。 批量原子移动多个任务(仅 suggested,要么全部成功,要么全部回滚。
**入参:** **入参:**
@@ -553,7 +557,7 @@ DB 记录:
### 状态约束 ### 状态约束
- pending 任务只能 place不能 move / swap / unplace - pending 任务只能 place不能 move / swap / unplace
- suggested 任务可以 move / swap / unplace - suggested 任务可以 move / swap / unplace
- existing 任务可以 move / swap / unplace - existing 任务不能 move / batch_move仅作已安排事实层
- 状态不符时返回明确错误信息 - 状态不符时返回明确错误信息
### 返回格式 ### 返回格式
@@ -570,7 +574,7 @@ DB 记录:
### 嵌入任务规则 ### 嵌入任务规则
- `can_embed=true` 的任务(水课)允许其他任务嵌入到同一时段 - `can_embed=true` 的任务(水课)允许其他任务嵌入到同一时段
- 嵌入任务占位时不触发冲突检测(与宿主共存) - 嵌入任务占位时不触发冲突检测(与宿主共存)
- `find_free` 返回结果中标注可嵌入时段,让 LLM 知道哪里可以叠加 - `find_first_free` 返回首个命中位,并附当日详细负载;`find_free` 为兼容别名
- `place` 到可嵌入时段时,若已有宿主任务,自动标记 embed_host 关系 - `place` 到可嵌入时段时,若已有宿主任务,自动标记 embed_host 关系
- 嵌入任务的 locked 继承宿主:宿主不可移动时,嵌入任务也不可单独移动 - 嵌入任务的 locked 继承宿主:宿主不可移动时,嵌入任务也不可单独移动

View File

@@ -13,12 +13,16 @@ import (
// - 只报当前真实状态,不做建议/推荐/假设 // - 只报当前真实状态,不做建议/推荐/假设
// - 不暴露 source、source_id、event_type 内部字段 // - 不暴露 source、source_id、event_type 内部字段
// GetOverview 获取规划窗口的粗粒度总览,用于建立全局感知 // GetOverview 获取规划窗口总览(任务视角,全量)
// 无参数,返回整个窗口的占用统计 + 每日概况 + 可嵌入时段 + 待安排任务。 //
// 设计约束:
// 1. 日内“总占用”保留课程占位影响,避免 LLM 误判可用空间;
// 2. 明细层不展开课程列表,只展开任务(非课程)清单;
// 3. 当前按“窗口不超过 30 天”场景直接全量返回,不做结果截断。
func GetOverview(state *ScheduleState) string { func GetOverview(state *ScheduleState) string {
totalSlots := state.Window.TotalDays * 12 totalSlots := state.Window.TotalDays * 12
// 1. 统计总占用时段数(排除嵌入任务,嵌入与宿主共享时段) // 1. 统计总占用(含课程占位)与空闲
totalOccupied := 0 totalOccupied := 0
for i := range state.Tasks { for i := range state.Tasks {
t := &state.Tasks[i] t := &state.Tasks[i]
@@ -31,80 +35,47 @@ func GetOverview(state *ScheduleState) string {
} }
totalFree := totalSlots - totalOccupied totalFree := totalSlots - totalOccupied
// 2. 统计任务状态分布。 // 2. 统计任务视角”状态分布,并单独统计课程条目数
existingCount := 0 taskExistingCount := 0
suggestedCount := 0 taskSuggestedCount := 0
pendingCount := 0 taskPendingCount := 0
courseExistingCount := 0
for i := range state.Tasks { for i := range state.Tasks {
task := state.Tasks[i] task := state.Tasks[i]
if isCourseScheduleTask(task) {
if IsExistingTask(task) {
courseExistingCount++
}
continue
}
switch { switch {
case IsPendingTask(task): case IsPendingTask(task):
pendingCount++ taskPendingCount++
case IsSuggestedTask(task): case IsSuggestedTask(task):
suggestedCount++ taskSuggestedCount++
case IsExistingTask(task): case IsExistingTask(task):
existingCount++ taskExistingCount++
} }
} }
var sb strings.Builder var sb strings.Builder
sb.WriteString(fmt.Sprintf("规划窗口共%d天每天12个时段总计%d个时段。\n", state.Window.TotalDays, totalSlots)) sb.WriteString(fmt.Sprintf("规划窗口共%d天每天12个时段总计%d个时段。\n", state.Window.TotalDays, totalSlots))
sb.WriteString(fmt.Sprintf("当前已占用%d个空闲%d个。已确定任务%d个已预排任务%d个待安排任务%d个。\n", totalOccupied, totalFree, existingCount, suggestedCount, pendingCount)) sb.WriteString(fmt.Sprintf(
"当前已占用%d个空闲%d个。课程占位条目%d个仅用于占位统计任务条目已安排(existing)%d个、已预排(suggested)%d个、待安排(pending)%d个。\n",
totalOccupied, totalFree, courseExistingCount, taskExistingCount, taskSuggestedCount, taskPendingCount,
))
// 3. 逐天概况 // 3. 逐天总览:保留课程占位计数,但只展示任务明细
sb.WriteString("\n每日概况\n") sb.WriteString("\n每日概况\n")
for day := 1; day <= state.Window.TotalDays; day++ { for day := 1; day <= state.Window.TotalDays; day++ {
sb.WriteString(buildOverviewDayLine(state, day) + "\n") sb.WriteString(buildTaskOnlyOverviewDayLine(state, day) + "\n")
} }
// 4. 可嵌入时段汇总(单独列出,方便 LLM 快速定位)。 // 4. 任务清单全量展开(不截断)。
embeddable := getEmbeddableTasks(state) sb.WriteString("\n任务清单全量已过滤课程\n")
if len(embeddable) > 0 { sb.WriteString(buildTaskOnlyOverviewList(state))
sb.WriteString("\n可嵌入时段")
parts := make([]string, 0, len(embeddable))
for _, t := range embeddable {
for _, slot := range t.Slots {
label := formatTaskLabel(*t)
embedStatus := "当前无嵌入任务"
if t.EmbeddedBy != nil {
guest := state.TaskByStateID(*t.EmbeddedBy)
if guest != nil {
embedStatus = fmt.Sprintf("已嵌入[%d]%s", guest.StateID, guest.Name)
}
}
parts = append(parts, fmt.Sprintf("第%d天 %s(%s)", slot.Day, label, embedStatus))
}
}
sb.WriteString(strings.Join(parts, "") + "\n")
}
// 5. 已预排任务汇总 // 5. 任务类约束(排课策略与限制)
if suggestedCount > 0 {
sb.WriteString("已预排:")
suggestedParts := make([]string, 0, suggestedCount)
for i := range state.Tasks {
t := &state.Tasks[i]
if IsSuggestedTask(*t) {
suggestedParts = append(suggestedParts, fmt.Sprintf("[%d]%s(%s)", t.StateID, t.Name, formatTaskSlotsBrief(t.Slots)))
}
}
sb.WriteString(strings.Join(suggestedParts, " ") + "\n")
}
// 6. 待安排任务汇总。
if pendingCount > 0 {
sb.WriteString("待安排:")
pendingParts := make([]string, 0, pendingCount)
for i := range state.Tasks {
t := &state.Tasks[i]
if IsPendingTask(*t) {
pendingParts = append(pendingParts, fmt.Sprintf("[%d]%s(需%d时段)", t.StateID, t.Name, t.Duration))
}
}
sb.WriteString(strings.Join(pendingParts, " ") + "\n")
}
// 7. 任务类约束(排课策略与限制)。
if len(state.TaskClasses) > 0 { if len(state.TaskClasses) > 0 {
sb.WriteString("\n任务类约束排课时请遵守\n") sb.WriteString("\n任务类约束排课时请遵守\n")
for _, tc := range state.TaskClasses { for _, tc := range state.TaskClasses {
@@ -226,12 +197,16 @@ func queryRangeSpecific(state *ScheduleState, day, startSlot, endSlot int) strin
return sb.String() return sb.String()
} }
// FindFree 查找满足指定连续时段长度的空闲位置 // FindFirstFree 查找首个可用空位,并返回该日详细信息
// duration 必填day 选填nil 表示搜索全部天)。 //
// 返回所有 >= duration 的空闲连续区间 + 可嵌入位置。 // 说明:
func FindFree(state *ScheduleState, duration int, day *int) string { // 1. 参数与旧 find_free 保持一致duration/day
var sb strings.Builder // 2. 返回“首个命中候选位 + 当日负载明细”,供 LLM 直接决策;
sb.WriteString(fmt.Sprintf("满足%d个连续空闲时段的位置\n\n", duration)) // 3. 当前阶段按用户要求全量返回,不做文本截断。
func FindFirstFree(state *ScheduleState, duration int, day *int) string {
if duration <= 0 {
return "查询失败duration 必须大于 0。"
}
// 1. 确定搜索范围。 // 1. 确定搜索范围。
days := make([]int, 0) days := make([]int, 0)
@@ -246,48 +221,262 @@ func FindFree(state *ScheduleState, duration int, day *int) string {
} }
} }
// 2. 逐天查找满足条件的空闲区间 // 2. 按天从前往后寻找“首个可直接放置”的空位
found := 0
for _, d := range days { for _, d := range days {
freeRanges := findFreeRangesOnDay(state, d) freeRanges := findFreeRangesOnDay(state, d)
for _, r := range freeRanges { for _, r := range freeRanges {
rDur := r.slotEnd - r.slotStart + 1 rDur := r.slotEnd - r.slotStart + 1
if rDur >= duration { if rDur < duration {
sb.WriteString(fmt.Sprintf("第%d天 第%s%d时段连续空闲\n", d, formatSlotRange(r.slotStart, r.slotEnd), rDur))
found++
}
}
}
if found == 0 {
sb.WriteString("未找到满足条件的空闲时段。\n")
}
// 3. 可嵌入位置单独列出(水课时段,可叠加任务)。
embeddable := getEmbeddableTasks(state)
if len(embeddable) > 0 {
sb.WriteString("\n可嵌入位置水课时段可叠加任务\n")
for _, t := range embeddable {
for _, slot := range t.Slots {
// 检查是否在搜索范围内。
if day != nil && slot.Day != *day {
continue continue
} }
embedStatus := "当前无嵌入任务" slotStart := r.slotStart
if t.EmbeddedBy != nil { slotEnd := r.slotStart + duration - 1
guest := state.TaskByStateID(*t.EmbeddedBy) return buildFindFirstFreeReport(state, d, duration, slotStart, slotEnd, false, nil)
if guest != nil {
embedStatus = fmt.Sprintf("已嵌入[%d]%s", guest.StateID, guest.Name)
}
}
sb.WriteString(fmt.Sprintf("第%d天 第%s[%d]%s%s\n", slot.Day, formatSlotRange(slot.SlotStart, slot.SlotEnd), t.StateID, t.Name, embedStatus))
}
} }
} }
// 3. 若没有纯空位,再尝试首个可嵌入宿主时段。
for _, d := range days {
host, slotStart, slotEnd := findFirstEmbeddablePosition(state, d, duration)
if host != nil {
return buildFindFirstFreeReport(state, d, duration, slotStart, slotEnd, true, host)
}
}
// 4. 无可用位置时返回摘要,辅助 LLM 判断是否需要换天或降时长。
var sb strings.Builder
sb.WriteString(fmt.Sprintf("未找到满足%d个连续时段的可用位置。\n", duration))
sb.WriteString("各天最大连续空闲区前10天\n")
limit := 10
if len(days) < limit {
limit = len(days)
}
for i := 0; i < limit; i++ {
d := days[i]
freeRanges := findFreeRangesOnDay(state, d)
maxDur := 0
for _, r := range freeRanges {
dur := r.slotEnd - r.slotStart + 1
if dur > maxDur {
maxDur = dur
}
}
sb.WriteString(fmt.Sprintf("第%d天最大连续空闲%d节\n", d, maxDur))
}
return sb.String() return sb.String()
} }
// FindFree 是 find_first_free 的兼容别名。
// 保留该入口可避免旧提示词和历史轨迹中的工具名失效。
func FindFree(state *ScheduleState, duration int, day *int) string {
return FindFirstFree(state, duration, day)
}
// buildFindFirstFreeReport 构造首个可用位的详细报告。
func buildFindFirstFreeReport(
state *ScheduleState,
day int,
duration int,
slotStart int,
slotEnd int,
isEmbedded bool,
host *ScheduleTask,
) string {
var sb strings.Builder
if isEmbedded && host != nil {
sb.WriteString(fmt.Sprintf("首个可用位置:第%d天第%s可嵌入宿主 [%d]%s。\n",
day, formatSlotRange(slotStart, slotEnd), host.StateID, host.Name))
} else {
sb.WriteString(fmt.Sprintf("首个可用位置:第%d天第%s可直接放置。\n", day, formatSlotRange(slotStart, slotEnd)))
}
sb.WriteString(fmt.Sprintf("匹配条件:需要%d个连续时段。\n", duration))
dayTotalOccupied := countDayOccupied(state, day)
dayTaskOccupied := countDayTaskOccupied(state, day)
dayCourseOccupied := dayTotalOccupied - dayTaskOccupied
sb.WriteString(fmt.Sprintf("当日负载:总占%d/12课程占%d/12任务占%d/12。\n", dayTotalOccupied, dayCourseOccupied, dayTaskOccupied))
sb.WriteString("当日任务明细(全量,已过滤课程):\n")
taskEntries := collectTaskEntriesOnDay(state, day)
if len(taskEntries) == 0 {
sb.WriteString(" 无任务明细。\n")
} else {
for _, td := range taskEntries {
sb.WriteString(fmt.Sprintf(" - [%d]%s | 状态:%s | 类别:%s | 时段:%s\n",
td.task.StateID, td.task.Name, taskStatusLabel(*td.task), td.task.Category, formatSlotRange(td.slotStart, td.slotEnd)))
}
}
sb.WriteString("当日连续空闲区:\n")
freeRanges := findFreeRangesOnDay(state, day)
if len(freeRanges) == 0 {
sb.WriteString(" 无连续空闲区。\n")
} else {
for _, r := range freeRanges {
sb.WriteString(" - " + buildFreeRangeLine(r) + "\n")
}
}
return sb.String()
}
// isCourseScheduleTask 判断任务是否属于“课程占位”。
// 用于 get_overview 的任务视角过滤:课程只参与占位统计,不参与任务明细展开。
func isCourseScheduleTask(task ScheduleTask) bool {
if task.Source != "event" {
return false
}
if strings.EqualFold(strings.TrimSpace(task.EventType), "course") {
return true
}
return strings.TrimSpace(task.Category) == "课程"
}
// taskStatusLabel 返回任务状态标签existing/suggested/pending
func taskStatusLabel(task ScheduleTask) string {
switch {
case IsPendingTask(task):
return "pending"
case IsSuggestedTask(task):
return "suggested"
default:
return "existing"
}
}
// collectTaskEntriesOnDay 收集某天的“任务视角”明细(过滤课程)。
func collectTaskEntriesOnDay(state *ScheduleState, day int) []taskOnDay {
all := getTasksOnDay(state, day)
result := make([]taskOnDay, 0, len(all))
for _, item := range all {
if item.task == nil {
continue
}
if isCourseScheduleTask(*item.task) {
continue
}
result = append(result, item)
}
return result
}
// countDayTaskOccupied 统计某天任务(过滤课程)的占用时段数。
func countDayTaskOccupied(state *ScheduleState, day int) int {
occupied := 0
for i := range state.Tasks {
t := state.Tasks[i]
if isCourseScheduleTask(t) {
continue
}
if t.EmbedHost != nil {
continue // 嵌入任务不重复计占用
}
for _, slot := range t.Slots {
if slot.Day == day {
occupied += slot.SlotEnd - slot.SlotStart + 1
}
}
}
return occupied
}
// buildTaskOnlyOverviewDayLine 生成某天“课程占位 + 任务明细”的摘要行。
func buildTaskOnlyOverviewDayLine(state *ScheduleState, day int) string {
totalOccupied := countDayOccupied(state, day)
taskOccupied := countDayTaskOccupied(state, day)
courseOccupied := totalOccupied - taskOccupied
taskEntries := collectTaskEntriesOnDay(state, day)
var sb strings.Builder
sb.WriteString(fmt.Sprintf("第%d天总占%d/12课程占%d/12任务占%d/12", day, totalOccupied, courseOccupied, taskOccupied))
if len(taskEntries) == 0 {
sb.WriteString(" — 任务:无")
return sb.String()
}
sb.WriteString(" — 任务:")
for i, item := range taskEntries {
if i > 0 {
sb.WriteString(" ")
}
sb.WriteString(fmt.Sprintf("[%d]%s(%s,%s)",
item.task.StateID,
item.task.Name,
taskStatusLabel(*item.task),
formatSlotRange(item.slotStart, item.slotEnd),
))
}
return sb.String()
}
// buildTaskOnlyOverviewList 输出“全量任务清单”(过滤课程)。
func buildTaskOnlyOverviewList(state *ScheduleState) string {
tasks := make([]ScheduleTask, 0, len(state.Tasks))
for i := range state.Tasks {
task := state.Tasks[i]
if isCourseScheduleTask(task) {
continue
}
tasks = append(tasks, task)
}
if len(tasks) == 0 {
return "无任务条目。\n"
}
sort.Slice(tasks, func(i, j int) bool { return tasks[i].StateID < tasks[j].StateID })
var sb strings.Builder
for _, t := range tasks {
classID := ""
if t.TaskClassID > 0 {
classID = fmt.Sprintf(" | task_class_id:%d", t.TaskClassID)
}
if IsPendingTask(t) {
sb.WriteString(fmt.Sprintf("[%d]%s | 状态:%s | 类别:%s%s | 需%d个连续时段\n",
t.StateID, t.Name, taskStatusLabel(t), t.Category, classID, t.Duration))
continue
}
sb.WriteString(fmt.Sprintf("[%d]%s | 状态:%s | 类别:%s%s | 时段:%s\n",
t.StateID, t.Name, taskStatusLabel(t), t.Category, classID, formatTaskSlotsBrief(t.Slots)))
}
return sb.String()
}
// findFirstEmbeddablePosition 查找某天首个可嵌入位置。
func findFirstEmbeddablePosition(state *ScheduleState, day, duration int) (*ScheduleTask, int, int) {
type candidate struct {
task *ScheduleTask
slotStart int
slotEnd int
}
candidates := make([]candidate, 0)
for _, host := range getEmbeddableTasks(state) {
if host == nil || host.EmbeddedBy != nil {
continue
}
for _, slot := range host.Slots {
if slot.Day != day {
continue
}
span := slot.SlotEnd - slot.SlotStart + 1
if span < duration {
continue
}
candidates = append(candidates, candidate{
task: host,
slotStart: slot.SlotStart,
slotEnd: slot.SlotStart + duration - 1,
})
}
}
if len(candidates) == 0 {
return nil, 0, 0
}
sort.Slice(candidates, func(i, j int) bool { return candidates[i].slotStart < candidates[j].slotStart })
best := candidates[0]
return best.task, best.slotStart, best.slotEnd
}
// ListTasks 列出任务清单,可按类别和状态过滤。 // ListTasks 列出任务清单,可按类别和状态过滤。
// category 选填nil 不过滤status 选填nil 默认 "all")。 // category 选填nil 不过滤status 选填nil 默认 "all")。
// 输出按状态分组:已安排 -> 已预排 -> 待安排,组内按 stateID 升序。 // 输出按状态分组:已安排 -> 已预排 -> 待安排,组内按 stateID 升序。
@@ -297,13 +486,25 @@ func ListTasks(state *ScheduleState, category, status *string) string {
if status != nil { if status != nil {
statusFilter = *status statusFilter = *status
} }
statusFilter = strings.ToLower(strings.TrimSpace(statusFilter))
if statusFilter == "" {
statusFilter = "all"
}
if err := validateListTasksStatus(statusFilter); err != nil {
return fmt.Sprintf("查询失败:%s", err.Error())
}
categoryFilter := ""
if category != nil {
categoryFilter = strings.TrimSpace(*category)
}
hasCategoryFilter := categoryFilter != ""
// 2. 过滤 + 分组。 // 2. 过滤 + 分组。
var existingTasks, suggestedTasks, pendingTasks []ScheduleTask var existingTasks, suggestedTasks, pendingTasks []ScheduleTask
for i := range state.Tasks { for i := range state.Tasks {
t := state.Tasks[i] t := state.Tasks[i]
// 类别过滤。 // 类别过滤。
if category != nil && t.Category != *category { if hasCategoryFilter && t.Category != categoryFilter {
continue continue
} }
@@ -333,40 +534,119 @@ func ListTasks(state *ScheduleState, category, status *string) string {
// 4. 纯待安排模式:只输出待安排任务。 // 4. 纯待安排模式:只输出待安排任务。
if statusFilter == "pending" { if statusFilter == "pending" {
if len(pendingTasks) == 0 {
return formatListTasksEmptyResult(statusFilter, categoryFilter)
}
return formatPendingList(pendingTasks) return formatPendingList(pendingTasks)
} }
// 5. 纯已预排模式:只输出已预排任务。 // 5. 纯已预排模式:只输出已预排任务。
if statusFilter == "suggested" { if statusFilter == "suggested" {
if len(suggestedTasks) == 0 {
return formatListTasksEmptyResult(statusFilter, categoryFilter)
}
return formatSuggestedList(suggestedTasks) return formatSuggestedList(suggestedTasks)
} }
// 6. 纯已安排模式:只输出已安排任务。 // 6. 纯已安排模式:只输出已安排任务。
if statusFilter == "existing" { if statusFilter == "existing" {
if len(existingTasks) == 0 {
return formatListTasksEmptyResult(statusFilter, categoryFilter)
}
return formatExistingList(existingTasks) return formatExistingList(existingTasks)
} }
// 7. 全部模式:统计 + 分组输出。 // 7. 全部模式:统计 + 分组输出。
total := len(existingTasks) + len(suggestedTasks) + len(pendingTasks) total := len(existingTasks) + len(suggestedTasks) + len(pendingTasks)
var sb strings.Builder var sb strings.Builder
sb.WriteString(fmt.Sprintf("共%d个任务已安排%d个已预排%d个待安排%d个。\n", total, len(existingTasks), len(suggestedTasks), len(pendingTasks))) sb.WriteString(fmt.Sprintf("共%d个任务已安排(existing)%d个已预排(suggested)%d个待安排(pending)%d个。\n", total, len(existingTasks), len(suggestedTasks), len(pendingTasks)))
if len(existingTasks) > 0 { if len(existingTasks) > 0 {
sb.WriteString("\n已安排\n") sb.WriteString("\n已安排(existing)\n")
sb.WriteString(formatExistingList(existingTasks)) sb.WriteString(formatExistingList(existingTasks))
} }
if len(suggestedTasks) > 0 { if len(suggestedTasks) > 0 {
sb.WriteString("\n已预排\n") sb.WriteString("\n已预排(suggested)\n")
sb.WriteString(formatSuggestedList(suggestedTasks)) sb.WriteString(formatSuggestedList(suggestedTasks))
} }
if len(pendingTasks) > 0 { if len(pendingTasks) > 0 {
sb.WriteString("\n待安排\n") sb.WriteString("\n待安排(pending)\n")
sb.WriteString(formatPendingList(pendingTasks)) sb.WriteString(formatPendingList(pendingTasks))
} }
return sb.String() return sb.String()
} }
// formatListTasksEmptyResult 统一构造 list_tasks 空结果文案。
//
// 设计意图:
// 1. 明确告诉模型“为什么为空”,避免把空字符串误解为工具异常或上下文缺失;
// 2. 对常见误用 category=ID 列表给出直接纠偏提示,减少死循环重试。
func formatListTasksEmptyResult(statusFilter, categoryFilter string) string {
statusLabel := map[string]string{
"all": "任意状态",
"existing": "已安排(existing)",
"suggested": "已预排(suggested)",
"pending": "待安排(pending)",
}
target := statusLabel[statusFilter]
if target == "" {
target = statusFilter
}
if strings.TrimSpace(categoryFilter) == "" {
return fmt.Sprintf("查询结果为空:当前没有%s任务。", target)
}
if looksLikeTaskClassIDList(categoryFilter) {
return fmt.Sprintf("查询结果为空category=%q 未匹配到任务。category 参数按任务类名称匹配,不支持 task_class_ids 列表。", categoryFilter)
}
return fmt.Sprintf("查询结果为空category=%q 下没有%s任务。", categoryFilter, target)
}
// looksLikeTaskClassIDList 判断 category 文本是否像“逗号分隔的数字 ID 列表”。
func looksLikeTaskClassIDList(value string) bool {
value = strings.TrimSpace(value)
if value == "" {
return false
}
parts := strings.Split(value, ",")
if len(parts) == 0 {
return false
}
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
return false
}
for _, r := range part {
if r < '0' || r > '9' {
return false
}
}
}
return true
}
// validateListTasksStatus 校验 list_tasks.status 的输入值。
//
// 职责边界:
// 1. 负责拦截非法 status避免“静默返回 0 条”误导模型;
// 2. 不负责自动拆分或容错纠偏(如 existing,suggested统一要求调用方改成合法单值。
func validateListTasksStatus(status string) error {
// 1. status 已在调用方归一化为小写并去空格。
// 2. 合法值仅允许 all / existing / suggested / pending。
switch status {
case "all", "existing", "suggested", "pending":
return nil
}
// 3. 对最常见误用给出明确修复建议,避免模型继续循环错误调用。
if strings.Contains(status, ",") {
return fmt.Errorf("status 只支持单值 all/existing/suggested/pending不支持 \"%s\"。如需同时查看 existing+suggested请使用 all", status)
}
return fmt.Errorf("status=%q 非法,仅支持 all/existing/suggested/pending", status)
}
// GetTaskInfo 查询单个任务的详细信息。 // GetTaskInfo 查询单个任务的详细信息。
// taskID 必填,为 state 内的 state_id。 // taskID 必填,为 state 内的 state_id。
// 不存在时返回错误信息字符串。 // 不存在时返回错误信息字符串。
@@ -380,13 +660,13 @@ func GetTaskInfo(state *ScheduleState, taskID int) string {
sb.WriteString(fmt.Sprintf("[%d]%s\n", task.StateID, task.Name)) sb.WriteString(fmt.Sprintf("[%d]%s\n", task.StateID, task.Name))
// 1. 类别、状态、来源。 // 1. 类别、状态、来源。
statusLabel := "已安排" statusLabel := "已安排(existing)"
if IsPendingTask(*task) { if IsPendingTask(*task) {
statusLabel = "待安排" statusLabel = "待安排(pending)"
} else if IsSuggestedTask(*task) { } else if IsSuggestedTask(*task) {
statusLabel = "已预排" statusLabel = "已预排(suggested)"
} else if task.Locked { } else if task.Locked {
statusLabel = "已安排固定" statusLabel = "已安排(existing,固定)"
} }
sb.WriteString(fmt.Sprintf("类别:%s | 状态:%s\n", task.Category, statusLabel)) sb.WriteString(fmt.Sprintf("类别:%s | 状态:%s\n", task.Category, statusLabel))
sb.WriteString(fmt.Sprintf("来源:%s\n", formatSourceName(task.Source))) sb.WriteString(fmt.Sprintf("来源:%s\n", formatSourceName(task.Source)))

View File

@@ -97,13 +97,13 @@ var writeTools = map[string]bool{
// ==================== 默认注册表 ==================== // ==================== 默认注册表 ====================
// NewDefaultRegistry 创建包含全部 10 个日程工具注册表。 // NewDefaultRegistry 创建默认日程工具注册表。
func NewDefaultRegistry() *ToolRegistry { func NewDefaultRegistry() *ToolRegistry {
r := NewToolRegistry() r := NewToolRegistry()
// --- 读工具 --- // --- 读工具 ---
r.Register("get_overview", r.Register("get_overview",
"获取规划窗口的粗粒度总览,包括每日占用、可嵌入时段和待安排任务。", "获取规划窗口总览(任务视角,全量返回):保留课程占位统计,展开任务清单(过滤课程明细)。",
`{"name":"get_overview","parameters":{}}`, `{"name":"get_overview","parameters":{}}`,
func(state *ScheduleState, args map[string]any) string { func(state *ScheduleState, args map[string]any) string {
return GetOverview(state) return GetOverview(state)
@@ -122,20 +122,33 @@ func NewDefaultRegistry() *ToolRegistry {
}, },
) )
r.Register("find_first_free",
"查找首个满足时长条件的可用位置并返回该日详细负载信息。duration 必填day 选填(不填按天顺序搜索)。",
`{"name":"find_first_free","parameters":{"duration":{"type":"int","required":true},"day":{"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"))
},
)
// 兼容别名:保留 find_free避免旧历史轨迹中的工具调用失效。
r.Register("find_free", r.Register("find_free",
"查找满足指定连续时段长度的空闲位置。duration 必填day 选填(不填搜全部天)。", "兼容别名,行为同 find_first_free。",
`{"name":"find_free","parameters":{"duration":{"type":"int","required":true},"day":{"type":"int"}}}`, `{"name":"find_free","parameters":{"duration":{"type":"int","required":true},"day":{"type":"int"}}}`,
func(state *ScheduleState, args map[string]any) string { func(state *ScheduleState, args map[string]any) string {
duration, ok := argsInt(args, "duration") duration, ok := argsInt(args, "duration")
if !ok { if !ok {
return "查询失败:缺少必填参数 duration。" return "查询失败:缺少必填参数 duration。"
} }
return FindFree(state, duration, argsIntPtr(args, "day")) return FindFirstFree(state, duration, argsIntPtr(args, "day"))
}, },
) )
r.Register("list_tasks", r.Register("list_tasks",
"列出任务清单可按类别和状态过滤。category 选status 选填(默认 all支持 existing/suggested/pending。", "列出任务清单可按类别和状态过滤。category 传任务类名称(非 ID 列表)可status 选填(默认 all支持单值 all/existing/suggested/pending。",
`{"name":"list_tasks","parameters":{"category":{"type":"string"},"status":{"type":"string","enum":["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 { func(state *ScheduleState, args map[string]any) string {
return ListTasks(state, argsStringPtr(args, "category"), argsStringPtr(args, "status")) return ListTasks(state, argsStringPtr(args, "category"), argsStringPtr(args, "status"))
@@ -176,7 +189,7 @@ func NewDefaultRegistry() *ToolRegistry {
) )
r.Register("move", r.Register("move",
"将一个已落位任务(existing 或 suggested移动到新位置。task_id/new_day/new_slot_start 必填。", "将一个已预排任务( suggested移动到新位置。existing 属于已安排事实层,不参与 move。task_id/new_day/new_slot_start 必填。",
`{"name":"move","parameters":{"task_id":{"type":"int","required":true},"new_day":{"type":"int","required":true},"new_slot_start":{"type":"int","required":true}}}`, `{"name":"move","parameters":{"task_id":{"type":"int","required":true},"new_day":{"type":"int","required":true},"new_slot_start":{"type":"int","required":true}}}`,
func(state *ScheduleState, args map[string]any) string { func(state *ScheduleState, args map[string]any) string {
taskID, ok := argsInt(args, "task_id") taskID, ok := argsInt(args, "task_id")
@@ -212,7 +225,7 @@ func NewDefaultRegistry() *ToolRegistry {
) )
r.Register("batch_move", r.Register("batch_move",
"原子性批量移动多个任务,全部成功才生效。moves 数组必填。", "原子性批量移动多个任务(仅 suggested全部成功才生效。若含 existing/pending 将整批失败回滚。moves 数组必填。",
`{"name":"batch_move","parameters":{"moves":{"type":"array","required":true,"items":{"task_id":"int","new_day":"int","new_slot_start":"int"}}}}`, `{"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 { func(state *ScheduleState, args map[string]any) string {
moves, err := argsMoveList(args) moves, err := argsMoveList(args)

View File

@@ -92,7 +92,7 @@ func Place(state *ScheduleState, taskID, day, slotStart int) string {
// ==================== Move ==================== // ==================== Move ====================
// Move 将一个已落位任务移动到新位置。 // Move 将一个已落位任务移动到新位置。
// taskID 允许 suggested / existing,但不能是真实 pending // taskID 允许 suggestedexisting/pending 都不允许移动
func Move(state *ScheduleState, taskID, newDay, newSlotStart int) string { func Move(state *ScheduleState, taskID, newDay, newSlotStart int) string {
// 1. 查找任务。 // 1. 查找任务。
task := state.TaskByStateID(taskID) task := state.TaskByStateID(taskID)
@@ -101,9 +101,15 @@ func Move(state *ScheduleState, taskID, newDay, newSlotStart int) string {
} }
// 2. 校验状态。 // 2. 校验状态。
if !IsSuggestedTask(*task) {
// 2.1 pending 任务尚未落位,应通过 place 安排;
// 2.2 existing 任务属于已安排事实层,不允许在 execute 微调里直接 move
// 2.3 仅 suggested 属于“本轮可微调建议落位”。
if IsPendingTask(*task) { if IsPendingTask(*task) {
return fmt.Sprintf("移动失败:[%d]%s 当前为待安排状态,请使用 place 放置。", task.StateID, task.Name) return fmt.Sprintf("移动失败:[%d]%s 当前为待安排状态,请使用 place 放置。", task.StateID, task.Name)
} }
return fmt.Sprintf("移动失败:[%d]%s 当前为已安排existing任务不允许 move仅 suggested 任务可移动。", task.StateID, task.Name)
}
// 3. 校验锁定。 // 3. 校验锁定。
if err := checkLocked(*task); err != nil { if err := checkLocked(*task); err != nil {
@@ -248,6 +254,7 @@ func Swap(state *ScheduleState, taskAID, taskBID int) string {
// ==================== BatchMove ==================== // ==================== BatchMove ====================
// BatchMove 原子性地批量移动多个任务。 // BatchMove 原子性地批量移动多个任务。
// moves 中每个 task_id 都必须是 suggestedexisting/pending 任一命中都会整批失败。
// 全部成功才生效,任一失败则完全回滚。 // 全部成功才生效,任一失败则完全回滚。
func BatchMove(state *ScheduleState, moves []MoveRequest) string { func BatchMove(state *ScheduleState, moves []MoveRequest) string {
if len(moves) == 0 { if len(moves) == 0 {
@@ -260,10 +267,16 @@ func BatchMove(state *ScheduleState, moves []MoveRequest) string {
if task == nil { if task == nil {
return fmt.Sprintf("批量移动失败,全部回滚,无任何变更。\n任务ID %d 不存在(第%d条移动请求。", m.TaskID, i+1) return fmt.Sprintf("批量移动失败,全部回滚,无任何变更。\n任务ID %d 不存在(第%d条移动请求。", m.TaskID, i+1)
} }
if !IsSuggestedTask(*task) {
// 1.1 保持与 Move 一致:批量移动仅允许 suggested
// 1.2 pending / existing 任一命中都应整批失败并回滚。
if IsPendingTask(*task) { if IsPendingTask(*task) {
return fmt.Sprintf("批量移动失败,全部回滚,无任何变更。\n[%d]%s 当前为待安排状态,请使用 place第%d条移动请求。", return fmt.Sprintf("批量移动失败,全部回滚,无任何变更。\n[%d]%s 当前为待安排状态,请使用 place第%d条移动请求。",
task.StateID, task.Name, i+1) task.StateID, task.Name, i+1)
} }
return fmt.Sprintf("批量移动失败,全部回滚,无任何变更。\n[%d]%s 当前为已安排existing任务不允许 move仅 suggested 任务可移动(第%d条移动请求。",
task.StateID, task.Name, i+1)
}
if err := checkLocked(*task); err != nil { if err := checkLocked(*task); err != nil {
return fmt.Sprintf("批量移动失败,全部回滚,无任何变更。\n%s第%d条移动请求", err.Error(), i+1) return fmt.Sprintf("批量移动失败,全部回滚,无任何变更。\n%s第%d条移动请求", err.Error(), i+1)
} }

View File

@@ -0,0 +1,465 @@
# 阶段 3上下文瘦身设计
本文档更新时间2026-04-08
## 0. 文档目的
这份文档只服务第 3 阶段“上下文瘦身”。
职责边界:
1. 记录当前已经和用户对齐的“瘦身后 execute 上下文骨架”。
2. 记录第 3 阶段的落地顺序、非目标和完成标准。
3. 作为后续上下文被裁剪后的继续施工依据。
明确不负责:
1. 不展开第 4 阶段 prompt 三层拆分的最终结构。
2. 不在这里继续讨论粗排和 abort 协议本身。
---
## 1. 当前已经确认的收敛结论
以下结论已经对齐,后续不要再回摆:
1. 第 3 阶段先解决“上下文过胖、重复、噪音多”,不是先做 prompt 三层重构。
2. 工具参数定义和 JSON 调用示例,应该放在工具块里,不应该散落在别的 message。
3. 上下文结构应尽量通用化,后端只填“已有事实”,不要引入大量需要主观概括的分类字段。
4. `msg3``msg4` 必须是一一对应的一组:
- `msg3` 是最近一次工具调用记录;
- `msg4` 是与 `msg3` 对应的工具结果;
- 如果当前没有最近工具调用,则 `msg3` / `msg4` 应一起缺省。
5. `msg4` 已经承载“最近一次工具结果”,`msg5` 不应再重复这部分内容。
6. `msg5` 只保留“有唯一来源的运行态事实”,不能放没有明确 owner 的解释性字段。
7. `当前步骤` 可以保留,唯一来源应是 `CommonState.CurrentPlanStep()`
8. `当前目标` 当前不保留,因为没有稳定 owner容易变成后端替模型总结下一步动作。
9. `最近观察` 当前不保留,因为没有稳定结构化来源;如果需要相关信息,应直接通过 `msg4` 表达最近一次工具结果。
10. `已确认语义` 这类过宽、难填、边界不清的字段不要进入第 3 阶段方案。
---
## 2. 瘦身后的目标上下文骨架
这里记录的是第 3 阶段完成后,`execute` 阶段理想的 messages 骨架。
注意:
1. 这是“上下文瘦身后的骨架”,不是第 4 阶段 prompt 三层重构的最终形态。
2. 重点是减量、去重、压缩,而不是新增更多层次和字段。
### 2.1 目标 message 列表
```text
message[0] role=system
执行规则:
- 只围绕当前步骤行动
- 只输出严格 JSON
- 不要伪造工具结果
- 读操作使用 action=continue + tool_call
- 写操作使用 action=confirm + tool_call
- 缺少关键信息时用 action=ask_user
- 当前流程应终止时用 action=abort
- next_plan / done 时 goal_check 必填
message[1] role=system
可用工具:
- 工具名
- 工具说明
- 参数定义
- 最小 JSON 调用示例
message[2] role=assistant
历史摘要:
- 用户目标
- 更早但仍有效的事实
- 最近失败摘要
- 已折叠说明(重复查询、过程话术、旧修正链已省略)
message[3] role=assistant
最近一次工具调用记录(与 message[4] 成对)
message[4] role=tool
最近一次工具结果
message[5] role=system
当前执行状态:
- 当前轮次
- 当前步骤
- 当前步骤完成判定
message[6] role=user
请继续当前任务的执行阶段,严格输出 JSON。
```
### 2.2 各 message 的职责边界
#### message[0]:执行规则
只保留 execute 的稳定规则,不在这里重复工具参数,也不在这里放运行态数据。
#### message[1]:工具块
必须包含:
1. 工具名
2. 工具说明
3. 参数定义
4. 最小 JSON 调用示例
原因:
1. 这是 LLM 真实调用工具时最直接依赖的材料。
2. 如果只给工具名不给参数,模型很容易继续出现缺参调用。
3. 第 3 阶段里,工具块比“更长的执行 prompt”更重要。
#### message[2]:历史摘要
只保留“更早但仍有效”的摘要,不保留全量流水账。
允许保留的内容:
1. 用户原始目标
2. 当前会话中仍然有效的约束
3. 最近失败摘要
4. 历史折叠说明
不应保留的内容:
1. assistant 的过程话术,例如“我先看一下”“我接下来准备……”
2. 同工具同参数的多份原始重复结果
3. 整段 correction 往返原文
#### message[3] + message[4]:最近一组工具观察
这是 execute 在当前轮之前最关键、最新鲜的一组观察。
约束:
1. `message[3]``message[4]` 必须成对出现。
2. `message[3]` 是调用记录,`message[4]` 是与之对应的工具结果。
3. 若当前没有最近工具调用,则这两条一起省略。
4. 不能凭空生成“最近结果摘要”。
#### message[5]:当前执行状态
这里只允许放“有唯一来源的运行态事实”。
当前允许的字段:
1. 当前轮次
2. 当前步骤
3. 当前步骤完成判定
当前不允许的字段:
1. 当前目标
2. 最近观察
3. 已确认语义
4. 任何需要后端主观解释才能生成的字段
#### message[6]:本轮触发指令
作用很单一:
1. 明确“现在继续 execute”
2. 强化“严格输出 JSON”
不要在这里重复整份计划、工具说明或历史摘要。
---
## 3. 一个更接近真实落地的示例
下面这个例子只用于校准方向,不要求文案逐字一致。
```text
message[0] role=system
你是 SmartFlow NewAgent 的执行器。
执行规则:
1. 只围绕当前步骤行动,不要跳到别的步骤。
2. 只输出严格 JSON不要输出 markdown不要输出 JSON 之外的解释。
3. 不要伪造工具结果。
4. 读操作用 action=continue + tool_call。
5. 写操作用 action=confirm + tool_call。
6. 缺少关键上下文且无法补齐时,输出 action=ask_user。
7. 当前流程应正式终止时,输出 action=abort。
8. 输出 action=next_plan 或 action=done 时goal_check 必填。
message[1] role=system
可用工具:
1. get_overview
说明:查看当前窗口内整体分布。
参数:
{}
调用示例:
{
"name": "get_overview",
"arguments": {}
}
2. find_free
说明:查找满足指定连续时长的空位。
参数:
{
"duration": "int, 必填",
"day": "int, 可选"
}
调用示例:
{
"name": "find_free",
"arguments": {
"duration": 2,
"day": 5
}
}
3. list_tasks
说明:列出任务,可按类别和状态过滤。
参数:
{
"category": "string, 可选",
"status": "string, 可选, 可取 all/existing/suggested/pending"
}
调用示例:
{
"name": "list_tasks",
"arguments": {
"status": "suggested"
}
}
4. move
说明:移动一个已预排任务(仅 suggested
参数:
{
"task_id": "int, 必填",
"new_day": "int, 必填",
"new_slot_start": "int, 必填"
}
调用示例:
{
"name": "move",
"arguments": {
"task_id": 128,
"new_day": 5,
"new_slot_start": 1
}
}
5. swap
说明:交换两个已落位任务。
参数:
{
"task_a": "int, 必填",
"task_b": "int, 必填"
}
调用示例:
{
"name": "swap",
"arguments": {
"task_a": 128,
"task_b": 136
}
}
6. batch_move
说明:批量原子移动多个任务。
参数:
{
"moves": [
{
"task_id": "int, 必填",
"new_day": "int, 必填",
"new_slot_start": "int, 必填"
}
]
}
调用示例:
{
"name": "batch_move",
"arguments": {
"moves": [
{
"task_id": 128,
"new_day": 5,
"new_slot_start": 1
},
{
"task_id": 129,
"new_day": 5,
"new_slot_start": 3
}
]
}
}
7. unplace
说明:取消一个已落位任务。
参数:
{
"task_id": "int, 必填"
}
调用示例:
{
"name": "unplace",
"arguments": {
"task_id": 128
}
}
补充约束:
- suggested 可以 move / swap / unplace
- existing 不能 move / batch_move仅作已安排事实层
- pending 不能 move / swap / unplace
- 如果当前任务已经是 suggested不要再把它当 pending 去 place
message[2] role=assistant
历史摘要:
- 用户目标:把任务类 [101,102] 调整到本周,尽量分布更均匀,周五不要太满。
- 当前已知事实:
1. 当前阶段是 execute
2. task_class_ids=[101,102]
3. 当前状态统计existing=9, suggested=18, pending=0
- 最近一次失败摘要find_free 缺少 duration 参数
- 更早的重复查询、过程话术、旧修正链已折叠
message[3] role=assistant
tool_call:
{
"name": "get_overview",
"arguments": {}
}
message[4] role=tool
规划窗口概览:
- existing=9
- suggested=18
- pending=0
- 周三第5-8节 suggested 偏密
- 周五第1-2节有空位
- 周五第3-4节有空位
message[5] role=system
当前执行状态:
- 当前轮次2/8
- 当前步骤:先识别最值得调整的 suggested 任务和候选空位
- 当前步骤完成判定:能明确指出哪些任务值得调整,以及候选目标时段
message[6] role=user
请继续当前任务的执行阶段,严格输出 JSON。
```
---
## 4. 第 3 阶段的具体落地计划
### 4.1 第一件事:先抓真实输入样本
目标:
1. 先拿到 `BuildExecuteMessages()` 真正送给模型的样本。
2. 不先拍脑袋改结构,先确认“胖点”究竟在 history、pinned还是 runtime prompt。
当前可复用入口:
1. `backend/newAgent/prompt/execute.go`
2. `backend/newAgent/prompt/base.go`
3. `backend/newAgent/node/execute.go` 中已有 execute 上下文调试日志
产出:
1. 至少 2 到 3 份真实样本
2. 标出每个 message 的长度、重复点和噪音来源
### 4.2 第二件事:补 execute 专用的历史压缩层
目标:
1. 不再把 `ConversationContext.History` 原样全量喂给 execute。
2. 形成“更早历史摘要 + 最近一组工具观察”的结构。
必须做的事:
1. 同工具同参数的重复查询,不保留多份原始结果。
2. 更早结果改成摘要,只保留最近一条原始结果。
3. assistant 过程话术不再进入后续模型历史。
4. correction / 工具失败链改成“最近失败摘要”,不要保留整段往返原文。
5. 保留合法的 assistant tool_call + tool result 成对消息,不能破坏 OpenAI 兼容格式。
### 4.3 第三件事:压缩 pinned / runtime 的重复信息
目标:
1. 避免 `state summary + pinned + runtime user prompt` 三处重复抄同一份信息。
2. 保留最新、必要、唯一来源的信息。
原则:
1. 当前计划/当前步骤只保留最新版本,不做历史累积。
2. `msg5` 只保留“当前轮次 + 当前步骤 + 当前步骤完成判定”。
3. 工具结果只出现在 `msg4`,不在 `msg5` 再复述。
4. 粗排语义只保留一处,不要在多条 message 重复提醒。
### 4.4 第四件事:最后再接 token budget
目标:
1. 在完成摘要化和去重后,再做按预算裁剪。
2. 避免一上来直接砍历史,把真正有价值的信息也一起砍掉。
可复用思路:
1. 参考旧链路 `agent.go` 的历史预算计算和裁剪流程。
2. 参考 `backend/pkg/token_budget.go` 中的预算估算与窗口裁剪函数。
要求:
1. 不一定照搬旧链路。
2. 但应复用“先估算、再裁剪、最后收敛会话窗口”的思路。
---
## 5. 第 3 阶段明确不做什么
为了避免和第 4 阶段混淆,这一轮明确不做:
1. 不把 execute prompt 直接拆成三层正式文件结构。
2. 不在通用执行 prompt 里重写完整排程领域模块。
3. 不额外新增没有稳定 owner 的字段,例如:
- 当前目标
- 最近观察
- 已确认语义
4. 不继续围绕粗排补边角语义。
5. 不继续围绕 abort 协议扩展描述文案。
---
## 6. 第 3 阶段完成标准
至少要满足:
1. execute 首轮 messages 明显变短。
2. 同工具同参数的重复查询不会继续堆多份原始结果。
3. assistant 过程话术不再进入后续执行历史。
4. 最近一次失败模式仍能被模型感知。
5. 最近一次工具调用与结果仍以合法配对形式保留。
6. `msg5` 不再重复 `msg4` 的内容。
7. 不破坏第 1-2 阶段已经打通的粗排 / abort 语义。
---
## 7. 供下一轮继续时快速判断的检查清单
如果下一轮接手时要快速判断是否做对,可以先问这几个问题:
1. execute 现在是否还是“全量 history + 全量 pinned + 全量 runtime prompt”直接拼接
2. 工具参数和 JSON 示例是否已经进入单独工具块?
3. `msg3` / `msg4` 是否仍保持一一对应?
4. `msg5` 是否只保留运行态事实,而没有重复工具结果?
5. assistant 的过程话术是否还在继续污染后续历史?
6. correction 失败链是否还在整段保留?
如果以上问题仍然大多回答“是”,说明第 3 阶段还没有真正完成。