Files
smartmate/backend/newAgent/node/plan.go
Losita 2038185730 Version: 0.9.2.dev.260406
后端:
   1.Chat 四路由升级(二分类 chat/task → 四路由 direct_reply/execute/deep_answer/plan)
     - 新建model/chat_contract.go:路由决策模型,含 NeedsRoughBuild 粗排标记
     - 更新node/chat.go:四路由分流;新增 deep_answer 深度回答路径(二次 LLM 开 thinking)
     - 更新prompt/chat.go:意图分类 prompt 升级为四路由 prompt;新增 deep_answer prompt
   2.粗排节点(RoughBuild)全链路
     - 新建node/rough_build.go:粗排节点,调用注入的算法函数,结果写入 ScheduleState 后进 Execute 微调
     - 更新graph/common_graph.go:注册 RoughBuild 节点;Chat/Confirm 后可路由至粗排
     - 更新model/graph_run_state.go:新增 RoughBuildPlacement/RoughBuildFunc 类型;Deps 注入入口
     - 更新model/plan_contract.go:PlanDecision 新增 NeedsRoughBuild/TaskClassIDs 字段
     - 更新node/plan.go:plan_done 时写入粗排标记和 TaskClassIDs
   3.任务类约束元数据(TaskClassMeta)贯穿 prompt → tools → 持久化
     - 更新tools/state.go:新增 TaskClassMeta;ScheduleState.TaskClasses;ScheduleTask.TaskClassID;Clone 深拷贝
     - 更新conv/schedule_state.go:加载时构建 TaskClassMeta;Diff 支持 HostEventID 嵌入关系
     - 更新conv/schedule_provider.go:新增 LoadTaskClassMetas 按需加载
     - 更新model/state_store.go:ScheduleStateProvider 接口新增 LoadTaskClassMetas
     - 更新prompt/base.go:renderStateSummary 渲染任务类约束
     - 更新prompt/plan.go:注入任务类 ID 上下文和粗排识别规则
     - 更新tools/read_tools.go:GetOverview 展示任务类约束
     - 更新model/common_state.go:CommonState 新增 TaskClassIDs/TaskClasses/NeedsRoughBuild
   4.Execute 健壮性增强(correction 重试 + 纯 ReAct 模式)
     - 更新node/execute.go:未知工具名/空文本走 correction 重试而非 fatal;maxConsecutiveCorrections 提升为包级常量;新增无 plan 纯ReAct 模式;工具结果截断;speak 排除 ask_user/confirm
     - 更新prompt/execute.go:新增 ReAct 模式 system prompt 和 contract
   5.写入持久化完善(task_item source + 嵌入水课)
     - 更新conv/schedule_persist.go:place/move/unplace 支持 task_item source,含嵌入水课和普通 task event 两条路径
     - 新建conv/schedule_preview.go:ScheduleState → 排程预览缓存,复用旧格式,前端无需改动
   6.状态持久化体系(Redis → MySQL outbox 异步)
     - 更新dao/cache.go:Redis 快照 TTL 从 24h 改为 2h,配合 MySQL outbox
     - 新建model/agent_state_snapshot_record.go:快照 MySQL 记录模型
     - 新建service/events/agent_state_persist.go:outbox 异步持久化处理器
     - 更新cmd/start.go + inits/mysql.go:注册快照事件处理器 + AutoMigrate
     - 更新service/agentsvc/agent_newagent.go:注入 RoughBuildFunc;outbox 异步写快照;排程结果写 Redis 预览缓存
   7.基础设施与稳定性
     - 更新stream/sse_adapter.go:outChan 满时静默丢弃,保证持久化不被 SSE 阻断
     - 更新service/agentsvc/agent.go:新增 readAgentExtraIntSlice;outChan 容量 8→256
     - 更新node/agent_nodes.go:Chat 注入工具 schema;Deliver 改 saveAgentState 替代 deleteAgentState
前端:无
仓库:无
2026-04-06 23:15:54 +08:00

