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:
@@ -2,24 +2,27 @@
|
||||
|
||||
以下内容可直接交给下一位助理继续做。
|
||||
|
||||
本文档更新时间:2026-04-07
|
||||
本文档更新时间:2026-04-08
|
||||
|
||||
## 0. 当前结论先说清
|
||||
|
||||
当前可以明确分成两段看:
|
||||
当前可以明确分成三段看:
|
||||
|
||||
1. 第 1-2 阶段已经基本完成
|
||||
粗排链路已经打通,且“粗排异常 -> 正式 abort -> deliver 收口”这条后端协议已经补齐。
|
||||
1. 第 1-2 阶段已完成
|
||||
粗排链路和 `abort -> deliver` 正式终止协议已打通,真实链路已验证可跑。
|
||||
|
||||
2. 第 3-4 阶段还没有做
|
||||
下游 `execute` 整体效果目前仍然很差,核心原因不是粗排本身,而是上下文过胖、提示词结构仍然混乱。
|
||||
所以现在**不能**用“整体排程效果”去评价第 1-2 阶段是否成功;必须先做第 3 阶段上下文瘦身,再看整体效果。
|
||||
2. 第 3 阶段第一版已落地
|
||||
`execute` 上下文已改成固定 4 消息结构,并接入当轮 ReAct 窗口压缩(按工具去重保留最新 observation);
|
||||
工具结果在执行链路中已改为不截断,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. 一句话交给下一位助理
|
||||
|
||||
第 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`
|
||||
避免模型把可嵌入并存误判为硬冲突。
|
||||
|
||||
#### P1(P0 后做)
|
||||
|
||||
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
2748
backend/newAgent/Log.txt
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
@@ -49,6 +50,47 @@ type ExecuteDecision struct {
|
||||
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 决策中的字符串字段。
|
||||
func (d *ExecuteDecision) Normalize() {
|
||||
if d == nil {
|
||||
@@ -173,6 +215,32 @@ type ToolCallIntent struct {
|
||||
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 清洗工具调用意图中的稳定字段。
|
||||
func (t *ToolCallIntent) Normalize() {
|
||||
if t == nil {
|
||||
@@ -193,6 +261,36 @@ func (t *ToolCallIntent) Validate() error {
|
||||
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 表示“当前步骤完成证明”来自哪里。
|
||||
type ExecuteEvidenceSource string
|
||||
|
||||
|
||||
@@ -8,6 +8,11 @@ import (
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
const (
|
||||
correctionHistoryKindKey = "newagent_history_kind"
|
||||
correctionHistoryKindCorrectionUser = "llm_correction_prompt"
|
||||
)
|
||||
|
||||
// AppendLLMCorrection 追加 LLM 修正提示到对话历史。
|
||||
//
|
||||
// 设计目的:
|
||||
@@ -56,6 +61,9 @@ func AppendLLMCorrection(
|
||||
conversationContext.AppendHistory(&schema.Message{
|
||||
Role: schema.User,
|
||||
Content: correctionContent,
|
||||
Extra: map[string]any{
|
||||
correctionHistoryKindKey: correctionHistoryKindCorrectionUser,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -96,5 +104,8 @@ func AppendLLMCorrectionWithHint(
|
||||
conversationContext.AppendHistory(&schema.Message{
|
||||
Role: schema.User,
|
||||
Content: correctionContent,
|
||||
Extra: map[string]any{
|
||||
correctionHistoryKindKey: correctionHistoryKindCorrectionUser,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -246,6 +246,10 @@ func RunExecuteNode(ctx context.Context, input ExecuteNodeInput) error {
|
||||
// 决策合法,重置连续修正计数。
|
||||
flowState.ConsecutiveCorrections = 0
|
||||
|
||||
// speak 兜底:continue / ask_user / confirm 三类动作对前端可读文案是强依赖。
|
||||
// 若模型漏填 speak,这里回退到 reason 或默认短句,避免前端出现“静默一轮”。
|
||||
decision.Speak = buildExecuteSpeakWithFallback(decision)
|
||||
|
||||
// speak 后处理:补列表序号换行 + 末尾加 \n 防止连续 speak 在前端粘连。
|
||||
decision.Speak = normalizeSpeak(decision.Speak) // 末尾已含 \n
|
||||
|
||||
@@ -425,6 +429,42 @@ func resolveExecuteAskUserText(decision *newagentmodel.ExecuteDecision) string {
|
||||
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 申报的写操作确认请求。
|
||||
//
|
||||
// 步骤:
|
||||
@@ -566,12 +606,6 @@ func executeToolCall(
|
||||
flattenForLog(result),
|
||||
)
|
||||
|
||||
// 2.5 截断过大的工具结果,防止上下文膨胀导致后续 LLM 调用返回空或超限。
|
||||
const maxToolResultLen = 3000
|
||||
if len(result) > maxToolResultLen {
|
||||
result = result[:maxToolResultLen] + fmt.Sprintf("\n...(结果已截断,原始长度 %d 字符)", len(result))
|
||||
}
|
||||
|
||||
// 3. 将工具调用和结果以合法的 assistant+tool 消息对追加到对话历史。
|
||||
//
|
||||
// 修复说明:
|
||||
|
||||
@@ -2,7 +2,6 @@ package newagentprompt
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
newagentmodel "github.com/LoveLosita/smartflow/backend/newAgent/model"
|
||||
@@ -10,62 +9,73 @@ import (
|
||||
)
|
||||
|
||||
const executeSystemPromptWithPlan = `
|
||||
你是 SmartFlow NewAgent 的执行器。
|
||||
你的职责是在"当前 plan 步骤"的约束下,进行思考、执行、观察,再决定下一步动作。
|
||||
你是 SmartFlow NewAgent 的执行器。你需要在“当前 plan 步骤”约束下推进任务。
|
||||
|
||||
请遵守以下规则:
|
||||
1. 只围绕当前步骤行动,不要擅自跳到其他 plan 步骤。
|
||||
2. 只输出严格 JSON,不要输出 markdown,不要输出额外解释,不要在 JSON 外再补文字。
|
||||
3. 只有当你确认当前步骤已经完成时,才输出 action=next_plan,且必须在 goal_check 中逐条对照 done_when 说明完成依据。
|
||||
4. 只有当你确认整个任务已经完成时,才输出 action=done,且必须在 goal_check 中总结整体完成证据。
|
||||
5. 如果执行当前步骤缺少关键上下文,且无法通过已有历史或工具补齐,输出 action=ask_user。
|
||||
6. 不要伪造工具结果;如果尚未真正拿到观察结果,就不要假装已经完成。
|
||||
7. goal_check 是你输出 next_plan / done 时的强制字段,禁止为空;必须显式地逐条对照 done_when,说明"哪些条件已满足、依据是什么"。
|
||||
你可以做什么:
|
||||
1. 只围绕当前步骤推进,先读后写,逐步完成当前步骤。
|
||||
2. 可调用读工具补充事实,再决定下一步。
|
||||
3. 需要写操作时输出 action=confirm 并附带 tool_call,等待用户确认。
|
||||
|
||||
你会看到:
|
||||
- 当前完整 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 = `
|
||||
你是 SmartFlow NewAgent 的执行器,当前为自由执行模式(无预定义计划步骤)。
|
||||
你需要根据用户意图,自主决定使用哪些工具来完成任务。
|
||||
你是 SmartFlow NewAgent 的执行器,当前处于自由执行模式(无预定义 plan 步骤)。
|
||||
|
||||
请遵守以下规则:
|
||||
1. 每轮先分析当前情况,决定下一步动作。
|
||||
2. 只输出严格 JSON,不要输出 markdown,不要输出额外解释,不要在 JSON 外再补文字。
|
||||
3. 需要查询数据 → 输出 action=continue 并附带 tool_call。
|
||||
4. 需要修改数据(写操作)→ 输出 action=confirm 并附带 tool_call,等待用户确认。
|
||||
5. 缺少关键信息且无法通过工具补齐 → 输出 action=ask_user。
|
||||
6. 任务完成 → 输出 action=done,并在 goal_check 中总结完成证据。
|
||||
7. 不要伪造工具结果;如果尚未真正拿到观察结果,就不要假装已经完成。
|
||||
8. 尽量高效:能用一次工具调用完成的,不要分多轮。
|
||||
阶段事实(强约束):
|
||||
1. 若上下文给出“粗排已完成/rough_build_done”,表示目标任务类已经进入 suggested/existing,不是待排入状态。
|
||||
2. 当前阶段目标是“微调”,不是“重新粗排”。
|
||||
|
||||
你会看到:
|
||||
- 用户原始请求
|
||||
- 置顶上下文块(粗排结果等)
|
||||
- 工具摘要
|
||||
- 历史对话与历史观察
|
||||
你可以做什么:
|
||||
1. 你可以基于科学排程原则(负载均衡、学习连贯性、冲突最小化)对 suggested 做微调。
|
||||
2. existing 属于已安排事实层,可用于冲突判断和参考,不作为 move/batch_move 的目标。
|
||||
3. 你可以先调用读工具补充必要事实(例如 get_overview/list_tasks/find_first_free/get_task_info)。
|
||||
4. 你可以在需要改动时提出 confirm(move/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 {
|
||||
return strings.TrimSpace(executeSystemPromptWithPlan)
|
||||
return buildExecutePromptWithFormatGuard(executeSystemPromptWithPlan)
|
||||
}
|
||||
|
||||
// BuildExecuteReActSystemPrompt 返回纯 ReAct 模式的系统提示词。
|
||||
// BuildExecuteReActSystemPrompt 返回执行阶段系统提示词(自由执行模式)。
|
||||
func BuildExecuteReActSystemPrompt() string {
|
||||
return strings.TrimSpace(executeSystemPromptReAct)
|
||||
return buildExecutePromptWithFormatGuard(executeSystemPromptReAct)
|
||||
}
|
||||
|
||||
// BuildExecuteDecisionContractText 返回执行阶段的输出协议说明(有 plan 模式)。
|
||||
// BuildExecuteDecisionContractText 返回执行阶段输出协议(有 plan 模式)。
|
||||
func BuildExecuteDecisionContractText() string {
|
||||
return strings.TrimSpace(fmt.Sprintf(`
|
||||
输出协议(严格 JSON):
|
||||
@@ -73,14 +83,14 @@ func BuildExecuteDecisionContractText() string {
|
||||
- action:只能是 %s / %s / %s / %s / %s
|
||||
- reason:给后端和日志看的简短说明
|
||||
- goal_check:输出 %s 或 %s 时必填,对照 done_when 逐条验证
|
||||
- tool_call:输出 %s 时可附带写工具意图(需 confirm),输出 %s 时可附带读工具调用
|
||||
- tool_call 格式:{"name": "工具名", "arguments": {...}}
|
||||
- tool_call:输出 %s(写操作,需 confirm)或 %s(读操作)时可附带
|
||||
- tool_call 格式:{"name":"工具名","arguments":{...}}
|
||||
|
||||
合法示例:
|
||||
示例:
|
||||
{
|
||||
"speak": "我来查一下本周的安排。",
|
||||
"speak": "我先查看当前整体安排。",
|
||||
"action": "%s",
|
||||
"reason": "需要先调用 get_overview 获取当前数据",
|
||||
"reason": "需要先调用 get_overview 获取事实",
|
||||
"tool_call": {
|
||||
"name": "get_overview",
|
||||
"arguments": {}
|
||||
@@ -88,16 +98,16 @@ func BuildExecuteDecisionContractText() string {
|
||||
}
|
||||
|
||||
{
|
||||
"speak": "查询完成。",
|
||||
"speak": "当前步骤已完成。",
|
||||
"action": "%s",
|
||||
"reason": "已拿到当前周课程列表",
|
||||
"goal_check": "已通过 get_overview 确认本周课程列表,满足完成条件"
|
||||
"reason": "已完成当前步骤所需查询与校验",
|
||||
"goal_check": "已满足当前步骤 done_when 条件"
|
||||
}
|
||||
|
||||
{
|
||||
"speak": "",
|
||||
"action": "%s",
|
||||
"reason": "整个任务已完成"
|
||||
"reason": "整体任务已完成"
|
||||
}
|
||||
`,
|
||||
newagentmodel.ExecuteActionContinue,
|
||||
@@ -115,22 +125,22 @@ func BuildExecuteDecisionContractText() string {
|
||||
))
|
||||
}
|
||||
|
||||
// BuildExecuteReActContractText 返回纯 ReAct 模式的输出协议说明。
|
||||
// BuildExecuteReActContractText 返回自由执行模式输出协议。
|
||||
func BuildExecuteReActContractText() string {
|
||||
return strings.TrimSpace(fmt.Sprintf(`
|
||||
输出协议(严格 JSON):
|
||||
- speak:给用户看的话(可以是分析结果、中间进展、或最终回复)
|
||||
- speak:给用户看的话
|
||||
- action:只能是 %s / %s / %s / %s
|
||||
- reason:给后端和日志看的简短说明
|
||||
- goal_check:输出 %s 时必填,总结任务完成证据
|
||||
- tool_call:输出 %s 时可附带写工具意图(需 confirm),输出 %s 时可附带读工具调用
|
||||
- tool_call 格式:{"name": "工具名", "arguments": {...}}
|
||||
- tool_call:输出 %s(写操作,需 confirm)或 %s(读操作)时可附带
|
||||
- tool_call 格式:{"name":"工具名","arguments":{...}}
|
||||
|
||||
合法示例:
|
||||
示例:
|
||||
{
|
||||
"speak": "我来查一下今天的安排。",
|
||||
"speak": "我先看一下现在的安排分布。",
|
||||
"action": "%s",
|
||||
"reason": "需要调用 get_overview 查询",
|
||||
"reason": "先读取概览再决定微调方向",
|
||||
"tool_call": {
|
||||
"name": "get_overview",
|
||||
"arguments": {}
|
||||
@@ -138,20 +148,20 @@ func BuildExecuteReActContractText() string {
|
||||
}
|
||||
|
||||
{
|
||||
"speak": "已将概率论移到周三第1-2节。",
|
||||
"speak": "我准备把两项任务对调位置,你确认后执行。",
|
||||
"action": "%s",
|
||||
"reason": "用户要求移动课程,写操作需确认",
|
||||
"reason": "写操作需要确认",
|
||||
"tool_call": {
|
||||
"name": "move",
|
||||
"arguments": {"task_state_id": 5, "target_day": 3, "target_slot_start": 1, "target_slot_end": 2}
|
||||
"name": "swap",
|
||||
"arguments": {"task_a": 1, "task_b": 2}
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
"speak": "今天共3节课,分别是...",
|
||||
"speak": "已完成你的请求。",
|
||||
"action": "%s",
|
||||
"reason": "查询完成,已回答用户",
|
||||
"goal_check": "已通过 get_overview 查到今天的课程并展示给用户"
|
||||
"reason": "微调执行完毕并已校验结果",
|
||||
"goal_check": "目标任务类已完成微调,且关键约束满足"
|
||||
}
|
||||
`,
|
||||
newagentmodel.ExecuteActionContinue,
|
||||
@@ -167,23 +177,23 @@ func BuildExecuteReActContractText() string {
|
||||
))
|
||||
}
|
||||
|
||||
// BuildExecuteDecisionContractTextV2 返回第二轮 abort 协议补齐后的执行输出契约。
|
||||
// BuildExecuteDecisionContractTextV2 返回补齐 abort 协议后的执行输出契约(有 plan 模式)。
|
||||
func BuildExecuteDecisionContractTextV2() string {
|
||||
return strings.TrimSpace(fmt.Sprintf(`
|
||||
输出协议(严格 JSON):
|
||||
- speak:给用户看的话;若 action=%s,通常留空,最终收口交给 deliver
|
||||
- speak:给用户看的话;若 action=%s,通常留空
|
||||
- action:只能是 %s / %s / %s / %s / %s / %s
|
||||
- reason:给后端和日志看的简短说明
|
||||
- goal_check:输出 %s 或 %s 时必填,对照 done_when 逐条验证
|
||||
- tool_call:输出 %s 时可附带写工具意图(需 confirm),输出 %s 时可附带读工具调用
|
||||
- abort:仅在输出 %s 时必填,格式为 {"code":"稳定机器码","user_message":"给用户看的终止说明","internal_reason":"给日志看的原因"}
|
||||
- tool_call:输出 %s(写操作,需 confirm)或 %s(读操作)时可附带
|
||||
- abort:仅在 action=%s 时必填,格式为 {"code":"...","user_message":"...","internal_reason":"..."}
|
||||
- tool_call 与 abort 互斥,禁止同时出现
|
||||
|
||||
合法示例:
|
||||
示例:
|
||||
{
|
||||
"speak": "我来查一下本周的安排。",
|
||||
"speak": "我先查看当前安排。",
|
||||
"action": "%s",
|
||||
"reason": "需要先调用 get_overview 获取当前数据",
|
||||
"reason": "先读取事实再决策",
|
||||
"tool_call": {
|
||||
"name": "get_overview",
|
||||
"arguments": {}
|
||||
@@ -191,20 +201,20 @@ func BuildExecuteDecisionContractTextV2() string {
|
||||
}
|
||||
|
||||
{
|
||||
"speak": "查询完成。",
|
||||
"speak": "当前步骤完成。",
|
||||
"action": "%s",
|
||||
"reason": "已拿到当前周课程列表",
|
||||
"goal_check": "已通过 get_overview 确认本周课程列表,满足完成条件"
|
||||
"reason": "步骤完成条件满足",
|
||||
"goal_check": "已满足当前步骤 done_when"
|
||||
}
|
||||
|
||||
{
|
||||
"speak": "",
|
||||
"action": "%s",
|
||||
"reason": "粗排结果存在业务异常,当前不应继续微调",
|
||||
"reason": "流程不应继续执行",
|
||||
"abort": {
|
||||
"code": "rough_build_pending_remaining",
|
||||
"user_message": "初始排课方案构建异常:粗排后仍有任务未获得初始落位。本轮先终止,请检查粗排算法或任务数据。",
|
||||
"internal_reason": "pending tasks remain after rough build"
|
||||
"code": "execute_abort",
|
||||
"user_message": "当前流程无法继续执行,本轮先终止。",
|
||||
"internal_reason": "execute declared abort"
|
||||
}
|
||||
}
|
||||
`,
|
||||
@@ -226,23 +236,23 @@ func BuildExecuteDecisionContractTextV2() string {
|
||||
))
|
||||
}
|
||||
|
||||
// BuildExecuteReActContractTextV2 返回第二轮 abort 协议补齐后的 ReAct 输出契约。
|
||||
// BuildExecuteReActContractTextV2 返回补齐 abort 协议后的自由执行输出契约。
|
||||
func BuildExecuteReActContractTextV2() string {
|
||||
return strings.TrimSpace(fmt.Sprintf(`
|
||||
输出协议(严格 JSON):
|
||||
- speak:给用户看的话(可以是分析结果、中间进展、或最终回复);若 action=%s,通常留空
|
||||
- speak:给用户看的话;若 action=%s,通常留空
|
||||
- action:只能是 %s / %s / %s / %s / %s
|
||||
- reason:给后端和日志看的简短说明
|
||||
- goal_check:输出 %s 时必填,总结任务完成证据
|
||||
- tool_call:输出 %s 时可附带写工具意图(需 confirm),输出 %s 时可附带读工具调用
|
||||
- abort:仅在输出 %s 时必填,格式为 {"code":"稳定机器码","user_message":"给用户看的终止说明","internal_reason":"给日志看的原因"}
|
||||
- tool_call:输出 %s(写操作,需 confirm)或 %s(读操作)时可附带
|
||||
- abort:仅在 action=%s 时必填,格式为 {"code":"...","user_message":"...","internal_reason":"..."}
|
||||
- tool_call 与 abort 互斥,禁止同时出现
|
||||
|
||||
合法示例:
|
||||
示例:
|
||||
{
|
||||
"speak": "我来查一下今天的安排。",
|
||||
"speak": "我先读取当前安排。",
|
||||
"action": "%s",
|
||||
"reason": "需要调用 get_overview 查询",
|
||||
"reason": "先获取事实再决策",
|
||||
"tool_call": {
|
||||
"name": "get_overview",
|
||||
"arguments": {}
|
||||
@@ -250,9 +260,9 @@ func BuildExecuteReActContractTextV2() string {
|
||||
}
|
||||
|
||||
{
|
||||
"speak": "已将概率论移到周三第1-2节。",
|
||||
"speak": "我准备执行写操作,等待你确认。",
|
||||
"action": "%s",
|
||||
"reason": "用户要求移动课程,写操作需确认",
|
||||
"reason": "写操作需要确认",
|
||||
"tool_call": {
|
||||
"name": "move",
|
||||
"arguments": {"task_id": 5, "new_day": 3, "new_slot_start": 1}
|
||||
@@ -262,7 +272,7 @@ func BuildExecuteReActContractTextV2() string {
|
||||
{
|
||||
"speak": "",
|
||||
"action": "%s",
|
||||
"reason": "当前流程不应继续执行,需要正式终止",
|
||||
"reason": "当前流程不应继续执行",
|
||||
"abort": {
|
||||
"code": "domain_abort",
|
||||
"user_message": "当前流程无法继续执行,本轮先终止。",
|
||||
@@ -286,92 +296,90 @@ func BuildExecuteReActContractTextV2() string {
|
||||
))
|
||||
}
|
||||
|
||||
// BuildExecuteMessages 组装执行阶段的 messages。
|
||||
// BuildExecuteMessages 组装执行阶段消息。
|
||||
func BuildExecuteMessages(state *newagentmodel.CommonState, ctx *newagentmodel.ConversationContext) []*schema.Message {
|
||||
if state != nil && state.HasPlan() {
|
||||
return buildStageMessages(
|
||||
return buildExecuteStageMessages(
|
||||
BuildExecuteSystemPrompt(),
|
||||
state,
|
||||
ctx,
|
||||
BuildExecuteUserPrompt(state),
|
||||
buildExecuteStrictJSONUserPrompt(),
|
||||
)
|
||||
}
|
||||
// 无 plan:纯 ReAct 模式。
|
||||
return buildStageMessages(
|
||||
|
||||
return buildExecuteStageMessages(
|
||||
BuildExecuteReActSystemPrompt(),
|
||||
state,
|
||||
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 模式的用户提示词。
|
||||
func BuildExecuteUserPrompt(state *newagentmodel.CommonState) string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString("请继续当前任务的执行阶段。\n")
|
||||
sb.WriteString(renderStateSummary(state))
|
||||
sb.WriteString("\n")
|
||||
|
||||
// 明确列出任务类 IDs,与 Plan 阶段保持信息对称,避免 LLM 因 plan 步骤中引用了 ID
|
||||
// 而在 Execute 阶段找不到显式来源,误触 rule 5(缺少关键上下文)→ ask_user。
|
||||
if state != nil && len(state.TaskClassIDs) > 0 {
|
||||
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())
|
||||
func BuildExecuteUserPrompt(_ *newagentmodel.CommonState) string {
|
||||
return strings.TrimSpace(`
|
||||
请继续当前任务的执行阶段,严格输出 JSON。
|
||||
输出字段:
|
||||
- speak
|
||||
- action
|
||||
- reason
|
||||
- goal_check
|
||||
- tool_call
|
||||
- abort
|
||||
`)
|
||||
}
|
||||
|
||||
// BuildExecuteReActUserPrompt 构造纯 ReAct 模式的用户提示词。
|
||||
func BuildExecuteReActUserPrompt(state *newagentmodel.CommonState) string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString("当前为自由执行模式,无预定义计划步骤。\n")
|
||||
sb.WriteString("请根据用户意图直接使用工具完成请求。\n\n")
|
||||
|
||||
sb.WriteString(renderStateSummary(state))
|
||||
sb.WriteString("\n")
|
||||
|
||||
if state != nil && len(state.TaskClassIDs) > 0 {
|
||||
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())
|
||||
// BuildExecuteReActUserPrompt 构造自由执行模式的用户提示词。
|
||||
func BuildExecuteReActUserPrompt(_ *newagentmodel.CommonState) string {
|
||||
return strings.TrimSpace(`
|
||||
请继续当前任务的执行阶段,严格输出 JSON。
|
||||
输出字段:
|
||||
- speak
|
||||
- action
|
||||
- reason
|
||||
- goal_check
|
||||
- tool_call
|
||||
- abort
|
||||
`)
|
||||
}
|
||||
|
||||
589
backend/newAgent/prompt/execute_context.go
Normal file
589
backend/newAgent/prompt/execute_context.go
Normal 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, "- 阶段约束:粗排已完成,本轮只微调 suggested;existing 仅作已安排事实参考,不做 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, ","))
|
||||
}
|
||||
@@ -27,7 +27,7 @@ const planSystemPrompt = `
|
||||
条件2:用户意图明确是"批量安排/帮我排课/把任务类排进日程"等批量调度需求。
|
||||
满足时:后端会在用户确认计划后自动运行粗排算法(硬性约束已由算法保证,无需 LLM 校验)。
|
||||
你的 plan_steps 应聚焦于"用读写工具优化方案",建议两步:
|
||||
第1步:用 get_overview / find_free 等读工具审视粗排结果,找出可优化的点(时段分布不均、空位未利用等);
|
||||
第1步:用 get_overview / find_first_free 等读工具审视粗排结果,找出可优化的点(时段分布不均、空位未利用等);
|
||||
第2步:用 move / batch_move 等写工具微调后,将最终方案展示给用户确认。
|
||||
禁止安排任何"校验/验证约束"步骤——硬性约束由算法兜底,LLM 不需要操心。
|
||||
|
||||
|
||||
@@ -178,7 +178,12 @@ DB 记录:
|
||||
|
||||
### 4.1 get_overview
|
||||
|
||||
获取规划窗口的粗粒度总览,用于建立全局感知。
|
||||
获取规划窗口总览(任务视角,全量返回)。
|
||||
|
||||
行为约束:
|
||||
- 保留课程占位统计(例如“第1天:占2/12”),避免误判可用空间。
|
||||
- 每日明细只展开任务(非课程),课程不进入任务明细列表。
|
||||
- 在当前阶段(窗口通常不超过 30 天)直接全量返回,不做截断。
|
||||
|
||||
**入参:** 无
|
||||
|
||||
@@ -186,25 +191,19 @@ DB 记录:
|
||||
|
||||
```
|
||||
规划窗口共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节)
|
||||
第2天:占2/12 — [5]物理(3-4节)
|
||||
第3天:占0/12
|
||||
第4天:占8/12 — [1]高等数学(1-2节) [6]线代(3-4节) [8]程序设计(9-10节)
|
||||
第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
|
||||
第1天:总占6/12(课程占6/12,任务占0/12) — 任务:无
|
||||
第2天:总占2/12(课程占2/12,任务占0/12) — 任务:无
|
||||
第3天:总占2/12(课程占0/12,任务占2/12) — 任务:[35]第一章随机事件与概率(suggested,第5-6节)
|
||||
第4天:总占4/12(课程占2/12,任务占2/12) — 任务:[36]第二章随机变量(suggested,第7-8节)
|
||||
...
|
||||
|
||||
可嵌入时段:第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个连续空闲时段的位置:
|
||||
|
||||
第2天 第5-8节(4时段连续空闲)
|
||||
第3天 第1-6节(6时段连续空闲)
|
||||
第3天 第7-12节(6时段连续空闲)
|
||||
第5天 第1-12节(12时段连续空闲)
|
||||
第6天 第3-5节(3时段连续空闲)
|
||||
第9天 第1-3节(3时段连续空闲)
|
||||
第10天 第5-7节(3时段连续空闲)
|
||||
|
||||
可嵌入位置(水课时段,可叠加任务):
|
||||
第7天 第1-2节([10]思政,当前无嵌入任务)
|
||||
首个可用位置:第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时段连续空闲)
|
||||
```
|
||||
|
||||
---
|
||||
@@ -290,8 +290,8 @@ DB 记录:
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| category | string | 否 | 过滤类别(对应 TaskClass.Name,如"课程"、"学习") |
|
||||
| status | string | 否 | existing / suggested / pending / all,默认 all |
|
||||
| category | string | 否 | 过滤类别(对应 TaskClass.Name,如"课程"、"学习";不支持 task_class_ids 列表) |
|
||||
| status | string | 否 | existing / suggested / pending / all,默认 all(仅支持单值,不支持 `existing,suggested` 这类拼接) |
|
||||
|
||||
**返回示例(待安排):**
|
||||
|
||||
@@ -408,7 +408,7 @@ DB 记录:
|
||||
|
||||
### 5.2 move
|
||||
|
||||
移动已落位任务到新位置。
|
||||
移动已预排任务(仅 suggested)到新位置。
|
||||
|
||||
**入参:**
|
||||
|
||||
@@ -445,6 +445,10 @@ DB 记录:
|
||||
移动失败:[3]复习线代 当前为待安排状态,请使用 place 放置。
|
||||
```
|
||||
|
||||
```
|
||||
移动失败:[2]英语 当前为已安排(existing)任务,不允许 move;仅 suggested 任务可移动。
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5.3 swap
|
||||
@@ -484,7 +488,7 @@ DB 记录:
|
||||
|
||||
### 5.4 batch_move
|
||||
|
||||
批量原子移动多个任务,要么全部成功,要么全部回滚。
|
||||
批量原子移动多个任务(仅 suggested),要么全部成功,要么全部回滚。
|
||||
|
||||
**入参:**
|
||||
|
||||
@@ -553,7 +557,7 @@ DB 记录:
|
||||
### 状态约束
|
||||
- pending 任务只能 place,不能 move / swap / unplace
|
||||
- suggested 任务可以 move / swap / unplace
|
||||
- existing 任务可以 move / swap / unplace
|
||||
- existing 任务不能 move / batch_move(仅作已安排事实层)
|
||||
- 状态不符时返回明确错误信息
|
||||
|
||||
### 返回格式
|
||||
@@ -570,7 +574,7 @@ DB 记录:
|
||||
### 嵌入任务规则
|
||||
- `can_embed=true` 的任务(水课)允许其他任务嵌入到同一时段
|
||||
- 嵌入任务占位时不触发冲突检测(与宿主共存)
|
||||
- `find_free` 返回结果中标注可嵌入时段,让 LLM 知道哪里可以叠加
|
||||
- `find_first_free` 返回首个命中位,并附当日详细负载;`find_free` 为兼容别名
|
||||
- `place` 到可嵌入时段时,若已有宿主任务,自动标记 embed_host 关系
|
||||
- 嵌入任务的 locked 继承宿主:宿主不可移动时,嵌入任务也不可单独移动
|
||||
|
||||
|
||||
@@ -13,12 +13,16 @@ import (
|
||||
// - 只报当前真实状态,不做建议/推荐/假设
|
||||
// - 不暴露 source、source_id、event_type 内部字段
|
||||
|
||||
// GetOverview 获取规划窗口的粗粒度总览,用于建立全局感知。
|
||||
// 无参数,返回整个窗口的占用统计 + 每日概况 + 可嵌入时段 + 待安排任务。
|
||||
// GetOverview 获取规划窗口总览(任务视角,全量)。
|
||||
//
|
||||
// 设计约束:
|
||||
// 1. 日内“总占用”保留课程占位影响,避免 LLM 误判可用空间;
|
||||
// 2. 明细层不展开课程列表,只展开任务(非课程)清单;
|
||||
// 3. 当前按“窗口不超过 30 天”场景直接全量返回,不做结果截断。
|
||||
func GetOverview(state *ScheduleState) string {
|
||||
totalSlots := state.Window.TotalDays * 12
|
||||
|
||||
// 1. 统计总占用时段数(排除嵌入任务,嵌入与宿主共享时段)。
|
||||
// 1. 统计总占用(含课程占位)与空闲。
|
||||
totalOccupied := 0
|
||||
for i := range state.Tasks {
|
||||
t := &state.Tasks[i]
|
||||
@@ -31,80 +35,47 @@ func GetOverview(state *ScheduleState) string {
|
||||
}
|
||||
totalFree := totalSlots - totalOccupied
|
||||
|
||||
// 2. 统计任务状态分布。
|
||||
existingCount := 0
|
||||
suggestedCount := 0
|
||||
pendingCount := 0
|
||||
// 2. 统计“任务视角”状态分布,并单独统计课程条目数。
|
||||
taskExistingCount := 0
|
||||
taskSuggestedCount := 0
|
||||
taskPendingCount := 0
|
||||
courseExistingCount := 0
|
||||
for i := range state.Tasks {
|
||||
task := state.Tasks[i]
|
||||
if isCourseScheduleTask(task) {
|
||||
if IsExistingTask(task) {
|
||||
courseExistingCount++
|
||||
}
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case IsPendingTask(task):
|
||||
pendingCount++
|
||||
taskPendingCount++
|
||||
case IsSuggestedTask(task):
|
||||
suggestedCount++
|
||||
taskSuggestedCount++
|
||||
case IsExistingTask(task):
|
||||
existingCount++
|
||||
taskExistingCount++
|
||||
}
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
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")
|
||||
for day := 1; day <= state.Window.TotalDays; day++ {
|
||||
sb.WriteString(buildOverviewDayLine(state, day) + "\n")
|
||||
sb.WriteString(buildTaskOnlyOverviewDayLine(state, day) + "\n")
|
||||
}
|
||||
|
||||
// 4. 可嵌入时段汇总(单独列出,方便 LLM 快速定位)。
|
||||
embeddable := getEmbeddableTasks(state)
|
||||
if len(embeddable) > 0 {
|
||||
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")
|
||||
}
|
||||
// 4. 任务清单全量展开(不截断)。
|
||||
sb.WriteString("\n任务清单(全量,已过滤课程):\n")
|
||||
sb.WriteString(buildTaskOnlyOverviewList(state))
|
||||
|
||||
// 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. 任务类约束(排课策略与限制)。
|
||||
// 5. 任务类约束(排课策略与限制)。
|
||||
if len(state.TaskClasses) > 0 {
|
||||
sb.WriteString("\n任务类约束(排课时请遵守):\n")
|
||||
for _, tc := range state.TaskClasses {
|
||||
@@ -226,12 +197,16 @@ func queryRangeSpecific(state *ScheduleState, day, startSlot, endSlot int) strin
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// FindFree 查找满足指定连续时段长度的空闲位置。
|
||||
// duration 必填,day 选填(nil 表示搜索全部天)。
|
||||
// 返回所有 >= duration 的空闲连续区间 + 可嵌入位置。
|
||||
func FindFree(state *ScheduleState, duration int, day *int) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("满足%d个连续空闲时段的位置:\n\n", duration))
|
||||
// FindFirstFree 查找首个可用空位,并返回该日详细信息。
|
||||
//
|
||||
// 说明:
|
||||
// 1. 参数与旧 find_free 保持一致(duration/day);
|
||||
// 2. 返回“首个命中候选位 + 当日负载明细”,供 LLM 直接决策;
|
||||
// 3. 当前阶段按用户要求全量返回,不做文本截断。
|
||||
func FindFirstFree(state *ScheduleState, duration int, day *int) string {
|
||||
if duration <= 0 {
|
||||
return "查询失败:duration 必须大于 0。"
|
||||
}
|
||||
|
||||
// 1. 确定搜索范围。
|
||||
days := make([]int, 0)
|
||||
@@ -246,48 +221,262 @@ func FindFree(state *ScheduleState, duration int, day *int) string {
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 逐天查找满足条件的空闲区间。
|
||||
found := 0
|
||||
// 2. 按天从前往后寻找“首个可直接放置”的空位。
|
||||
for _, d := range days {
|
||||
freeRanges := findFreeRangesOnDay(state, d)
|
||||
for _, r := range freeRanges {
|
||||
rDur := r.slotEnd - r.slotStart + 1
|
||||
if rDur >= duration {
|
||||
sb.WriteString(fmt.Sprintf("第%d天 第%s(%d时段连续空闲)\n", d, formatSlotRange(r.slotStart, r.slotEnd), rDur))
|
||||
found++
|
||||
if rDur < duration {
|
||||
continue
|
||||
}
|
||||
slotStart := r.slotStart
|
||||
slotEnd := r.slotStart + duration - 1
|
||||
return buildFindFirstFreeReport(state, d, duration, slotStart, slotEnd, false, nil)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
embedStatus := "当前无嵌入任务"
|
||||
if t.EmbeddedBy != nil {
|
||||
guest := state.TaskByStateID(*t.EmbeddedBy)
|
||||
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()
|
||||
}
|
||||
|
||||
// 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 列出任务清单,可按类别和状态过滤。
|
||||
// category 选填(nil 不过滤),status 选填(nil 默认 "all")。
|
||||
// 输出按状态分组:已安排 -> 已预排 -> 待安排,组内按 stateID 升序。
|
||||
@@ -297,13 +486,25 @@ func ListTasks(state *ScheduleState, category, status *string) string {
|
||||
if status != nil {
|
||||
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. 过滤 + 分组。
|
||||
var existingTasks, suggestedTasks, pendingTasks []ScheduleTask
|
||||
for i := range state.Tasks {
|
||||
t := state.Tasks[i]
|
||||
// 类别过滤。
|
||||
if category != nil && t.Category != *category {
|
||||
if hasCategoryFilter && t.Category != categoryFilter {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -333,40 +534,119 @@ func ListTasks(state *ScheduleState, category, status *string) string {
|
||||
|
||||
// 4. 纯待安排模式:只输出待安排任务。
|
||||
if statusFilter == "pending" {
|
||||
if len(pendingTasks) == 0 {
|
||||
return formatListTasksEmptyResult(statusFilter, categoryFilter)
|
||||
}
|
||||
return formatPendingList(pendingTasks)
|
||||
}
|
||||
|
||||
// 5. 纯已预排模式:只输出已预排任务。
|
||||
if statusFilter == "suggested" {
|
||||
if len(suggestedTasks) == 0 {
|
||||
return formatListTasksEmptyResult(statusFilter, categoryFilter)
|
||||
}
|
||||
return formatSuggestedList(suggestedTasks)
|
||||
}
|
||||
|
||||
// 6. 纯已安排模式:只输出已安排任务。
|
||||
if statusFilter == "existing" {
|
||||
if len(existingTasks) == 0 {
|
||||
return formatListTasksEmptyResult(statusFilter, categoryFilter)
|
||||
}
|
||||
return formatExistingList(existingTasks)
|
||||
}
|
||||
|
||||
// 7. 全部模式:统计 + 分组输出。
|
||||
total := len(existingTasks) + len(suggestedTasks) + len(pendingTasks)
|
||||
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 {
|
||||
sb.WriteString("\n已安排:\n")
|
||||
sb.WriteString("\n已安排(existing):\n")
|
||||
sb.WriteString(formatExistingList(existingTasks))
|
||||
}
|
||||
if len(suggestedTasks) > 0 {
|
||||
sb.WriteString("\n已预排:\n")
|
||||
sb.WriteString("\n已预排(suggested):\n")
|
||||
sb.WriteString(formatSuggestedList(suggestedTasks))
|
||||
}
|
||||
if len(pendingTasks) > 0 {
|
||||
sb.WriteString("\n待安排:\n")
|
||||
sb.WriteString("\n待安排(pending):\n")
|
||||
sb.WriteString(formatPendingList(pendingTasks))
|
||||
}
|
||||
|
||||
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 查询单个任务的详细信息。
|
||||
// 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))
|
||||
|
||||
// 1. 类别、状态、来源。
|
||||
statusLabel := "已安排"
|
||||
statusLabel := "已安排(existing)"
|
||||
if IsPendingTask(*task) {
|
||||
statusLabel = "待安排"
|
||||
statusLabel = "待安排(pending)"
|
||||
} else if IsSuggestedTask(*task) {
|
||||
statusLabel = "已预排"
|
||||
statusLabel = "已预排(suggested)"
|
||||
} else if task.Locked {
|
||||
statusLabel = "已安排(固定)"
|
||||
statusLabel = "已安排(existing,固定)"
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("类别:%s | 状态:%s\n", task.Category, statusLabel))
|
||||
sb.WriteString(fmt.Sprintf("来源:%s\n", formatSourceName(task.Source)))
|
||||
|
||||
@@ -97,13 +97,13 @@ var writeTools = map[string]bool{
|
||||
|
||||
// ==================== 默认注册表 ====================
|
||||
|
||||
// NewDefaultRegistry 创建包含全部 10 个日程工具的注册表。
|
||||
// NewDefaultRegistry 创建默认日程工具注册表。
|
||||
func NewDefaultRegistry() *ToolRegistry {
|
||||
r := NewToolRegistry()
|
||||
|
||||
// --- 读工具 ---
|
||||
r.Register("get_overview",
|
||||
"获取规划窗口的粗粒度总览,包括每日占用、可嵌入时段和待安排任务。",
|
||||
"获取规划窗口总览(任务视角,全量返回):保留课程占位统计,展开任务清单(过滤课程明细)。",
|
||||
`{"name":"get_overview","parameters":{}}`,
|
||||
func(state *ScheduleState, args map[string]any) string {
|
||||
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",
|
||||
"查找满足指定连续时段长度的空闲位置。duration 必填,day 选填(不填搜全部天)。",
|
||||
"兼容别名,行为同 find_first_free。",
|
||||
`{"name":"find_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 FindFree(state, duration, argsIntPtr(args, "day"))
|
||||
return FindFirstFree(state, duration, argsIntPtr(args, "day"))
|
||||
},
|
||||
)
|
||||
|
||||
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"]}}}`,
|
||||
func(state *ScheduleState, args map[string]any) string {
|
||||
return ListTasks(state, argsStringPtr(args, "category"), argsStringPtr(args, "status"))
|
||||
@@ -176,7 +189,7 @@ func NewDefaultRegistry() *ToolRegistry {
|
||||
)
|
||||
|
||||
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}}}`,
|
||||
func(state *ScheduleState, args map[string]any) string {
|
||||
taskID, ok := argsInt(args, "task_id")
|
||||
@@ -212,7 +225,7 @@ func NewDefaultRegistry() *ToolRegistry {
|
||||
)
|
||||
|
||||
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"}}}}`,
|
||||
func(state *ScheduleState, args map[string]any) string {
|
||||
moves, err := argsMoveList(args)
|
||||
|
||||
@@ -92,7 +92,7 @@ func Place(state *ScheduleState, taskID, day, slotStart int) string {
|
||||
// ==================== Move ====================
|
||||
|
||||
// Move 将一个已落位任务移动到新位置。
|
||||
// taskID 允许是 suggested / existing,但不能是真实 pending。
|
||||
// taskID 仅允许 suggested;existing/pending 都不允许移动。
|
||||
func Move(state *ScheduleState, taskID, newDay, newSlotStart int) string {
|
||||
// 1. 查找任务。
|
||||
task := state.TaskByStateID(taskID)
|
||||
@@ -101,8 +101,14 @@ func Move(state *ScheduleState, taskID, newDay, newSlotStart int) string {
|
||||
}
|
||||
|
||||
// 2. 校验状态。
|
||||
if IsPendingTask(*task) {
|
||||
return fmt.Sprintf("移动失败:[%d]%s 当前为待安排状态,请使用 place 放置。", task.StateID, task.Name)
|
||||
if !IsSuggestedTask(*task) {
|
||||
// 2.1 pending 任务尚未落位,应通过 place 安排;
|
||||
// 2.2 existing 任务属于已安排事实层,不允许在 execute 微调里直接 move;
|
||||
// 2.3 仅 suggested 属于“本轮可微调建议落位”。
|
||||
if IsPendingTask(*task) {
|
||||
return fmt.Sprintf("移动失败:[%d]%s 当前为待安排状态,请使用 place 放置。", task.StateID, task.Name)
|
||||
}
|
||||
return fmt.Sprintf("移动失败:[%d]%s 当前为已安排(existing)任务,不允许 move;仅 suggested 任务可移动。", task.StateID, task.Name)
|
||||
}
|
||||
|
||||
// 3. 校验锁定。
|
||||
@@ -248,6 +254,7 @@ func Swap(state *ScheduleState, taskAID, taskBID int) string {
|
||||
// ==================== BatchMove ====================
|
||||
|
||||
// BatchMove 原子性地批量移动多个任务。
|
||||
// moves 中每个 task_id 都必须是 suggested;existing/pending 任一命中都会整批失败。
|
||||
// 全部成功才生效,任一失败则完全回滚。
|
||||
func BatchMove(state *ScheduleState, moves []MoveRequest) string {
|
||||
if len(moves) == 0 {
|
||||
@@ -260,8 +267,14 @@ func BatchMove(state *ScheduleState, moves []MoveRequest) string {
|
||||
if task == nil {
|
||||
return fmt.Sprintf("批量移动失败,全部回滚,无任何变更。\n任务ID %d 不存在(第%d条移动请求)。", m.TaskID, i+1)
|
||||
}
|
||||
if IsPendingTask(*task) {
|
||||
return fmt.Sprintf("批量移动失败,全部回滚,无任何变更。\n[%d]%s 当前为待安排状态,请使用 place(第%d条移动请求)。",
|
||||
if !IsSuggestedTask(*task) {
|
||||
// 1.1 保持与 Move 一致:批量移动仅允许 suggested;
|
||||
// 1.2 pending / existing 任一命中都应整批失败并回滚。
|
||||
if IsPendingTask(*task) {
|
||||
return fmt.Sprintf("批量移动失败,全部回滚,无任何变更。\n[%d]%s 当前为待安排状态,请使用 place(第%d条移动请求)。",
|
||||
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 {
|
||||
|
||||
465
backend/newAgent/阶段3_上下文瘦身设计.md
Normal file
465
backend/newAgent/阶段3_上下文瘦身设计.md
Normal 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 阶段还没有真正完成。
|
||||
Reference in New Issue
Block a user