Version: 0.9.4.dev.260407

后端:
1.粗排结果/预览语义修复(task_item suggested 保真 + existing/嵌入识别补全)
- 更新conv/schedule_state.go:LoadScheduleState 补齐 event.rel_id / schedules.embedded_task_id / task_item.embedded_time 三种“已落位”信号;嵌入任务强制 existing + 继承 host slots;补充 task_item duration/name/slot helper;Diff 相关英文注释改中文
- 更新conv/schedule_preview.go:预览层新增 shouldMarkSuggestedInPreview,pending 任务与 source=task_item 的建议态任务统一输出 suggested
2.newAgent 状态快照增强(ScheduleState/OriginalScheduleState 跨轮恢复)
- 更新model/state_store.go:AgentStateSnapshot 新增 ScheduleState / OriginalScheduleState
- 更新model/graph_run_state.go:AgentGraphRunInput/AgentGraphState 接入两份 schedule 状态;恢复旧快照时自动补 original clone
- 更新service/agentsvc/agent_newagent.go:loadOrCreateRuntimeState 返回并恢复 schedule/original;runNewAgentGraph 透传到 graph
- 更新node/agent_nodes.go:saveAgentState 一并保存 schedule/original 到 Redis 快照 3.Execute 链路纠偏(只写内存不落库 + 完整打点 + 恢复消息去重)
- 更新node/execute.go:AlwaysExecute/confirm resume 路径取消 PersistScheduleChanges,仅保留内存写;新增 execute LLM 完整上下文日志;新增工具调用前后 state 摘要日志;thinking 模式改为 enabled
- 更新node/chat.go:pending resume 不再重复写入同一轮 user message
- 更新service/agentsvc/agent_newagent.go:新增 deliver preview write/state 摘要日志,便于排查 suggested 丢失问题
4.AlwaysExecute 贯通 Plan→Graph→Execute
- 更新node/plan.go:PlanNodeInput 新增 AlwaysExecute;plan_done 后支持自动确认直接进入执行
- 更新graph/common_graph.go:branchAfterPlan 支持 PhaseExecuting/PhaseDone 分支
5.排课上下文补强(显式注入 task_class_ids,减少 Execute 误 ask_user)
- 更新prompt/execute.go:Plan/ReAct 两种 execute prompt 都显式写入任务类 ID,声明“上下文已完整,无需追问”
- 更新node/rough_build.go:粗排完成 pinned block 显式标注任务类 ID,避免 Execute 找不到 ID 来源
6.流式输出与预览调试工具修复
- 更新stream/emitter.go:保留换行,修复 pseudo stream 分片后文本黏连/双换行问题
- 更新infra/schedule_preview_viewer.html:升级预览工具,支持 candidate_plans / hybrid_entries

前端:无
仓库:
1.更新了infra内的html,适应了获取日程接口
This commit is contained in:
LoveLosita
2026-04-07 21:13:59 +08:00
parent 32bb740b75
commit 07d307fe07
15 changed files with 1378 additions and 400 deletions

View File