268 lines
9.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package newagentnode
import (
"context"
"fmt"
"strings"
"time"
"github.com/google/uuid"
newagentllm "github.com/LoveLosita/smartflow/backend/newAgent/llm"
newagentmodel "github.com/LoveLosita/smartflow/backend/newAgent/model"
newagentprompt "github.com/LoveLosita/smartflow/backend/newAgent/prompt"
newagentstream "github.com/LoveLosita/smartflow/backend/newAgent/stream"
"github.com/cloudwego/eino/schema"
)
const (
planStageName = "plan"
planStatusBlockID = "plan.status"
planSpeakBlockID = "plan.speak"
planPinnedKey = "current_plan"
planCurrentStepKey = "current_step"
planCurrentStepTitle = "当前步骤"
planFullPlanTitle = "当前完整计划"
)
// PlanNodeInput 描述单轮规划节点执行所需的最小依赖。
type PlanNodeInput struct {
RuntimeState *newagentmodel.AgentRuntimeState
ConversationContext *newagentmodel.ConversationContext
UserInput string
Client *newagentllm.Client
ChunkEmitter *newagentstream.ChunkEmitter
ResumeNode string
}
// RunPlanNode 执行一轮规划节点逻辑。
//
// 步骤说明:
// 1. 先校验最小依赖,并推送一条”正在规划”的状态,避免用户空等;
// 2. Phase 1快速评估不开 thinking让 LLM 同时产出复杂度评估和规划结果;
// 3. Phase 2深度规划若 LLM 自评需要深度思考且规划已完成,开 thinking 重跑;
// 4. 若模型先对用户说了话,则先把 speak 伪流式推给前端,并写回 history
// 5. 最后按 action 推进流程:
// 5.1 continue继续停留在 planning
// 5.2 ask_user打开 pending interaction后续交给 interrupt 收口;
// 5.3 plan_done固化完整计划刷新 pinned context并进入 waiting_confirm。
func RunPlanNode(ctx context.Context, input PlanNodeInput) error {
runtimeState, conversationContext, emitter, err := preparePlanNodeInput(input)
if err != nil {
return err
}
flowState := runtimeState.EnsureCommonState()
// 1. 先发一条阶段状态,让前端知道当前已经进入规划环节。
if err := emitter.EmitStatus(
planStatusBlockID,
planStageName,
"planning",
"正在梳理目标并补全执行计划。",
false,
); err != nil {
return fmt.Errorf("规划阶段状态推送失败: %w", err)
}
// 2. 构造本轮规划输入。
messages := newagentprompt.BuildPlanMessages(flowState, conversationContext, input.UserInput)
// 3. Phase 1快速评估开 thinking让 LLM 同时产出复杂度评估和规划结果。
decision, rawResult, err := newagentllm.GenerateJSON[newagentmodel.PlanDecision](
ctx,
input.Client,
messages,
newagentllm.GenerateOptions{
Temperature: 0.2,
MaxTokens: 1600,
Thinking: newagentllm.ThinkingModeEnabled,
Metadata: map[string]any{
"stage": planStageName,
"phase": "assessment",
},
},
)
if err != nil {
if rawResult != nil && strings.TrimSpace(rawResult.Text) != "" {
return fmt.Errorf("规划评估解析失败,原始输出=%s错误=%w", strings.TrimSpace(rawResult.Text), err)
}
return fmt.Errorf("规划评估阶段模型调用失败: %w", err)
}
if err := decision.Validate(); err != nil {
return fmt.Errorf("规划评估决策不合法: %w", err)
}
// 4. Phase 2若 LLM 自评需要深度思考且本轮规划已完成,则开启 thinking 重跑。
// 条件NeedThinking=true + Action=plan_done → 说明 LLM 认为当前无 thinking 的计划质量不够。
// 其他 actioncontinue / ask_user不需要 thinking直接用 Phase 1 结果。
if decision.NeedThinking && decision.Action == newagentmodel.PlanActionDone {
if err := emitter.EmitStatus(
planStatusBlockID,
planStageName,
"deep_planning",
"正在深入思考,生成更完善的计划。",
false,
); err != nil {
return fmt.Errorf("深度规划状态推送失败: %w", err)
}
deepDecision, _, deepErr := newagentllm.GenerateJSON[newagentmodel.PlanDecision](
ctx,
input.Client,
messages,
newagentllm.GenerateOptions{
Temperature: 0.2,
MaxTokens: 3200,
Thinking: newagentllm.ThinkingModeEnabled,
Metadata: map[string]any{
"stage": planStageName,
"phase": "deep_planning",
},
},
)
if deepErr == nil && deepDecision != nil {
if validateErr := deepDecision.Validate(); validateErr == nil {
decision = deepDecision
}
}
// 深度规划失败时静默降级到 Phase 1 结果,不中断流程。
}
// 5. 若模型先对用户说了话,且不是 ask_userask_user 交给 interrupt 收口),则先以伪流式推送,再写回 history。
if strings.TrimSpace(decision.Speak) != "" && decision.Action != newagentmodel.PlanActionAskUser {
if err := emitter.EmitPseudoAssistantText(
ctx,
planSpeakBlockID,
planStageName,
decision.Speak,
newagentstream.DefaultPseudoStreamOptions(),
); err != nil {
return fmt.Errorf("规划文案推送失败: %w", err)
}
conversationContext.AppendHistory(schema.AssistantMessage(decision.Speak, nil))
}
// 6. 按规划动作推进流程状态。
switch decision.Action {
case newagentmodel.PlanActionContinue:
flowState.Phase = newagentmodel.PhasePlanning
return nil
case newagentmodel.PlanActionAskUser:
question := resolvePlanAskUserText(decision)
runtimeState.OpenAskUserInteraction(uuid.NewString(), question, strings.TrimSpace(input.ResumeNode))
return nil
case newagentmodel.PlanActionDone:
// 4.1 直接把结构化 PlanStep 固化到 CommonState避免 state 层丢失 done_when。
// 4.2 再把完整自然语言计划写入 pinned context保证后续 execute 优先看到。
// 4.3 若 LLM 识别到批量排课意图,把 NeedsRoughBuild 标记写入 CommonState
// Confirm 节点后的路由会据此决定是否跳入 RoughBuild 节点。
// 4.4 最后进入 waiting_confirm等待用户确认整体计划。
flowState.FinishPlan(decision.PlanSteps)
writePlanPinnedBlocks(conversationContext, decision.PlanSteps)
if decision.NeedsRoughBuild {
flowState.NeedsRoughBuild = true
// 以 LLM 决策中的 task_class_ids 为准(若非空则覆盖前端传入值)。
if len(decision.TaskClassIDs) > 0 {
flowState.TaskClassIDs = decision.TaskClassIDs
}
}
return nil
default:
// 1. LLM 输出了不支持的 action不应直接报错终止而应给它修正机会。
// 2. 使用通用修正函数追加错误反馈,让 Graph 继续循环。
// 3. LLM 下一轮会看到错误反馈并修正自己的输出。
llmOutput := decision.Speak
if strings.TrimSpace(llmOutput) == "" {
llmOutput = decision.Reason
}
AppendLLMCorrectionWithHint(
conversationContext,
llmOutput,
fmt.Sprintf("你输出的 action \"%s\" 不是合法的执行动作。", decision.Action),
"合法的 action 包括continue继续当前步骤、ask_user追问用户、next_plan推进到下一步、done任务完成。",
)
return nil
}
}
func preparePlanNodeInput(input PlanNodeInput) (*newagentmodel.AgentRuntimeState, *newagentmodel.ConversationContext, *newagentstream.ChunkEmitter, error) {
if input.RuntimeState == nil {
return nil, nil, nil, fmt.Errorf("plan node: runtime state 不能为空")
}
if input.Client == nil {
return nil, nil, nil, fmt.Errorf("plan node: plan client 未注入")
}
input.RuntimeState.EnsureCommonState()
if input.ConversationContext == nil {
input.ConversationContext = newagentmodel.NewConversationContext("")
}
if input.ChunkEmitter == nil {
input.ChunkEmitter = newagentstream.NewChunkEmitter(newagentstream.NoopPayloadEmitter(), "", "", time.Now().Unix())
}
return input.RuntimeState, input.ConversationContext, input.ChunkEmitter, nil
}
func resolvePlanAskUserText(decision *newagentmodel.PlanDecision) string {
if decision == nil {
return "我还缺一点关键信息,想先向你确认一下。"
}
if strings.TrimSpace(decision.Speak) != "" {
return strings.TrimSpace(decision.Speak)
}
if strings.TrimSpace(decision.Reason) != "" {
return strings.TrimSpace(decision.Reason)
}
return "我还缺一点关键信息,想先向你确认一下。"
}
func writePlanPinnedBlocks(ctx *newagentmodel.ConversationContext, steps []newagentmodel.PlanStep) {
if ctx == nil {
return
}
fullPlanText := buildPinnedPlanText(steps)
if strings.TrimSpace(fullPlanText) != "" {
ctx.UpsertPinnedBlock(newagentmodel.ContextBlock{
Key: planPinnedKey,
Title: planFullPlanTitle,
Content: fullPlanText,
})
}
if len(steps) == 0 {
return
}
firstStep := strings.TrimSpace(steps[0].Content)
if strings.TrimSpace(steps[0].DoneWhen) != "" {
firstStep = fmt.Sprintf("%s\n完成判定%s", firstStep, strings.TrimSpace(steps[0].DoneWhen))
}
ctx.UpsertPinnedBlock(newagentmodel.ContextBlock{
Key: planCurrentStepKey,
Title: planCurrentStepTitle,
Content: firstStep,
})
}
func buildPinnedPlanText(steps []newagentmodel.PlanStep) string {
if len(steps) == 0 {
return ""
}
lines := make([]string, 0, len(steps))
for i, step := range steps {
content := strings.TrimSpace(step.Content)
if content == "" {
continue
}
line := fmt.Sprintf("%d. %s", i+1, content)
if strings.TrimSpace(step.DoneWhen) != "" {
line += fmt.Sprintf("\n完成判定%s", strings.TrimSpace(step.DoneWhen))
}
lines = append(lines, line)
}
return strings.TrimSpace(strings.Join(lines, "\n\n"))
}