@@ -60,7 +60,7 @@ func ScheduleStateToPreview(
}
// Status 映射existing 不变pending有位置= suggested。
if t.Status == "pending" {
if shouldMarkSuggestedInPreview(*t) {
entry.Status = "suggested"
} else {
entry.Status = "existing"
@@ -109,3 +109,19 @@ func ScheduleStateToPreview(
GeneratedAt: time.Now(),
}
}
// shouldMarkSuggestedInPreview 判断某条 ScheduleTask 在预览层是否应标记为 suggested。
//
// 规则说明:
// 1. pending 任务在预览语义中属于“建议态”;
// 2. source=task_item 且 Duration>0 的任务来自待排任务池,
// 即使工具层在 place 后把它改成 existing预览层也要继续按 suggested 输出。
func shouldMarkSuggestedInPreview(t newagenttools.ScheduleTask) bool {
if t.Status == "pending" {
return true
}
if t.Source == "task_item" && t.Duration > 0 {
return true
}
return false
}

View File

@@ -7,22 +7,18 @@ import (
newagenttools "github.com/LoveLosita/smartflow/backend/newAgent/tools"
)
// WindowDay represents a single day in the planning window.
// WindowDay 表示排课窗口中的一天(相对周 + 周几)。
type WindowDay struct {
Week int
DayOfWeek int
}
// ==================== Load: DB → State ====================
// LoadScheduleState builds a ScheduleState from database query results.
// LoadScheduleState 将数据库层的 schedules + taskClasses 聚合为 newAgent 工具层可直接操作的 ScheduleState。
//
// Parameters:
// - schedules: existing Schedule records in the window (must preload Event and EmbeddedTask)
// - taskClasses: TaskClasses being scheduled (must preload Items)
// - extraItemCategories: optional TaskClassItem.ID → category name,
// for task events not belonging to the provided taskClasses
// - windowDays: ordered (week, day_of_week) pairs defining the planning window
// 职责边界:
// 1. 只负责数据映射与状态归一,不做数据库读写;
// 2. 同时兼容三种“任务已落位”信号event.rel_id、schedules.embedded_task_id、task_item.embedded_time
// 3. 对嵌入课程任务优先判定为 existing避免误挂回 pending。
func LoadScheduleState(
schedules []model.Schedule,
taskClasses []model.TaskClass,
@@ -37,27 +33,28 @@ func LoadScheduleState(
Tasks: make([]newagenttools.ScheduleTask, 0),
}
// --- Step 1: Build day mapping and lookup index ---
// 1. 构建 day_index 与 (week, day_of_week) 的双向转换基础索引。
dayLookup := make(map[[2]int]int, len(windowDays))
for i, wd := range windowDays {
idx := i + 1
dayIndex := i + 1
state.Window.DayMapping[i] = newagenttools.DayMapping{
DayIndex: idx,
DayIndex: dayIndex,
Week: wd.Week,
DayOfWeek: wd.DayOfWeek,
}
dayLookup[[2]int{wd.Week, wd.DayOfWeek}] = idx
dayLookup[[2]int{wd.Week, wd.DayOfWeek}] = dayIndex
}
// --- Step 2: Build itemID → categoryName lookup ---
// extraItemCategories first (lower priority), then taskClasses overwrites (higher priority).
// 2. 构建 task_item -> 分类名映射。
// 2.1 先放 extraItemCategories(低优先级,兜底);
// 2.2 再用 taskClasses 覆盖(高优先级,确保本轮排课分类准确)。
itemCategoryLookup := make(map[int]string)
for id, name := range extraItemCategories {
itemCategoryLookup[id] = name
}
for _, tc := range taskClasses {
catName := "任务"
if tc.Name != nil {
if tc.Name != nil && *tc.Name != "" {
catName = *tc.Name
}
for _, item := range tc.Items {
@@ -65,14 +62,13 @@ func LoadScheduleState(
}
}
// --- Step 3: Process existing schedules → existing tasks ---
// 3. 先把 schedules 聚合成 event 任务existing
type slotGroup struct {
week int
dayOfWeek int
sections []int
}
eventSlotMap := make(map[int][]slotGroup) // eventID → groups
eventSlotMap := make(map[int][]slotGroup) // eventID -> 多天多段槽位
eventInfo := make(map[int]*model.ScheduleEvent)
for i := range schedules {
@@ -104,46 +100,45 @@ func LoadScheduleState(
}
nextStateID := 1
eventStateIDs := make(map[int]int) // eventID stateID
eventStateIDs := make(map[int]int) // eventID -> stateID
for eventID, groups := range eventSlotMap {
event := eventInfo[eventID]
if event == nil {
continue
}
// Category
category := "课程"
if event.Type == "task" {
category = "任务"
if event.RelID != nil {
if cat, ok := itemCategoryLookup[*event.RelID]; ok {
if cat, ok := itemCategoryLookup[*event.RelID]; ok && cat != "" {
category = cat
}
}
}
// Locked: course + not embeddable
locked := event.Type == "course" && !event.CanBeEmbedded
// Compress sections into slot ranges
var slots []newagenttools.TaskSlot
for _, g := range groups {
if len(g.sections) == 0 {
continue
}
sort.Ints(g.sections)
start, end := g.sections[0], g.sections[0]
for _, sec := range g.sections[1:] {
if sec == end+1 {
end = sec
} else {
if day, ok := dayLookup[[2]int{g.week, g.dayOfWeek}]; ok {
slots = append(slots, newagenttools.TaskSlot{Day: day, SlotStart: start, SlotEnd: end})
}
start, end = sec, sec
continue
}
if day, ok := dayLookup[[2]int{g.week, g.dayOfWeek}]; ok {
slots = append(slots, newagenttools.TaskSlot{Day: day, SlotStart: start, SlotEnd: end})
}
start, end = sec, sec
}
if day, ok := dayLookup[[2]int{g.week, g.dayOfWeek}]; ok {
slots = append(slots, newagenttools.TaskSlot{Day: day, SlotStart: start, SlotEnd: end})
}
}
// Sort slots by day, then slot_start
sort.Slice(slots, func(i, j int) bool {
if slots[i].Day != slots[j].Day {
return slots[i].Day < slots[j].Day
@@ -168,32 +163,91 @@ func LoadScheduleState(
nextStateID++
}
// --- Step 4: Process pending task items → pending tasks ---
itemStateIDs := make(map[int]int) // TaskClassItem.ID → stateID
// 4. 构建 task_item 占位索引(后续 pending 判定优先用这两个索引短路)。
// 4.1 event.rel_id 占位:该 item 已有 task event
// 4.2 schedules.embedded_task_id 占位:该 item 已嵌入到课程槽位。
itemIDToTaskEventStateID := make(map[int]int)
for eventID, stateID := range eventStateIDs {
event := eventInfo[eventID]
if event == nil || event.Type != "task" || event.RelID == nil {
continue
}
itemIDToTaskEventStateID[*event.RelID] = stateID
}
itemIDToEmbedHostStateID := make(map[int]int)
for i := range schedules {
s := &schedules[i]
if s.EmbeddedTaskID == nil {
continue
}
hostStateID, ok := eventStateIDs[s.EventID]
if !ok {
continue
}
itemIDToEmbedHostStateID[*s.EmbeddedTaskID] = hostStateID
}
// 5. 处理 task_items
// 5.1 先消化 existingtask event / 课程嵌入 / embedded_time
// 5.2 剩余条目再按 status 判 pending。
itemStateIDs := make(map[int]int) // task_item_id -> stateID
for _, tc := range taskClasses {
catName := "任务"
if tc.Name != nil {
if tc.Name != nil && *tc.Name != "" {
catName = *tc.Name
}
catID := tc.ID
defaultDuration := estimateTaskItemDuration(tc)
pendingCount := 0
for _, item := range tc.Items {
if item.Status == nil || *item.Status != model.TaskItemStatusUnscheduled {
if stateID, ok := itemIDToTaskEventStateID[item.ID]; ok {
itemStateIDs[item.ID] = stateID
continue
}
duration := 2
if tc.TotalSlots != nil && *tc.TotalSlots > 0 && len(tc.Items) > 0 {
if d := *tc.TotalSlots / len(tc.Items); d > 0 {
duration = d
if hostStateID, ok := itemIDToEmbedHostStateID[item.ID]; ok {
hostSlots := []newagenttools.TaskSlot(nil)
if hostTask := state.TaskByStateID(hostStateID); hostTask != nil {
hostSlots = cloneTaskSlots(hostTask.Slots)
}
stateID := nextStateID
state.Tasks = append(state.Tasks, newagenttools.ScheduleTask{
StateID: stateID,
Source: "task_item",
SourceID: item.ID,
Name: taskItemName(item),
Category: catName,
Status: "existing",
Slots: hostSlots,
CategoryID: tc.ID,
TaskClassID: tc.ID,
})
itemStateIDs[item.ID] = stateID
nextStateID++
continue
}
name := ""
if item.Content != nil {
name = *item.Content
if slots, ok := slotsFromTargetTime(item.EmbeddedTime, dayLookup); ok {
stateID := nextStateID
state.Tasks = append(state.Tasks, newagenttools.ScheduleTask{
StateID: stateID,
Source: "task_item",
SourceID: item.ID,
Name: taskItemName(item),
Category: catName,
Status: "existing",
Slots: slots,
CategoryID: tc.ID,
TaskClassID: tc.ID,
})
itemStateIDs[item.ID] = stateID
nextStateID++
continue
}
if !isTaskItemPending(item) {
continue
}
stateID := nextStateID
@@ -201,11 +255,11 @@ func LoadScheduleState(
StateID: stateID,
Source: "task_item",
SourceID: item.ID,
Name: name,
Name: taskItemName(item),
Category: catName,
Status: "pending",
Duration: duration,
CategoryID: catID,
Duration: defaultDuration,
CategoryID: tc.ID,
TaskClassID: tc.ID,
})
itemStateIDs[item.ID] = stateID
@@ -213,7 +267,7 @@ func LoadScheduleState(
pendingCount++
}
// 有待安排 item 的任务类才暴露约束给 LLM。
// 仅当该任务类仍有 pending item 时,才把约束暴露给 LLM。
if pendingCount > 0 {
meta := newagenttools.TaskClassMeta{
ID: tc.ID,
@@ -241,92 +295,181 @@ func LoadScheduleState(
}
}
// --- Step 5: Resolve embed relationships ---
// Extend itemStateIDs with existing task events (rel_id → stateID)
for eventID, stateID := range eventStateIDs {
event := eventInfo[eventID]
if event.Type == "task" && event.RelID != nil {
if _, exists := itemStateIDs[*event.RelID]; !exists {
itemStateIDs[*event.RelID] = stateID
}
}
}
// 6. 统一回填嵌入关系:
// 6.1 host 记录 EmbeddedBy
// 6.2 guest 记录 EmbedHost
// 6.3 guest 强制 existing + host slots防止“嵌入任务残留 pending”。
for i := range schedules {
s := &schedules[i]
if s.EmbeddedTaskID == nil || s.Event == nil {
if s.EmbeddedTaskID == nil {
continue
}
hostStateID, ok := eventStateIDs[s.EventID]
if !ok {
continue
}
guestStateID, ok := itemStateIDs[*s.EmbeddedTaskID]
hostTask := state.TaskByStateID(hostStateID)
itemID := *s.EmbeddedTaskID
guestStateID, ok := itemStateIDs[itemID]
if !ok {
continue
// 兜底:只在 schedules 层看到嵌入关系taskClasses 不含该 item 时补建 guest。
name := ""
categoryID := 0
taskClassID := 0
if s.EmbeddedTask != nil {
name = taskItemName(*s.EmbeddedTask)
if s.EmbeddedTask.CategoryID != nil {
categoryID = *s.EmbeddedTask.CategoryID
taskClassID = *s.EmbeddedTask.CategoryID
}
}
category := "任务"
if cat, exists := itemCategoryLookup[itemID]; exists && cat != "" {
category = cat
}
hostSlots := []newagenttools.TaskSlot(nil)
if hostTask != nil {
hostSlots = cloneTaskSlots(hostTask.Slots)
}
guestStateID = nextStateID
state.Tasks = append(state.Tasks, newagenttools.ScheduleTask{
StateID: guestStateID,
Source: "task_item",
SourceID: itemID,
Name: name,
Category: category,
Status: "existing",
Slots: hostSlots,
CategoryID: categoryID,
TaskClassID: taskClassID,
})
itemStateIDs[itemID] = guestStateID
nextStateID++
}
// Host: record which guest is embedded
hostTask := state.TaskByStateID(hostStateID)
if hostTask != nil && hostTask.EmbeddedBy == nil {
v := guestStateID
hostTask.EmbeddedBy = &v
}
// Guest: record which host it's embedded into, copy host's slots
guestTask := state.TaskByStateID(guestStateID)
if guestTask != nil && guestTask.EmbedHost == nil {
if guestTask == nil {
continue
}
if guestTask.EmbedHost == nil {
v := hostStateID
guestTask.EmbedHost = &v
}
guestTask.Status = "existing"
if hostTask != nil && len(guestTask.Slots) == 0 {
guestTask.Slots = cloneTaskSlots(hostTask.Slots)
}
// existing 的 task_item 不应再携带 Duration避免预览层误判成 suggested。
guestTask.Duration = 0
}
return state
}
// ==================== Diff: State comparison ====================
// isTaskItemPending 仅根据 status 判断是否应进入 pending 池。
//
// 说明:
// 1. status=nil 兼容历史数据,按“未安排”处理;
// 2. 仅 status=TaskItemStatusUnscheduled 进入 pending
// 3. 其它“已安排”信号由 LoadScheduleState 主流程统一判定,避免多处口径不一致。
func isTaskItemPending(item model.TaskClassItem) bool {
if item.Status == nil {
return true
}
return *item.Status == model.TaskItemStatusUnscheduled
}
// ScheduleChangeType classifies the type of state change.
// estimateTaskItemDuration 估算 pending 任务默认时长。
//
// 规则:若任务类声明了 total_slots则按 total_slots / item_count 取整(最少 1
// 否则回退到 2 节。
func estimateTaskItemDuration(tc model.TaskClass) int {
duration := 2
if tc.TotalSlots != nil && *tc.TotalSlots > 0 && len(tc.Items) > 0 {
if d := *tc.TotalSlots / len(tc.Items); d > 0 {
duration = d
}
}
return duration
}
// taskItemName 读取任务项展示名。
func taskItemName(item model.TaskClassItem) string {
if item.Content == nil {
return ""
}
return *item.Content
}
// slotsFromTargetTime 将 task_items.embedded_time 转换为 state 的槽位结构。
// 若 target 为空、节次非法、或不在窗口内,返回 false。
func slotsFromTargetTime(
target *model.TargetTime,
dayLookup map[[2]int]int,
) ([]newagenttools.TaskSlot, bool) {
if target == nil {
return nil, false
}
if target.SectionFrom < 1 || target.SectionTo < target.SectionFrom {
return nil, false
}
day, ok := dayLookup[[2]int{target.Week, target.DayOfWeek}]
if !ok {
return nil, false
}
return []newagenttools.TaskSlot{
{
Day: day,
SlotStart: target.SectionFrom,
SlotEnd: target.SectionTo,
},
}, true
}
// ScheduleChangeType 表示两份 ScheduleState 对比后的变更类型。
type ScheduleChangeType string
const (
ChangePlace ScheduleChangeType = "place" // pending → placed
ChangeMove ScheduleChangeType = "move" // slots relocated
ChangeUnplace ScheduleChangeType = "unplace" // placed → pending
ChangePlace ScheduleChangeType = "place" // pending 变为已放置
ChangeMove ScheduleChangeType = "move" // 已有槽位发生移动
ChangeUnplace ScheduleChangeType = "unplace" // 从已放置变回 pending
)
// SlotCoord is an individual section position in DB coordinates (week, day_of_week, section).
// SlotCoord 表示数据库坐标系中的单节槽位(week/day_of_week/section)。
type SlotCoord struct {
Week int
DayOfWeek int
Section int
}
// ScheduleChange represents a single task change between original and modified state.
// ScheduleChange 描述单个任务在前后状态间的变化。
type ScheduleChange struct {
Type ScheduleChangeType
StateID int
Source string // "event" | "task_item"
SourceID int // ScheduleEvent.ID or TaskClassItem.ID
EventType string // "course" | "task" (source=event only)
CategoryID int // source=task_item only
SourceID int // ScheduleEvent.ID TaskClassItem.ID
EventType string // 仅 source=event 时有意义course/task
CategoryID int // source=task_item 时有意义
Name string
// For place/move: new slot positions (expanded to individual sections)
// place/move 的新位置(展开到逐节坐标)。
NewCoords []SlotCoord
// For move/unplace: old slot positions
// move/unplace 的旧位置(展开到逐节坐标)。
OldCoords []SlotCoord
// HostEventID: source=task_item 嵌入路径时,宿主课程的 schedule_event.id
// Place/Unplace当前操作位置的宿主 EventID0 表示非嵌入)。
// Move新位置的宿主 EventID。
// HostEventID:变更后位置对应的宿主 event非嵌入为 0
HostEventID int
// OldHostEventID: Move 时旧位置的宿主 EventID0 表示旧位置非嵌入)。
// OldHostEventIDmove 时旧位置对应的宿主 event(非嵌入为 0)。
OldHostEventID int
}
// DiffScheduleState compares original and modified ScheduleState,
// returning the changes that need to be persisted to the database.
// DiffScheduleState 比较 original modified,返回需要持久化的变更集合。
func DiffScheduleState(
original *newagenttools.ScheduleState,
modified *newagenttools.ScheduleState,
@@ -347,7 +490,6 @@ func DiffScheduleState(
hadSlots := orig != nil && len(orig.Slots) > 0
switch {
// Place: pending → has slots
case wasPending && hasSlots:
changes = append(changes, ScheduleChange{
Type: ChangePlace,
@@ -360,8 +502,6 @@ func DiffScheduleState(
NewCoords: expandToCoords(mod.Slots, modified),
HostEventID: resolveHostEventID(mod, modified),
})
// Move: had slots → different slots
case hadSlots && hasSlots && !slotsEqual(orig.Slots, mod.Slots):
changes = append(changes, ScheduleChange{
Type: ChangeMove,
@@ -376,8 +516,6 @@ func DiffScheduleState(
HostEventID: resolveHostEventID(mod, modified),
OldHostEventID: resolveHostEventID(orig, original),
})
// Unplace: had slots → no slots
case hadSlots && !hasSlots:
changes = append(changes, ScheduleChange{
Type: ChangeUnplace,
@@ -395,7 +533,7 @@ func DiffScheduleState(
return changes
}
// indexByStateID creates a map of stateID → *ScheduleTask.
// indexByStateID 将任务列表按 state_id 建立索引。
func indexByStateID(state *newagenttools.ScheduleState) map[int]*newagenttools.ScheduleTask {
m := make(map[int]*newagenttools.ScheduleTask, len(state.Tasks))
for i := range state.Tasks {
@@ -404,7 +542,7 @@ func indexByStateID(state *newagenttools.ScheduleState) map[int]*newagenttools.S
return m
}
// slotsEqual compares two TaskSlot slices for equality.
// slotsEqual 判断两个压缩槽位切片是否完全一致。
func slotsEqual(a, b []newagenttools.TaskSlot) bool {
if len(a) != len(b) {
return false
@@ -417,9 +555,18 @@ func slotsEqual(a, b []newagenttools.TaskSlot) bool {
return true
}
// resolveHostEventID 从任务的 EmbedHost 字段反查宿主的 ScheduleEvent.ID
// 用于 DiffScheduleState 在生成 ScheduleChange 时记录嵌入路径的宿主 EventID。
// 若任务非嵌入EmbedHost == nil或宿主不存在返回 0。
// cloneTaskSlots 深拷贝槽位切片
func cloneTaskSlots(src []newagenttools.TaskSlot) []newagenttools.TaskSlot {
if len(src) == 0 {
return nil
}
dst := make([]newagenttools.TaskSlot, len(src))
copy(dst, src)
return dst
}
// resolveHostEventID 通过任务的 EmbedHost 反查宿主 event_id。
// 非嵌入任务或宿主不存在时返回 0。
func resolveHostEventID(task *newagenttools.ScheduleTask, state *newagenttools.ScheduleState) int {
if task == nil || task.EmbedHost == nil {
return 0
@@ -431,7 +578,7 @@ func resolveHostEventID(task *newagenttools.ScheduleTask, state *newagenttools.S
return host.SourceID
}
// expandToCoords converts compressed TaskSlots to individual SlotCoords.
// expandToCoords 将压缩槽位展开成逐节坐标,便于后续持久化层处理。
func expandToCoords(slots []newagenttools.TaskSlot, state *newagenttools.ScheduleState) []SlotCoord {
var coords []SlotCoord
for _, slot := range slots {

View File

@@ -0,0 +1,322 @@
# Handoff
以下内容可直接交给下一位助理继续做。
## 目标
当前有两条主线要继续推进:
1. 粗排算法修复与链路纠偏
目标:粗排完成后,不应该再把 LLM 引导到“手动一个个 `place` 补洞”。如果粗排后仍有 `pending`,按当前业务理解,这属于异常,应直接终止并报错,而不是继续优化或补排。
2. `execute` 上下文瘦身 + 可插拔 prompt 重构
目标:把现在的“消息流水账堆砌”改成“结构化执行简报”,并且 prompt 不能写死成排程专用,要能复用于排程、加任务、学习计划等不同任务域。
## 用户已经明确确认的业务结论
- `always_execute`、后端是否自动放行、是否写库,这些是后端执行层语义,不应写进 prompt。
- LLM 只需要按统一协议产出 `continue / confirm / ask_user / done / abort` 这类动作;后端怎么处理是后端自己的事。
- 对排程场景LLM 的主要职责是“粗排后的优化器”,不是“粗排补洞工”。
- 如果“粗排完成后仍有 pending 任务”,这不是要让 LLM 手工 `place` 的正常状态,而是异常状态。
- prompt 需要明显的文字引导,必须有编号和子编号,让 LLM 每轮都收到一份规范文本。
- prompt 必须是可插拔的,不能写死成“排程优化”专用。
## 已经完成的改动
- 已修复“同一轮 user message 重复写入上下文”的问题。
实现位置:`backend/newAgent/node/chat.go`
改动点:`handleChatResume` 不再重复 `AppendHistory(schema.UserMessage(...))`,现在 user message 只在 service 层统一写入一次。
- 已经给 `execute` 节点加了完整上下文调试打点。
实现位置:`backend/newAgent/node/execute.go`
关键函数:`formatExecuteLLMMessagesForDebug`
- 之前已经做过一轮粗排结果接入修复:`makeRoughBuildFunc` 改为使用 `HybridScheduleWithPlanMultiFunc``entries` 结果,而不是只看 `[]TaskClassItem`
实现位置:`backend/service/agentsvc/agent_newagent.go`
## 当前上下文链路的真实现状
`execute` 真正喂给 LLM 的消息来自:
- `backend/newAgent/node/execute.go`
- `backend/newAgent/prompt/execute.go`
- `backend/newAgent/prompt/base.go`
当前拼装顺序是:
- `system`:基础 persona + execute 阶段规则
- `system`:工具摘要
- `history`:完整历史消息
- `system`pinned blocks
- `user`:运行时执行提示词
这套链路的核心问题不是“少了什么”,而是“保留了太多不该保留的东西”。
## 已确认的上下文膨胀问题
基于用户提供的第 13 轮上下文样本,当前冗余主要有这些:
- 大型 `tool result` 长期保留。
典型是 `get_overview``list_tasks``find_free` 的超长结果被反复塞进 history。
- 同工具同参数的重复查询长期保留。
例如 `find_free(duration=2)` 连续多次查询,主体内容几乎相同;`list_tasks(all)``get_overview` 也重复大量信息。
- 大量 assistant 过程性话术进入 history。
例如“我先查一下”“我需要先获取”“我将安排……请确认”这类文本,对后续决策价值很低,却持续吃 token。
- 失败回合被原样保留。
例如 `place``task_id``find_free``duration` 的失败记录,不需要完整原文链路,只需要摘要化保留“最近失败模式”。
- 指令层重复。
`renderStateSummary`、pinned blocks、运行时 user prompt 存在明显重叠。
- `newAgent` 目前没有接旧链路那套历史 token budget 裁剪。
对照位置:
- 新链路:`backend/service/agentsvc/agent_newagent.go`
- 旧链路:`backend/service/agentsvc/agent.go`
- token budget 工具:`backend/pkg/token_budget.go`
## 当前排程链路里最需要纠偏的错误引导
当前这段逻辑已经不符合用户现在确认的业务前提:
- `backend/newAgent/node/rough_build.go`
这里现在会在粗排后写入一段 pinned 文本,大意是:
- 如果还有 `pending`,就让 LLM 去 `get_overview/find_free/place`
- 重复 place直到 pending 归零
这段引导现在应视为错误业务语义。下一位助理需要重点改掉它。
## 粗排算法主线的交接意见
下一位助理要继续查两件事:
- 粗排算法本体是否真的仍会漏排。
重点排查:
- `makeRoughBuildFunc`
- `RunRoughBuildNode`
- `placements` 写入 `ScheduleState` 后,是否所有目标任务都应有初始落位
- 如果业务上“粗排不应漏排”已经成立,那么链路要改成:
- 粗排完成且 `pending > 0`:直接异常结束
- 不再把 LLM 引导成“手工补排”
- 最好在执行层支持 `abort` 语义,而不是让模型继续乱试
## prompt 重构主线的交接意见
用户已经认可的新方向是:把 prompt 改成“通用执行内核 + 可插拔领域模块 + 当前任务简报”。
推荐的 3-message 结构如下。
### 第一条消息:通用执行内核
职责:
- 定义 agent 身份
- 定义通用规则
- 定义通用动作协议
- 提供最小必要的 JSON 示例
### 第二条消息:领域模块
职责:
- 注入当前领域名称、职责边界、目标、非目标
- 注入领域工具简表
- 注入领域硬约束、软目标
- 注入异常定义与完成判定
### 第三条消息:运行时任务简报
职责:
- 给出用户原始目标与最新补充
- 给出当前实例级约束
- 给出最新状态快照
- 给出最近操作摘要
- 给出上一次工具调用结果
- 给出本轮目标
## 用户已经认可的 prompt 设计原则
- 必须保留 JSON 示例,否则 LLM 容易不会按协议输出。
- prompt 必须有显式编号和子编号,例如 `1. / 1.1 / 2.1`
- prompt 不能写死成排程专用。
- 排程只是一个领域模块示例,不是通用内核的一部分。
- 对排程领域来说,应明确:
- 这是“粗排后的优化器”
- 不是“补排器”
- `pending > 0` 是异常条件,不是待办事项
- 对不同领域,应通过占位参数注入,不要把具体业务写进通用层。
## 已产出的可插拔 prompt 方案要点
建议最终落地成这三层:
### 通用执行内核
- 身份
- 通用规则
- 通用动作协议
- 输出字段定义
- 最小 JSON 示例
### 领域模块
- `domain_name`
- `task_type`
- `domain_primary_responsibility`
- `domain_out_of_scope`
- `domain_goals`
- `domain_non_goals`
- `tool_catalog_brief`
- `tool_usage_rules`
- `tool_required_args_rules`
- `tool_common_failures`
- `hard_constraints`
- `soft_objectives`
- `abort_conditions`
- `abort_handling_rules`
- `done_conditions`
- `abort_output_conditions`
### 运行时任务简报
- `original_user_goal`
- `latest_user_instruction`
- `current_effective_goal`
- `current_phase`
- `current_round`
- `instance_constraints`
- `latest_state_summary`
- `latest_state_delta`
- `latest_risks`
- `recent_operation_summary`
- `recent_failure_patterns`
- `last_tool_name`
- `last_tool_arguments_summary`
- `last_tool_result_summary`
- `last_tool_success`
- `last_tool_state_change`
- `last_tool_takeaway`
- `current_round_goal`
- `recommended_next_action`
## 排程领域的具体模块语义
如果当前领域是“粗排后的排程优化”,建议这样填:
- `domain_name = schedule_optimization`
- `domain_primary_responsibility = 在粗排结果基础上优化排程质量`
- `domain_out_of_scope = 手工补排粗排遗漏任务`
- `domain_goals = 更均匀、更符合学习规律、更平衡每日负载`
- `domain_non_goals = 把 pending 任务一个个 place 进去`
- `abort_conditions = 粗排完成后仍有 pending 任务`
- `abort_handling_rules = 不再继续优化,不再 place直接 abort`
- `done_conditions = 方案满足硬约束且整体分布合理`
## 代码层建议的实施顺序
建议下一位助理按这个顺序做,风险最低:
1. 先改粗排后 pinned 引导
重点文件:`backend/newAgent/node/rough_build.go`
目标删掉“pending 继续 place”的提示换成“pending 是异常”的提示。
2. 再补 `abort` 动作语义
重点文件:
- `backend/newAgent/node/execute.go`
- 相关 decision model 定义文件
- 可能涉及 deliver / graph 分支
目标:让 LLM 可以正规地终止异常流程,而不是只能 continue / done / ask_user / confirm。
3. 再做 prompt 结构重构
重点文件:
- `backend/newAgent/prompt/base.go`
- `backend/newAgent/prompt/execute.go`
- 如有必要,可新增一个领域模块文件
目标把目前“system/tool/history/pinned/runtime prompt”重组为“通用内核 + 领域模块 + 任务简报”。
4. 最后再做历史瘦身
目标:
- 同工具同参数结果只保留最近一份原文
- 更早历史改摘要
- assistant 废话不入 history
- 失败模式摘要化
- 必要时接入 token budget
## 关于历史瘦身,已达成的结论
下一位助理可以直接照这个原则做:
- 不再把几十条 `assistant/tool` 原始流水账直接喂给模型
- 把历史改成“状态快照 + 最近摘要 + 上一次结果 + 本轮目标”
- `tool result` 只保留:
- 最新一条原文
- 更早的同类结果摘要
- 重复查询要压缩:
- 同工具同参数只保留最新一条
- assistant 过程话术要剔除:
- “我先查一下”“我将继续……”之类原则上不入模型历史
- 保留最近失败模式:
- 例如 `place``task_id`
- 例如 `find_free``duration`
## 测试与验证注意事项
- 运行 `go test` 后,必须清理项目根目录 `.gocache`
- 当前环境可能会因为网络限制导致 `go test` 拉依赖失败;之前已经出现过这种情况。
- 项目要求:
- 注释、接口文案、说明、评审反馈都用中文
- 文件编码 UTF-8无 BOM
- 不要把 agent 改回写库逻辑;当前用户明确要求 agent 操作只写内存,不写数据库
- 代码中若改动复杂逻辑,注释要同步更新,且注释必须用中文
## 关键文件清单
- 执行节点与上下文打点:`backend/newAgent/node/execute.go`
- prompt 拼装基础:`backend/newAgent/prompt/base.go`
- execute prompt`backend/newAgent/prompt/execute.go`
- 粗排节点:`backend/newAgent/node/rough_build.go`
- graph 节点装配:`backend/newAgent/node/agent_nodes.go`
- newAgent service 入口:`backend/service/agentsvc/agent_newagent.go`
- 旧链路 token budget 参考:`backend/service/agentsvc/agent.go`
- token budget 工具:`backend/pkg/token_budget.go`
## 一句话总结给下一位助理
当前要做的,不是继续 patch 某个 prompt 文案,而是同时完成两件事:
- 把“粗排后 pending 还让 LLM 手工补排”的错误业务语义彻底清掉
-`execute` 从“消息流水账喂模”重构成“通用执行内核 + 可插拔领域模块 + 运行时任务简报”的结构化 prompt
## TODO Checklist
### 粗排算法与异常语义
- [ ] 确认粗排算法本体是否真的会漏排
- [ ] 确认 `placements` 写入 `ScheduleState` 后是否所有目标任务都已有初始落位
- [ ] 删除 `rough_build` 节点里“pending 继续 place”的错误提示
- [ ] 改成“粗排后 pending > 0 即异常”的提示语义
- [ ] 在执行决策层补齐 `abort` 动作语义
### Prompt 重构
- [ ] 抽出通用执行内核 prompt
- [ ] 抽出领域模块 prompt
- [ ] 抽出运行时任务简报拼装逻辑
- [ ] 保留最小必要 JSON 示例
- [ ] 清除后端执行层语义对 LLM 的干扰
- [ ] 让排程领域以模块方式接入,而不是写死在内核
### 历史瘦身
- [ ] 同工具同参数仅保留最新一条原文
- [ ] 更早同类结果改为摘要
- [ ] assistant 过程性废话不再进入模型历史
- [ ] 最近失败模式摘要化保留
- [ ] 必要时接入 token budget

View File

@@ -189,6 +189,15 @@ func branchAfterPlan(_ context.Context, st *newagentmodel.AgentGraphState) (stri
if flowState.Phase == newagentmodel.PhaseWaitingConfirm {
return NodeConfirm, nil
}
if flowState.Phase == newagentmodel.PhaseExecuting {
if flowState.NeedsRoughBuild && st.Deps.RoughBuildFunc != nil {
return NodeRoughBuild, nil
}
return NodeExecute, nil
}
if flowState.Phase == newagentmodel.PhaseDone {
return NodeDeliver, nil
}
return NodePlan, nil
}

View File

@@ -147,10 +147,12 @@ func (d *AgentGraphDeps) ResolveDeliverClient() *newagentllm.Client {
// 3. Request当前这次请求的轻量输入
// 4. Depsgraph/node 层真正依赖的可插拔能力。
type AgentGraphRunInput struct {
RuntimeState *AgentRuntimeState
ConversationContext *ConversationContext
Request AgentGraphRequest
Deps AgentGraphDeps
RuntimeState *AgentRuntimeState
ConversationContext *ConversationContext
ScheduleState *newagenttools.ScheduleState
OriginalScheduleState *newagenttools.ScheduleState
Request AgentGraphRequest
Deps AgentGraphDeps
}
// AgentGraphState 是 graph 内部真正流转的运行态容器。
@@ -171,10 +173,12 @@ type AgentGraphState struct {
// NewAgentGraphState 把入口参数整理成 graph 内部状态。
func NewAgentGraphState(input AgentGraphRunInput) *AgentGraphState {
st := &AgentGraphState{
RuntimeState: input.RuntimeState,
ConversationContext: input.ConversationContext,
Request: input.Request,
Deps: input.Deps,
RuntimeState: input.RuntimeState,
ConversationContext: input.ConversationContext,
Request: input.Request,
Deps: input.Deps,
ScheduleState: input.ScheduleState,
OriginalScheduleState: input.OriginalScheduleState,
}
st.Request.Normalize()
st.EnsureRuntimeState()
@@ -238,6 +242,12 @@ func (s *AgentGraphState) EnsureScheduleState(ctx context.Context) (*newagenttoo
return nil, nil
}
if s.ScheduleState != nil {
if s.OriginalScheduleState == nil {
// 1. 兼容老快照:历史 Redis 快照里可能还没带 original_state。
// 2. 当前阶段虽然已经不落库,但后续若重新接回 diff 链,仍需要稳定的原始快照。
// 3. 因此这里在“已恢复出 ScheduleState、但缺 original”时补一份克隆兜底。
s.OriginalScheduleState = s.ScheduleState.Clone()
}
return s.ScheduleState, nil
}
if s.Deps.ScheduleProvider == nil {

View File

@@ -14,8 +14,10 @@ import (
// 3. 不保存 Deps依赖注入每次由 Service 层重建);
// 4. 不保存 ToolSchemas每次请求由 Service 层重新注入)。
type AgentStateSnapshot struct {
RuntimeState *AgentRuntimeState `json:"runtime_state"`
ConversationContext *ConversationContext `json:"conversation_context"`
RuntimeState *AgentRuntimeState `json:"runtime_state"`
ConversationContext *ConversationContext `json:"conversation_context"`
ScheduleState *newagenttools.ScheduleState `json:"schedule_state,omitempty"`
OriginalScheduleState *newagenttools.ScheduleState `json:"original_schedule_state,omitempty"`
}
// AgentStateStore 定义 agent 状态持久化的最小接口。

View File

@@ -85,6 +85,9 @@ func (n *AgentNodes) Confirm(ctx context.Context, st *newagentmodel.AgentGraphSt
},
); err != nil {
return nil, err
} else if st.Deps.WriteSchedulePreview != nil && st.ScheduleState == nil {
flowState := st.EnsureFlowState()
log.Printf("[WARN] deliver: schedule state is nil, skip preview write chat=%s", flowState.ConversationID)
}
saveAgentState(ctx, st)
@@ -111,6 +114,7 @@ func (n *AgentNodes) Plan(ctx context.Context, st *newagentmodel.AgentGraphState
Client: st.Deps.ResolvePlanClient(),
ChunkEmitter: st.EnsureChunkEmitter(),
ResumeNode: "plan",
AlwaysExecute: st.Request.AlwaysExecute,
},
); err != nil {
return nil, err
@@ -293,8 +297,10 @@ func saveAgentState(ctx context.Context, st *newagentmodel.AgentGraphState) {
}
snapshot := &newagentmodel.AgentStateSnapshot{
RuntimeState: runtimeState,
ConversationContext: st.EnsureConversationContext(),
RuntimeState: runtimeState,
ConversationContext: st.EnsureConversationContext(),
ScheduleState: st.ScheduleState.Clone(),
OriginalScheduleState: st.OriginalScheduleState.Clone(),
}
_ = store.Save(ctx, flowState.ConversationID, snapshot)

View File

@@ -54,7 +54,7 @@ func RunChatNode(ctx context.Context, input ChatNodeInput) error {
// 1. 有 pending interaction → 纯状态传递,处理恢复。
if runtimeState.HasPendingInteraction() {
return handleChatResume(input, runtimeState, conversationContext, emitter)
return handleChatResume(input, runtimeState, emitter)
}
// 2. 无 pending → 路由决策(一次快速 LLM 调用,不开 thinking
@@ -263,16 +263,13 @@ func handleRoutePlan(
func handleChatResume(
input ChatNodeInput,
runtimeState *newagentmodel.AgentRuntimeState,
conversationContext *newagentmodel.ConversationContext,
emitter *newagentstream.ChunkEmitter,
) error {
pending := runtimeState.PendingInteraction
flowState := runtimeState.EnsureCommonState()
// 用户本轮输入写回历史ask_user 回复、confirm 附言等)
if strings.TrimSpace(input.UserInput) != "" {
conversationContext.AppendHistory(schema.UserMessage(input.UserInput))
}
// 用户输入在 service 层进入 graph 前已经统一追加到 ConversationContext
// 这里不再二次写入,避免 pending 恢复路径把同一轮 user message 追加两次。
switch pending.Type {
case newagentmodel.PendingInteractionTypeAskUser:

View File

@@ -38,8 +38,8 @@ const (
// 3. ConversationContext 提供历史对话与置顶上下文;
// 4. ToolRegistry 提供工具注册表;
// 5. ScheduleState 提供工具操作的内存数据源(可为 nil由调用方按需加载
// 6. SchedulePersistor 用于写工具执行后持久化变更
// 7. OriginalScheduleState 是首次加载时的原始快照,用于 diff
// 6. SchedulePersistor 仍保留注入位,但当前阶段不调用,避免写库
// 7. OriginalScheduleState 继续保留,供 Redis 快照恢复时维持“当前态/原始态”成对语义
type ExecuteNodeInput struct {
RuntimeState *newagentmodel.AgentRuntimeState
ConversationContext *newagentmodel.ConversationContext
@@ -138,6 +138,15 @@ func RunExecuteNode(ctx context.Context, input ExecuteNodeInput) error {
// 5. 构造本轮执行输入,请求 LLM 输出 ExecuteDecision。
messages := newagentprompt.BuildExecuteMessages(flowState, conversationContext)
log.Printf(
"[DEBUG] execute LLM context begin chat=%s round=%d message_count=%d\n%s\n[DEBUG] execute LLM context end chat=%s round=%d",
flowState.ConversationID,
flowState.RoundUsed,
len(messages),
formatExecuteLLMMessagesForDebug(messages),
flowState.ConversationID,
flowState.RoundUsed,
)
decision, rawResult, err := newagentllm.GenerateJSON[newagentmodel.ExecuteDecision](
ctx,
input.Client,
@@ -316,18 +325,9 @@ func RunExecuteNode(ctx context.Context, input ExecuteNodeInput) error {
return nil
case newagentmodel.ExecuteActionConfirm:
// AlwaysExecute=true跳过确认闸门直接执行写工具并持久化,不走 confirm 节点。
// AlwaysExecute=true跳过确认闸门直接执行内存写工具,不走 confirm 节点。
if input.AlwaysExecute && decision.ToolCall != nil {
if err := executeToolCall(ctx, flowState, conversationContext, decision.ToolCall, emitter, input.ToolRegistry, input.ScheduleState); err != nil {
return err
}
if input.SchedulePersistor != nil && input.OriginalScheduleState != nil {
cs := runtimeState.EnsureCommonState()
if persistErr := input.SchedulePersistor.PersistScheduleChanges(ctx, input.OriginalScheduleState, input.ScheduleState, cs.UserID); persistErr != nil {
log.Printf("[WARN] execute always-execute 持久化失败: %v", persistErr)
}
}
return nil
return executeToolCall(ctx, flowState, conversationContext, decision.ToolCall, emitter, input.ToolRegistry, input.ScheduleState)
}
// AlwaysExecute=false默认暂存工具意图设 Phase → 下游 confirm 节点接管。
return handleExecuteActionConfirm(decision, runtimeState, flowState)
@@ -504,7 +504,19 @@ func executeToolCall(
}
// 2. 执行工具。
beforeDigest := summarizeScheduleStateForDebug(scheduleState)
result := registry.Execute(scheduleState, toolName, toolCall.Arguments)
afterDigest := summarizeScheduleStateForDebug(scheduleState)
log.Printf(
"[DEBUG] execute tool chat=%s round=%d tool=%s args=%s before=%s after=%s result_preview=%.200s",
flowState.ConversationID,
flowState.RoundUsed,
toolName,
marshalArgsForDebug(toolCall.Arguments),
beforeDigest,
afterDigest,
flattenForLog(result),
)
// 2.5 截断过大的工具结果,防止上下文膨胀导致后续 LLM 调用返回空或超限。
const maxToolResultLen = 3000
@@ -558,7 +570,7 @@ func executeToolCall(
// 1. 从 PendingConfirmTool 读取工具名和参数(已序列化);
// 2. 反序列化参数后调用工具执行;
// 3. 将结果追加到历史,清空 PendingConfirmTool
// 4. 执行成功后调用 persistor 持久化变更
// 4. 当前阶段只保留内存修改,不在这里落库
// 5. 不调用 LLM直接返回让下一轮继续。
func executePendingTool(
ctx context.Context,
@@ -598,7 +610,20 @@ func executePendingTool(
}
// 4. 执行工具。
beforeDigest := summarizeScheduleStateForDebug(scheduleState)
result := registry.Execute(scheduleState, pending.ToolName, args)
afterDigest := summarizeScheduleStateForDebug(scheduleState)
flowState := runtimeState.EnsureCommonState()
log.Printf(
"[DEBUG] execute pending tool chat=%s round=%d tool=%s args=%s before=%s after=%s result_preview=%.200s",
flowState.ConversationID,
flowState.RoundUsed,
pending.ToolName,
marshalArgsForDebug(args),
beforeDigest,
afterDigest,
flattenForLog(result),
)
// 5. 将工具调用和结果以合法的 assistant+tool 消息对追加到历史。
//
@@ -630,13 +655,6 @@ func executePendingTool(
// 6. 清空临时邮箱,避免重复执行。
runtimeState.PendingConfirmTool = nil
// 7. 持久化变更(如果有 persistor
if persistor != nil && originalState != nil {
if err := persistor.PersistScheduleChanges(ctx, originalState, scheduleState, runtimeState.UserID); err != nil {
return fmt.Errorf("持久化日程变更失败: %w", err)
}
}
return nil
}
@@ -671,3 +689,147 @@ func truncateText(text string, maxLen int) string {
}
return text[:maxLen-3] + "..."
}
// summarizeScheduleStateForDebug 返回内存日程状态的关键计数,用于判断工具是否真的修改了 state。
func summarizeScheduleStateForDebug(state *newagenttools.ScheduleState) string {
if state == nil {
return "state=nil"
}
total := len(state.Tasks)
pendingNoSlot := 0
pendingWithSlot := 0
existingTotal := 0
taskItemWithSlot := 0
eventWithSlot := 0
for i := range state.Tasks {
t := &state.Tasks[i]
hasSlot := len(t.Slots) > 0
switch t.Status {
case "pending":
if hasSlot {
pendingWithSlot++
} else {
pendingNoSlot++
}
case "existing":
existingTotal++
}
if hasSlot {
if t.Source == "task_item" {
taskItemWithSlot++
}
if t.Source == "event" {
eventWithSlot++
}
}
}
return fmt.Sprintf(
"tasks=%d pending_no_slot=%d pending_with_slot=%d existing=%d task_item_with_slot=%d event_with_slot=%d",
total,
pendingNoSlot,
pendingWithSlot,
existingTotal,
taskItemWithSlot,
eventWithSlot,
)
}
// marshalArgsForDebug 将工具参数序列化为日志可读的短文本。
func marshalArgsForDebug(args map[string]any) string {
if len(args) == 0 {
return "{}"
}
raw, err := json.Marshal(args)
if err != nil {
return "<marshal_error>"
}
return string(raw)
}
// flattenForLog 将多行文本压成单行,避免日志换行影响排查。
func flattenForLog(text string) string {
text = strings.ReplaceAll(text, "\n", " ")
text = strings.ReplaceAll(text, "\r", " ")
return strings.TrimSpace(text)
}
// formatExecuteLLMMessagesForDebug 将本轮送入 LLM 的完整消息上下文展开成可读多行日志。
//
// 说明:
// 1. 按消息索引逐条输出,便于和上游上下文构造步骤逐项对齐;
// 2. 完整输出 content / reasoning_content / tool_calls / extra不做截断
// 3. 仅用于调试打点,不参与业务决策。
func formatExecuteLLMMessagesForDebug(messages []*schema.Message) string {
if len(messages) == 0 {
return "(empty messages)"
}
var sb strings.Builder
for i, msg := range messages {
sb.WriteString(fmt.Sprintf("----- message[%d] -----\n", i))
if msg == nil {
sb.WriteString("role: <nil>\n\n")
continue
}
sb.WriteString(fmt.Sprintf("role: %s\n", msg.Role))
if strings.TrimSpace(msg.ToolCallID) != "" {
sb.WriteString(fmt.Sprintf("tool_call_id: %s\n", msg.ToolCallID))
}
if strings.TrimSpace(msg.ToolName) != "" {
sb.WriteString(fmt.Sprintf("tool_name: %s\n", msg.ToolName))
}
if len(msg.ToolCalls) > 0 {
sb.WriteString("tool_calls:\n")
for j, call := range msg.ToolCalls {
sb.WriteString(fmt.Sprintf(" - [%d] id=%s type=%s function=%s\n", j, call.ID, call.Type, call.Function.Name))
sb.WriteString(" arguments:\n")
sb.WriteString(indentMultilineForDebug(call.Function.Arguments, " "))
sb.WriteString("\n")
}
}
if strings.TrimSpace(msg.ReasoningContent) != "" {
sb.WriteString("reasoning_content:\n")
sb.WriteString(indentMultilineForDebug(msg.ReasoningContent, " "))
sb.WriteString("\n")
}
sb.WriteString("content:\n")
sb.WriteString(indentMultilineForDebug(msg.Content, " "))
sb.WriteString("\n")
if len(msg.Extra) > 0 {
sb.WriteString("extra:\n")
raw, err := json.MarshalIndent(msg.Extra, "", " ")
if err != nil {
sb.WriteString(indentMultilineForDebug("<marshal_error>", " "))
} else {
sb.WriteString(indentMultilineForDebug(string(raw), " "))
}
sb.WriteString("\n")
}
sb.WriteString("\n")
}
return sb.String()
}
// indentMultilineForDebug 为多行文本统一添加前缀缩进,避免日志折行后难以阅读。
func indentMultilineForDebug(text, prefix string) string {
if text == "" {
return prefix + "<empty>"
}
lines := strings.Split(text, "\n")
for i := range lines {
lines[i] = prefix + lines[i]
}
return strings.Join(lines, "\n")
}

View File

@@ -33,6 +33,7 @@ type PlanNodeInput struct {
Client *newagentllm.Client
ChunkEmitter *newagentstream.ChunkEmitter
ResumeNode string
AlwaysExecute bool // true 时计划生成后自动确认,不进入 confirm 节点
}
// RunPlanNode 执行一轮规划节点逻辑。
@@ -166,6 +167,18 @@ func RunPlanNode(ctx context.Context, input PlanNodeInput) error {
flowState.TaskClassIDs = decision.TaskClassIDs
}
}
// always_execute 开启时,计划层跳过确认闸门,直接进入执行阶段。
// 这样可以与 Execute 节点的“写工具跳过确认”语义保持一致。
if input.AlwaysExecute {
flowState.ConfirmPlan()
_ = emitter.EmitStatus(
planStatusBlockID,
planStageName,
"plan_auto_confirmed",
"计划已自动确认,开始执行。",
false,
)
}
return nil
default:
// 1. LLM 输出了不支持的 action不应直接报错终止而应给它修正机会。

View File

@@ -3,6 +3,8 @@ package newagentnode
import (
"context"
"fmt"
"strconv"
"strings"
newagentmodel "github.com/LoveLosita/smartflow/backend/newAgent/model"
newagenttools "github.com/LoveLosita/smartflow/backend/newAgent/tools"
@@ -82,10 +84,18 @@ func RunRoughBuildNode(ctx context.Context, st *newagentmodel.AgentGraphState) e
// 8. 把粗排完成信息写入 pinned context让 Execute 阶段的 LLM 直接进入验证和微调。
stillPending := countPendingTasks(scheduleState)
// 构造任务类 ID 字符串,供 pinned block 明确标注,避免 Execute LLM 因找不到 task_class_id 来源而 ask_user。
idParts := make([]string, len(taskClassIDs))
for i, id := range taskClassIDs {
idParts[i] = strconv.Itoa(id)
}
idStr := strings.Join(idParts, ", ")
var pinnedContent string
if stillPending > 0 {
pinnedContent = fmt.Sprintf(
"后端已自动运行粗排算法,初始排课方案已写入日程状态(共 %d 个任务已预排)。\n"+
"后端已自动运行粗排算法(任务类 ID[%s],初始排课方案已写入日程状态(共 %d 个任务已预排)。\n"+
"注意:仍有 %d 个任务未被粗排覆盖处于待安排pending状态必须在微调阶段手动安排完毕。\n\n"+
"处理 pending 任务的正确操作顺序:\n"+
"1. 调用 get_overview 或 find_free 确认可用空位(不要反复调用 list_taskslist_tasks 只能看任务列表,看不出空位)\n"+
@@ -93,14 +103,14 @@ func RunRoughBuildNode(ctx context.Context, st *newagentmodel.AgentGraphState) e
"3. 重复上述步骤,直到 get_overview 显示待安排任务剩余为 0\n\n"+
"微调完成的判定标准:所有 pending 任务均已 place待安排任务剩余=0且现有排课无明显失衡。\n"+
"无需再次触发粗排。",
len(placements), stillPending,
idStr, len(placements), stillPending,
)
} else {
pinnedContent = fmt.Sprintf(
"后端已自动运行粗排算法,初始排课方案已写入日程状态(共 %d 个任务已预排,无待安排任务)。\n"+
"后端已自动运行粗排算法(任务类 ID[%s],初始排课方案已写入日程状态(共 %d 个任务已预排,无待安排任务)。\n"+
"请直接调用 get_overview 查看预排结果,然后用 move/swap 微调不合理的位置。\n"+
"无需再次触发粗排。",
len(placements),
idStr, len(placements),
)
}
st.EnsureConversationContext().UpsertPinnedBlock(newagentmodel.ContextBlock{

View File

@@ -2,6 +2,7 @@ package newagentprompt
import (
"fmt"
"strconv"
"strings"
newagentmodel "github.com/LoveLosita/smartflow/backend/newAgent/model"
@@ -191,6 +192,17 @@ func BuildExecuteUserPrompt(state *newagentmodel.CommonState) string {
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())
@@ -221,7 +233,16 @@ func BuildExecuteReActUserPrompt(state *newagentmodel.CommonState) string {
sb.WriteString("请根据用户意图直接使用工具完成请求。\n\n")
sb.WriteString(renderStateSummary(state))
sb.WriteString("\n\n")
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")

View File

@@ -139,8 +139,8 @@ func (e *ChunkEmitter) EmitAssistantText(blockID, stage, text string, includeRol
if e == nil || e.emit == nil {
return nil
}
text = strings.TrimSpace(text)
//这里如果不删掉,换行符会被吞了,导致文字黏连
/* text = strings.TrimSpace(text)*/
if text == "" {
return nil
}
@@ -509,9 +509,7 @@ func SplitPseudoStreamText(text string, options PseudoStreamOptions) []string {
options = normalizePseudoStreamOptions(options)
runes := []rune(text)
if len(runes) <= options.MaxChunkRunes {
if hasTrailingNewline {
return []string{text + "\n"}
}
// text 经 TrimRight(" \t\r") 已保留结尾 \n直接返回不再追加。
return []string{text}
}
@@ -532,7 +530,9 @@ func SplitPseudoStreamText(text string, options PseudoStreamOptions) []string {
continue
}
chunk := strings.TrimSpace(string(runes[start : i+1]))
// 用 Trim(" \t\r") 代替 TrimSpace保留 chunk 内的 \n段落分隔符
// TrimSpace 会把 flush 在 \n 边界时结尾的 \n、以及下一段开头的 \n 全部删掉,导致黏连。
chunk := strings.Trim(string(runes[start:i+1]), " \t\r")
if chunk != "" {
chunks = append(chunks, chunk)
}
@@ -541,19 +541,17 @@ func SplitPseudoStreamText(text string, options PseudoStreamOptions) []string {
}
if start < len(runes) {
chunk := strings.TrimSpace(string(runes[start:]))
chunk := strings.Trim(string(runes[start:]), " \t\r")
if chunk != "" {
chunks = append(chunks, chunk)
}
}
if len(chunks) == 0 {
if hasTrailingNewline {
return []string{text + "\n"}
}
return []string{text}
}
if hasTrailingNewline {
// 仅当最后一个 chunk 尚未以 \n 结尾时才追加,避免 Trim 修复后出现双换行。
if hasTrailingNewline && !strings.HasSuffix(chunks[len(chunks)-1], "\n") {
chunks[len(chunks)-1] += "\n"
}
return chunks

View File

@@ -86,7 +86,7 @@ func (s *AgentService) runNewAgentGraph(
// 4. 从 StateStore 加载或创建 RuntimeState。
// 恢复场景confirm/ask_user同时拿到快照中保存的 ConversationContext
// 其中包含工具调用/结果等中间消息,保证后续 LLM 调用的消息链完整。
runtimeState, savedConversationContext := s.loadOrCreateRuntimeState(requestCtx, chatID, userID)
runtimeState, savedConversationContext, savedScheduleState, savedOriginalScheduleState := s.loadOrCreateRuntimeState(requestCtx, chatID, userID)
// 5. 构造 ConversationContext。
// 优先使用快照中恢复的 ConversationContext含工具调用/结果),
@@ -161,10 +161,12 @@ func (s *AgentService) runNewAgentGraph(
// 10. 构造 AgentGraphRunInput 并运行 graph。
runInput := newagentmodel.AgentGraphRunInput{
RuntimeState: runtimeState,
ConversationContext: conversationContext,
Request: graphRequest,
Deps: deps,
RuntimeState: runtimeState,
ConversationContext: conversationContext,
ScheduleState: savedScheduleState,
OriginalScheduleState: savedOriginalScheduleState,
Request: graphRequest,
Deps: deps,
}
finalState, graphErr := newagentgraph.RunAgentGraph(requestCtx, runInput)
@@ -211,13 +213,13 @@ func (s *AgentService) runNewAgentGraph(
// 这些消息不会出现在 Redis LLM 历史缓存中;
// 2. 恢复场景confirm/ask_user必须使用快照中的 ConversationContext否则工具结果丢失
// 导致后续 LLM 调用收到非法的裸 Tool 消息API 拒绝请求、连接断开。
func (s *AgentService) loadOrCreateRuntimeState(ctx context.Context, chatID string, userID int) (*newagentmodel.AgentRuntimeState, *newagentmodel.ConversationContext) {
newRT := func() (*newagentmodel.AgentRuntimeState, *newagentmodel.ConversationContext) {
func (s *AgentService) loadOrCreateRuntimeState(ctx context.Context, chatID string, userID int) (*newagentmodel.AgentRuntimeState, *newagentmodel.ConversationContext, *newagenttools.ScheduleState, *newagenttools.ScheduleState) {
newRT := func() (*newagentmodel.AgentRuntimeState, *newagentmodel.ConversationContext, *newagenttools.ScheduleState, *newagenttools.ScheduleState) {
rt := newagentmodel.NewAgentRuntimeState(nil)
cs := rt.EnsureCommonState()
cs.UserID = userID
cs.ConversationID = chatID // saveAgentState 依赖此字段决定是否持久化
return rt, nil
return rt, nil, nil, nil
}
if s.agentStateStore == nil {
@@ -225,11 +227,13 @@ func (s *AgentService) loadOrCreateRuntimeState(ctx context.Context, chatID stri
}
snapshot, ok, err := s.agentStateStore.Load(ctx, chatID)
log.Printf("[DEBUG] loadOrCreateRuntimeState chatID=%s ok=%v err=%v hasRuntime=%v hasPending=%v hasCtx=%v",
log.Printf("[DEBUG] loadOrCreateRuntimeState chatID=%s ok=%v err=%v hasRuntime=%v hasPending=%v hasCtx=%v hasSchedule=%v hasOriginal=%v",
chatID, ok, err,
snapshot != nil && snapshot.RuntimeState != nil,
snapshot != nil && snapshot.RuntimeState != nil && snapshot.RuntimeState.HasPendingInteraction(),
snapshot != nil && snapshot.ConversationContext != nil,
snapshot != nil && snapshot.ScheduleState != nil,
snapshot != nil && snapshot.OriginalScheduleState != nil,
)
if err != nil {
log.Printf("加载 agent 状态失败 chat=%s: %v", chatID, err)
@@ -244,7 +248,14 @@ func (s *AgentService) loadOrCreateRuntimeState(ctx context.Context, chatID stri
// 不需要手动重置 Phase所有请求统一先过 Chat 节点Chat 会根据路由决策覆盖 Phase。
// 保留完整的 RuntimeStatePlanSteps、CurrentStep 等),支持连续对话调整日程。
return snapshot.RuntimeState, snapshot.ConversationContext
originalScheduleState := snapshot.OriginalScheduleState
if snapshot.ScheduleState != nil && originalScheduleState == nil {
// 1. 兼容老快照:历史会话可能只存了 ScheduleState没有 original 副本。
// 2. 这里补一份克隆,保证后续节点拿到的仍是“恢复态 + 原始态”成对数据。
// 3. 即便当前阶段不落库,这里也保留一致性,避免下一轮再出现语义漂移。
originalScheduleState = snapshot.ScheduleState.Clone()
}
return snapshot.RuntimeState, snapshot.ConversationContext, snapshot.ScheduleState, originalScheduleState
}
return newRT()
}
@@ -458,14 +469,94 @@ func (s *AgentService) makeWriteSchedulePreviewFunc() newagentmodel.WriteSchedul
return nil
}
return func(ctx context.Context, state *newagenttools.ScheduleState, userID int, conversationID string, taskClassIDs []int) error {
stateDigest := summarizeScheduleStateForPreviewDebug(state)
preview := conv.ScheduleStateToPreview(state, userID, conversationID, taskClassIDs, "")
if preview == nil {
log.Printf("[WARN] deliver preview skipped chat=%s user=%d state=%s", conversationID, userID, stateDigest)
return nil
}
previewDigest := summarizeHybridEntriesForPreviewDebug(preview.HybridEntries)
log.Printf(
"[DEBUG] deliver preview write chat=%s user=%d state=%s preview=%s generated_at=%s",
conversationID,
userID,
stateDigest,
previewDigest,
preview.GeneratedAt.Format(time.RFC3339),
)
return s.cacheDAO.SetSchedulePlanPreviewToCache(ctx, userID, conversationID, preview)
}
}
// summarizeScheduleStateForPreviewDebug 统计 Deliver 写预览前的内存日程摘要。
func summarizeScheduleStateForPreviewDebug(state *newagenttools.ScheduleState) string {
if state == nil {
return "state=nil"
}
total := len(state.Tasks)
pendingNoSlot := 0
pendingWithSlot := 0
taskItemWithSlot := 0
eventWithSlot := 0
for i := range state.Tasks {
t := &state.Tasks[i]
hasSlot := len(t.Slots) > 0
if t.Status == "pending" {
if hasSlot {
pendingWithSlot++
} else {
pendingNoSlot++
}
}
if hasSlot {
if t.Source == "task_item" {
taskItemWithSlot++
}
if t.Source == "event" {
eventWithSlot++
}
}
}
return fmt.Sprintf(
"tasks=%d pending_no_slot=%d pending_with_slot=%d task_item_with_slot=%d event_with_slot=%d",
total,
pendingNoSlot,
pendingWithSlot,
taskItemWithSlot,
eventWithSlot,
)
}
// summarizeHybridEntriesForPreviewDebug 统计预览转换后的 HybridEntries 摘要。
func summarizeHybridEntriesForPreviewDebug(entries []model.HybridScheduleEntry) string {
existing := 0
suggested := 0
taskType := 0
courseType := 0
for _, e := range entries {
if e.Status == "suggested" {
suggested++
} else {
existing++
}
if e.Type == "task" {
taskType++
}
if e.Type == "course" {
courseType++
}
}
return fmt.Sprintf(
"entries=%d existing=%d suggested=%d task_type=%d course_type=%d",
len(entries),
existing,
suggested,
taskType,
courseType,
)
}
// --- 依赖注入字段 ---
// toolRegistry 由 cmd/start.go 注入