From 2c64b37d001dee8ce274085d955565e911e2a17a Mon Sep 17 00:00:00 2001 From: LoveLosita <2810873701@qq.com> Date: Wed, 1 Apr 2026 21:35:41 +0800 Subject: [PATCH] =?UTF-8?q?Version:=200.8.6.dev.260401=20=E5=90=8E?= =?UTF-8?q?=E7=AB=AF=EF=BC=9A=20=E6=96=B0=E5=BB=BA=E4=BA=86chat=E5=92=8Cex?= =?UTF-8?q?ecute=E8=8A=82=E7=82=B9=EF=BC=8C=E5=AE=8C=E5=96=84=E4=BA=86?= =?UTF-8?q?=E7=9B=B8=E5=85=B3=E9=80=BB=E8=BE=91=EF=BC=8C=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E5=B0=9A=E6=9C=AAreview=20=E5=89=8D=E7=AB=AF=EF=BC=9A=20?= =?UTF-8?q?=E6=97=A0=20=E4=BB=93=E5=BA=93=EF=BC=9A=20=E6=97=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/newAgent/graph/common_graph.go | 58 +--- backend/newAgent/model/graph_run_state.go | 4 +- backend/newAgent/node/agent_nodes.go | 59 ++++ backend/newAgent/node/chat.go | 261 ++++++++++++++++++ backend/newAgent/node/correction.go | 100 +++++++ backend/newAgent/node/execute.go | 314 ++++++++++++++++++++++ backend/newAgent/node/plan.go | 15 +- backend/newAgent/prompt/chat.go | 56 ++++ 8 files changed, 817 insertions(+), 50 deletions(-) create mode 100644 backend/newAgent/node/chat.go create mode 100644 backend/newAgent/node/correction.go create mode 100644 backend/newAgent/node/execute.go create mode 100644 backend/newAgent/prompt/chat.go diff --git a/backend/newAgent/graph/common_graph.go b/backend/newAgent/graph/common_graph.go index b83720d..96939f2 100644 --- a/backend/newAgent/graph/common_graph.go +++ b/backend/newAgent/graph/common_graph.go @@ -35,7 +35,7 @@ func RunAgentGraph(ctx context.Context, input newagentmodel.AgentGraphRunInput) g := compose.NewGraph[*newagentmodel.AgentGraphState, *newagentmodel.AgentGraphState]() // --- 注册节点 --- - if err := g.AddLambdaNode(NodeChat, compose.InvokableLambda(chatNode)); err != nil { + if err := g.AddLambdaNode(NodeChat, compose.InvokableLambda(nodes.Chat)); err != nil { return nil, err } if err := g.AddLambdaNode(NodePlan, compose.InvokableLambda(nodes.Plan)); err != nil { @@ -44,7 +44,7 @@ func RunAgentGraph(ctx context.Context, input newagentmodel.AgentGraphRunInput) if err := g.AddLambdaNode(NodeConfirm, compose.InvokableLambda(confirmNode)); err != nil { return nil, err } - if err := g.AddLambdaNode(NodeExecute, compose.InvokableLambda(executeNode)); err != nil { + if err := g.AddLambdaNode(NodeExecute, compose.InvokableLambda(nodes.Execute)); err != nil { return nil, err } if err := g.AddLambdaNode(NodeInterrupt, compose.InvokableLambda(interruptNode)); err != nil { @@ -56,11 +56,11 @@ func RunAgentGraph(ctx context.Context, input newagentmodel.AgentGraphRunInput) // --- 连边 --- // 1. 所有请求统一先过 chat 入口,这样普通聊天、首次任务、恢复执行都走同一入口。 - // 2. chat 不再负责旧式“多业务图路由”,只负责决定后续应该进入哪个统一节点。 + // 2. chat 不再负责旧式多业务图路由,只负责决定后续应该进入哪个统一节点。 if err := g.AddEdge(compose.START, NodeChat); err != nil { return nil, err } - // Chat → END(普通聊天) / Plan / Confirm / Execute / Deliver / Interrupt + // Chat -> END(普通聊天) / Plan / Confirm / Execute / Deliver / Interrupt if err := g.AddBranch(NodeChat, compose.NewGraphBranch( branchAfterChat, map[string]bool{ @@ -74,7 +74,7 @@ func RunAgentGraph(ctx context.Context, input newagentmodel.AgentGraphRunInput) )); err != nil { return nil, err } - // Plan → Plan(继续规划) / Confirm(规划完成) / Interrupt(需要追问用户) + // Plan -> Plan(继续规划) / Confirm(规划完成) / Interrupt(需要追问用户) if err := g.AddBranch(NodePlan, compose.NewGraphBranch( branchAfterPlan, map[string]bool{ @@ -85,7 +85,7 @@ func RunAgentGraph(ctx context.Context, input newagentmodel.AgentGraphRunInput) )); err != nil { return nil, err } - // Confirm → Plan(用户拒绝或重规划) / Execute(确认后继续执行) / Interrupt(产出确认中断并等待外部回调) + // Confirm -> Plan(用户拒绝或重规划) / Execute(确认后继续执行) / Interrupt(产出确认中断并等待外部回调) if err := g.AddBranch(NodeConfirm, compose.NewGraphBranch( branchAfterConfirm, map[string]bool{ @@ -96,7 +96,7 @@ func RunAgentGraph(ctx context.Context, input newagentmodel.AgentGraphRunInput) )); err != nil { return nil, err } - // Execute → Execute(继续 ReAct) / Confirm(写操作待确认) / Deliver(完成) / Interrupt(需要追问用户) + // Execute -> Execute(继续 ReAct) / Confirm(写操作待确认) / Deliver(完成) / Interrupt(需要追问用户) if err := g.AddBranch(NodeExecute, compose.NewGraphBranch( branchAfterExecute, map[string]bool{ @@ -108,11 +108,11 @@ func RunAgentGraph(ctx context.Context, input newagentmodel.AgentGraphRunInput) )); err != nil { return nil, err } - // Interrupt → END:当前连接必须在这里收口,等待用户输入或确认回调恢复。 + // Interrupt -> END:当前连接必须在这里收口,等待用户输入或确认回调恢复。 if err := g.AddEdge(NodeInterrupt, compose.END); err != nil { return nil, err } - // Deliver → END + // Deliver -> END if err := g.AddEdge(NodeDeliver, compose.END); err != nil { return nil, err } @@ -132,23 +132,6 @@ func RunAgentGraph(ctx context.Context, input newagentmodel.AgentGraphRunInput) // --- 占位节点,后续逐步由 node 层替换 --- -func chatNode(_ context.Context, st *newagentmodel.AgentGraphState) (*newagentmodel.AgentGraphState, error) { - if st == nil { - return nil, errors.New("chat node: state is nil") - } - st.EnsureFlowState() - st.EnsureConversationContext() - st.EnsureChunkEmitter() - - // TODO: - // 1. 识别当前请求是普通聊天、首次任务进入,还是从 pending interaction 恢复。 - // 2. 若只是普通聊天,则生成回复并把 Phase 设为 PhaseChatting,后续直接 END。 - // 3. 若识别到任务意图,则把 Phase 切到 planning / waiting_confirm / executing 对应阶段。 - // 4. 若本轮是恢复请求,则这里只负责吞掉用户最新输入并准备恢复,不再重复输出闲聊回复。 - // 5. 后续 chatNode 可直接读取 st.Request.UserInput、st.ConversationContext 与 st.Deps.ResolveChatClient()。 - return st, nil -} - func confirmNode(_ context.Context, st *newagentmodel.AgentGraphState) (*newagentmodel.AgentGraphState, error) { if st == nil { return nil, errors.New("confirm node: state is nil") @@ -158,34 +141,13 @@ func confirmNode(_ context.Context, st *newagentmodel.AgentGraphState) (*newagen st.EnsureChunkEmitter() // TODO: - // 1. 这里不再做“confirm 节点内自循环等待”,而是统一走中断恢复模式。 + // 1. 这里不再做 confirm 节点内自循环等待,而是统一走中断恢复模式。 // 2. 节点职责是生成确认事件、固化待执行工具快照,并调用 st.OpenConfirmInteraction(...)。 // 3. 当前连接随后会流向 interrupt 节点收口;用户确认/取消后,由外部回调恢复到 executing 或 planning。 // 4. 这里不要直接执行写工具,必须先把待执行工具调用固化为 pending snapshot。 return st, nil } -func executeNode(_ context.Context, st *newagentmodel.AgentGraphState) (*newagentmodel.AgentGraphState, error) { - if st == nil { - return nil, errors.New("execute node: state is nil") - } - flowState := st.EnsureFlowState() - st.EnsureConversationContext() - st.EnsureChunkEmitter() - - // TODO: - // 1. 让 LLM 在“当前步骤”约束下做一轮 ReAct:思考 → 调工具/观察 → reflection。 - // 1.1 执行阶段所需上下文应直接从 st.ConversationContext 读取。 - // 1.2 执行阶段模型依赖应通过 st.Deps.ResolveExecuteClient() 获取。 - // 2. 若执行中发现缺少关键用户信息,则调用 st.OpenAskUserInteraction(...) 并走 interrupt。 - // 3. 若命中写工具确认闸门: - // 3.1 若走同连接确认,则把 Phase 置为 waiting_confirm 并跳到 confirm; - // 3.2 若走短连接恢复,则调用 st.OpenConfirmInteraction(...) 并走 interrupt。 - // 4. 若当前步骤已完成,则由 node 层决定是 AdvanceStep() 继续,还是 Done() 进入交付。 - flowState.NextRound() - return st, nil -} - func interruptNode(_ context.Context, st *newagentmodel.AgentGraphState) (*newagentmodel.AgentGraphState, error) { if st == nil { return nil, errors.New("interrupt node: state is nil") diff --git a/backend/newAgent/model/graph_run_state.go b/backend/newAgent/model/graph_run_state.go index 6522381..bbd8746 100644 --- a/backend/newAgent/model/graph_run_state.go +++ b/backend/newAgent/model/graph_run_state.go @@ -14,7 +14,8 @@ import ( // 2. 不负责承载可持久化流程状态,流程状态仍归 AgentRuntimeState; // 3. 不负责承载 LLM / emitter / store 等依赖,这些统一放进 AgentGraphDeps。 type AgentGraphRequest struct { - UserInput string + UserInput string + ConfirmAction string // "accept" / "reject" / "",仅 confirm 恢复场景由前端传入 } // Normalize 统一清洗请求级输入中的字符串字段。 @@ -23,6 +24,7 @@ func (r *AgentGraphRequest) Normalize() { return } r.UserInput = strings.TrimSpace(r.UserInput) + r.ConfirmAction = strings.TrimSpace(r.ConfirmAction) } // AgentGraphDeps 描述 graph/node 层运行时真正依赖的可插拔能力。 diff --git a/backend/newAgent/node/agent_nodes.go b/backend/newAgent/node/agent_nodes.go index 19da54b..50a2e87 100644 --- a/backend/newAgent/node/agent_nodes.go +++ b/backend/newAgent/node/agent_nodes.go @@ -20,6 +20,33 @@ func NewAgentNodes() *AgentNodes { return &AgentNodes{} } +// Chat 是聊天入口的正式节点方法。 +// +// 职责边界: +// 1. 这里只做 graph -> node 的参数转接; +// 2. 真正的入口逻辑仍由 RunChatNode 负责; +// 3. 这样 graph 层后续只需挂 n.Chat,而不再自己维护占位 chatNode。 +func (n *AgentNodes) Chat(ctx context.Context, st *newagentmodel.AgentGraphState) (*newagentmodel.AgentGraphState, error) { + if st == nil { + return nil, errors.New("chat node: state is nil") + } + + if err := RunChatNode( + ctx, + ChatNodeInput{ + RuntimeState: st.EnsureRuntimeState(), + ConversationContext: st.EnsureConversationContext(), + UserInput: st.Request.UserInput, + ConfirmAction: st.Request.ConfirmAction, + Client: st.Deps.ResolveChatClient(), + ChunkEmitter: st.EnsureChunkEmitter(), + }, + ); err != nil { + return nil, err + } + return st, nil +} + // Plan 是规划阶段的正式节点方法。 // // 职责边界: @@ -46,3 +73,35 @@ func (n *AgentNodes) Plan(ctx context.Context, st *newagentmodel.AgentGraphState } return st, nil } + +// Execute 是执行阶段的正式节点方法。 +// +// 职责边界: +// 1. 这里只做 graph -> node 的参数转接; +// 2. 真正的单轮执行逻辑仍由 RunExecuteNode 负责; +// 3. 这样 graph 层后续只需挂 n.Execute,而不再自己维护占位 executeNode。 +// +// 设计原则: +// 1. LLM 主导:LLM 自己判断 done_when 是否满足,自己决定何时推进/完成; +// 2. 后端兜底:只做资源控制、安全兜底、证据记录; +// 3. 不做硬校验:后端不质疑 LLM 的 advance/complete 决策。 +func (n *AgentNodes) Execute(ctx context.Context, st *newagentmodel.AgentGraphState) (*newagentmodel.AgentGraphState, error) { + if st == nil { + return nil, errors.New("execute node: state is nil") + } + + if err := RunExecuteNode( + ctx, + ExecuteNodeInput{ + RuntimeState: st.EnsureRuntimeState(), + ConversationContext: st.EnsureConversationContext(), + UserInput: st.Request.UserInput, + Client: st.Deps.ResolveExecuteClient(), + ChunkEmitter: st.EnsureChunkEmitter(), + ResumeNode: "execute", + }, + ); err != nil { + return nil, err + } + return st, nil +} diff --git a/backend/newAgent/node/chat.go b/backend/newAgent/node/chat.go new file mode 100644 index 0000000..2b532b1 --- /dev/null +++ b/backend/newAgent/node/chat.go @@ -0,0 +1,261 @@ +package newagentnode + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/cloudwego/eino/schema" + + 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" +) + +const ( + chatStageName = "chat" + chatStatusBlockID = "chat.status" + chatSpeakBlockID = "chat.speak" +) + +// ChatNodeInput 描述聊天节点单轮运行所需的最小依赖。 +// +// 职责边界: +// 1. 只承载"本轮 chat"需要的输入,不负责持久化; +// 2. RuntimeState 提供 pending interaction 与流程状态; +// 3. ConversationContext 提供历史对话; +// 4. ConfirmAction 仅在 confirm 恢复场景下由前端传入 "accept" / "reject"。 +type ChatNodeInput struct { + RuntimeState *newagentmodel.AgentRuntimeState + ConversationContext *newagentmodel.ConversationContext + UserInput string + ConfirmAction string + Client *newagentllm.Client + ChunkEmitter *newagentstream.ChunkEmitter +} + +// chatIntentDecision 是意图分类的结构化输出。 +type chatIntentDecision struct { + Intent string `json:"intent"` + Reply string `json:"reply,omitempty"` + Reason string `json:"reason,omitempty"` +} + +// Normalize 清洗意图分类结果中的字符串字段。 +func (d *chatIntentDecision) Normalize() { + if d == nil { + return + } + d.Intent = strings.TrimSpace(d.Intent) + d.Reply = strings.TrimSpace(d.Reply) + d.Reason = strings.TrimSpace(d.Reason) +} + +// Validate 校验意图分类结果的最小合法性。 +func (d *chatIntentDecision) Validate() error { + if d == nil { + return fmt.Errorf("chat intent decision 不能为空") + } + d.Normalize() + switch d.Intent { + case "chat", "task": + return nil + default: + return fmt.Errorf("未知 intent: %s", d.Intent) + } +} + +// RunChatNode 执行一轮聊天节点逻辑。 +// +// 核心职责: +// 1. 恢复判定:有 pending interaction 则处理恢复,不生成 speak; +// 2. 意图分流:无 pending 时,调 LLM 分类 chat / task; +// 3. 闲聊回复:纯 chat 场景直接生成回复并流式推送,phase → chatting → END; +// 4. 任务路由:task 场景 phase → planning,交给后续 Plan 节点处理。 +// +// 保守原则:分类失败或意图不明时,一律走 task,不丢失用户意图。 +func RunChatNode(ctx context.Context, input ChatNodeInput) error { + runtimeState, conversationContext, emitter, err := prepareChatNodeInput(input) + if err != nil { + return err + } + + // 1. 有 pending interaction → 纯状态传递,不生成 speak。 + if runtimeState.HasPendingInteraction() { + return handleChatResume(input, runtimeState, conversationContext, emitter) + } + + // 2. 无 pending → 调 LLM 做意图分类。 + messages := newagentprompt.BuildChatIntentMessages(conversationContext, input.UserInput) + decision, _, err := newagentllm.GenerateJSON[chatIntentDecision]( + ctx, + input.Client, + messages, + newagentllm.GenerateOptions{ + Temperature: 0.1, + MaxTokens: 300, + Thinking: newagentllm.ThinkingModeDisabled, + }, + ) + if err != nil || decision.Validate() != nil { + // 分类失败 → 保守:走 task。 + runtimeState.EnsureCommonState().Phase = newagentmodel.PhasePlanning + return nil + } + + // 3. 按意图分流。 + flowState := runtimeState.EnsureCommonState() + switch decision.Intent { + case "task": + flowState.Phase = newagentmodel.PhasePlanning + return nil + case "chat": + return handleChatReply(ctx, decision, conversationContext, emitter, flowState) + default: + flowState.Phase = newagentmodel.PhasePlanning + return nil + } +} + +// handleChatResume 处理 pending interaction 恢复。 +// +// 职责边界: +// 1. 只做状态传递:吞掉用户输入、写回历史、恢复 phase; +// 2. 不生成 speak,真正的回复由下游 Plan / Execute 节点产出; +// 3. 只推送轻量 status 通知前端"已收到回复,正在继续"。 +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)) + } + + switch pending.Type { + case newagentmodel.PendingInteractionTypeAskUser: + // 用户回答了问题 → 恢复 phase,交给下游节点继续。 + runtimeState.ResumeFromPending() + _ = emitter.EmitStatus( + chatStatusBlockID, chatStageName, + "resumed", "收到回复,继续处理。", false, + ) + return nil + + case newagentmodel.PendingInteractionTypeConfirm: + return handleConfirmResume(input, runtimeState, flowState, pending, emitter) + + default: + // connection_lost 等其他类型 → 直接恢复。 + runtimeState.ResumeFromPending() + return nil + } +} + +// handleConfirmResume 处理 confirm 类型恢复。 +// +// 分支逻辑: +// 1. accept → 恢复后 phase 设为 executing,下游 Execute 节点接管; +// 2. reject + 有 PendingTool(工具确认)→ 回到 executing 让 Execute 节点换策略; +// 3. reject + 无 PendingTool(计划确认)→ 清空计划,回到 planning 重新规划。 +func handleConfirmResume( + input ChatNodeInput, + runtimeState *newagentmodel.AgentRuntimeState, + flowState *newagentmodel.CommonState, + pending *newagentmodel.PendingInteraction, + emitter *newagentstream.ChunkEmitter, +) error { + action := strings.ToLower(strings.TrimSpace(input.ConfirmAction)) + + switch action { + case "accept": + runtimeState.ResumeFromPending() + flowState.Phase = newagentmodel.PhaseExecuting + _ = emitter.EmitStatus( + chatStatusBlockID, chatStageName, + "confirmed", "已确认,开始执行。", false, + ) + + case "reject": + runtimeState.ResumeFromPending() + if pending.PendingTool != nil { + // 工具确认被拒 → 回到 executing 换策略。 + flowState.Phase = newagentmodel.PhaseExecuting + } else { + // 计划确认被拒 → 清空计划,回到 planning。 + flowState.RejectPlan() + } + _ = emitter.EmitStatus( + chatStatusBlockID, chatStageName, + "rejected", "已取消,准备重新规划。", false, + ) + + default: + // 无合法 confirm action → 保守:等同于 reject。 + runtimeState.ResumeFromPending() + if pending.PendingTool != nil { + flowState.Phase = newagentmodel.PhaseExecuting + } else { + flowState.RejectPlan() + } + } + return nil +} + +// handleChatReply 处理纯闲聊意图 — 把分类时产出的 reply 流式推给前端。 +func handleChatReply( + ctx context.Context, + decision *chatIntentDecision, + conversationContext *newagentmodel.ConversationContext, + emitter *newagentstream.ChunkEmitter, + flowState *newagentmodel.CommonState, +) error { + reply := strings.TrimSpace(decision.Reply) + + if reply != "" { + if err := emitter.EmitPseudoAssistantText( + ctx, chatSpeakBlockID, chatStageName, + reply, + newagentstream.DefaultPseudoStreamOptions(), + ); err != nil { + return fmt.Errorf("闲聊回复推送失败: %w", err) + } + conversationContext.AppendHistory(schema.AssistantMessage(reply, nil)) + } + + flowState.Phase = newagentmodel.PhaseChatting + return nil +} + +// prepareChatNodeInput 校验并准备聊天节点的运行态依赖。 +func prepareChatNodeInput(input ChatNodeInput) ( + *newagentmodel.AgentRuntimeState, + *newagentmodel.ConversationContext, + *newagentstream.ChunkEmitter, + error, +) { + if input.RuntimeState == nil { + return nil, nil, nil, fmt.Errorf("chat node: runtime state 不能为空") + } + if input.Client == nil { + return nil, nil, nil, fmt.Errorf("chat node: chat 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 +} diff --git a/backend/newAgent/node/correction.go b/backend/newAgent/node/correction.go new file mode 100644 index 0000000..a0bece1 --- /dev/null +++ b/backend/newAgent/node/correction.go @@ -0,0 +1,100 @@ +package newagentnode + +import ( + "fmt" + "strings" + + newagentmodel "github.com/LoveLosita/smartflow/backend/newAgent/model" + "github.com/cloudwego/eino/schema" +) + +// AppendLLMCorrection 追加 LLM 修正提示到对话历史。 +// +// 设计目的: +// 1. 当 LLM 输出不符合预期(如不支持的 action、格式错误等),不应直接报错终止; +// 2. 应该给 LLM 一个自我修正的机会,把错误反馈写回历史,让它重新生成; +// 3. 该函数封装了"追加 assistant 消息 + 追加纠正提示"的通用流程。 +// +// 参数说明: +// - conversationContext: 对话上下文,用于追加历史消息; +// - llmOutput: LLM 的原始输出内容,会作为 assistant 消息追加; +// - validOptionsDesc: 合法选项的描述,用于构造纠正提示。 +// +// 使用示例: +// +// AppendLLMCorrection(conversationContext, decision.Speak, "合法的 action 包括:continue、ask_user、next_plan、done") +// +// 返回值: +// - 返回 nil 表示修正流程完成,调用方应继续 Graph 循环; +// - 该函数不会返回 error,因为追加历史失败不影响主流程。 +func AppendLLMCorrection( + conversationContext *newagentmodel.ConversationContext, + llmOutput string, + validOptionsDesc string, +) { + if conversationContext == nil { + return + } + + // 1. 构造 assistant 消息,让 LLM 知道自己刚才输出了什么。 + // 如果 llmOutput 为空,则生成一个占位描述。 + assistantContent := strings.TrimSpace(llmOutput) + if assistantContent == "" { + assistantContent = "[LLM 输出为空或无法解析]" + } + conversationContext.AppendHistory(&schema.Message{ + Role: schema.Assistant, + Content: assistantContent, + }) + + // 2. 构造纠正提示,明确告知 LLM 哪里错了、合法选项有哪些。 + // 不做硬编码的错误类型,由调用方通过 validOptionsDesc 传入。 + correctionContent := fmt.Sprintf( + "你的输出不符合预期。%s 请重新分析当前状态,输出正确的内容。", + validOptionsDesc, + ) + conversationContext.AppendHistory(&schema.Message{ + Role: schema.User, + Content: correctionContent, + }) +} + +// AppendLLMCorrectionWithHint 追加 LLM 修正提示(带自定义错误描述)。 +// +// 相比 AppendLLMCorrection,该函数允许调用方提供更详细的错误描述, +// 适用于需要明确告知 LLM 具体哪里出错的场景。 +// +// 参数说明: +// - conversationContext: 对话上下文; +// - llmOutput: LLM 的原始输出内容; +// - errorDesc: 具体的错误描述,如 "action \"invalid\" 不是合法的执行动作"; +// - validOptionsDesc: 合法选项的描述。 +func AppendLLMCorrectionWithHint( + conversationContext *newagentmodel.ConversationContext, + llmOutput string, + errorDesc string, + validOptionsDesc string, +) { + if conversationContext == nil { + return + } + + assistantContent := strings.TrimSpace(llmOutput) + if assistantContent == "" { + assistantContent = "[LLM 输出为空或无法解析]" + } + conversationContext.AppendHistory(&schema.Message{ + Role: schema.Assistant, + Content: assistantContent, + }) + + correctionContent := fmt.Sprintf( + "%s %s 请重新分析当前状态,输出正确的内容。", + errorDesc, + validOptionsDesc, + ) + conversationContext.AppendHistory(&schema.Message{ + Role: schema.User, + Content: correctionContent, + }) +} diff --git a/backend/newAgent/node/execute.go b/backend/newAgent/node/execute.go new file mode 100644 index 0000000..41234bd --- /dev/null +++ b/backend/newAgent/node/execute.go @@ -0,0 +1,314 @@ +package newagentnode + +import ( + "context" + "fmt" + "strings" + "time" + + 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/google/uuid" +) + +const ( + executeStageName = "execute" + executeStatusBlockID = "execute.status" + executeSpeakBlockID = "execute.speak" + executePinnedKey = "execution_context" +) + +// ExecuteNodeInput 描述执行节点单轮运行所需的最小依赖。 +// +// 职责边界: +// 1. 只承载"本轮执行"需要的输入,不负责持久化; +// 2. RuntimeState 提供 plan 步骤与轮次预算; +// 3. ConversationContext 提供历史对话与置顶上下文; +// 4. ToolExecutor 后续由业务层注入,当前先留空。 +type ExecuteNodeInput struct { + RuntimeState *newagentmodel.AgentRuntimeState + ConversationContext *newagentmodel.ConversationContext + UserInput string + Client *newagentllm.Client + ChunkEmitter *newagentstream.ChunkEmitter + ResumeNode string +} + +// ExecuteRoundObservation 记录执行阶段每轮的关键观察。 +// +// 设计说明: +// 1. 参考 coding agent 模式,后端只记录事实,不做语义校验; +// 2. ToolResult 存储工具调用的原始返回,供 LLM 下一轮决策; +// 3. 该结构后续可扩展用于调试、回放、审计。 +type ExecuteRoundObservation struct { + Round int `json:"round"` + StepIndex int `json:"step_index"` + GoalCheck string `json:"goal_check,omitempty"` + Decision string `json:"decision,omitempty"` + ToolName string `json:"tool_name,omitempty"` + ToolParams string `json:"tool_params,omitempty"` + ToolSuccess bool `json:"tool_success"` + ToolResult string `json:"tool_result,omitempty"` +} + +// RunExecuteNode 执行一轮执行节点逻辑。 +// +// 核心设计原则: +// 1. LLM 主导:LLM 自己判断 done_when 是否满足,自己决定何时推进/完成; +// 2. 后端兜底:只做资源控制(轮次预算)、安全兜底(防无限循环)、证据记录; +// 3. 不做硬校验:后端不质疑 LLM 的 advance/complete 决策,信任 LLM 判断。 +// +// 步骤说明: +// 1. 校验最小依赖,推送"正在执行"状态,避免用户空等; +// 2. 检查当前是否有可执行的 plan 步骤,无计划则报错; +// 3. 构造执行阶段 prompt,调用 LLM 获取决策; +// 4. 若 LLM 先对用户说话,则伪流式推送并写回历史; +// 5. 按 LLM 决策执行动作: +// 5.1 call_tool:执行工具调用,记录证据,推进轮次; +// 5.2 ask_user:打开追问交互,等待用户回复; +// 5.3 advance:LLM 判定当前步骤完成,推进到下一步; +// 5.4 complete:LLM 判定整个任务完成,进入交付阶段; +// 6. 安全兜底:轮次耗尽时强制进入交付,避免无限循环。 +func RunExecuteNode(ctx context.Context, input ExecuteNodeInput) error { + // 1. 校验依赖并准备运行态。 + runtimeState, conversationContext, emitter, err := prepareExecuteNodeInput(input) + if err != nil { + return err + } + flowState := runtimeState.EnsureCommonState() + + // 2. 检查是否有可执行的 plan 步骤。 + if !flowState.HasCurrentPlanStep() { + return fmt.Errorf("execute node: 当前无有效 plan 步骤,无法执行") + } + + // 3. 推送执行阶段状态,让前端知道当前进度。 + current, total := flowState.PlanProgress() + currentStep, _ := flowState.CurrentPlanStep() + if err := emitter.EmitStatus( + executeStatusBlockID, + executeStageName, + "executing", + fmt.Sprintf("正在执行第 %d/%d 步:%s", current, total, truncateText(currentStep.Content, 60)), + false, + ); err != nil { + return fmt.Errorf("执行阶段状态推送失败: %w", err) + } + + // 4. 消耗一轮预算,并检查是否耗尽。 + if !flowState.NextRound() { + // 轮次耗尽,强制进入交付阶段。 + flowState.Done() + return nil + } + + // 5. 构造本轮执行输入,请求 LLM 输出 ExecuteDecision。 + messages := newagentprompt.BuildExecuteMessages(flowState, conversationContext) + decision, rawResult, err := newagentllm.GenerateJSON[newagentmodel.ExecuteDecision]( + ctx, + input.Client, + messages, + newagentllm.GenerateOptions{ + Temperature: 0.3, + MaxTokens: 1200, + Thinking: newagentllm.ThinkingModeEnabled, + Metadata: map[string]any{ + "stage": executeStageName, + "step_index": flowState.CurrentStep, + "round_used": flowState.RoundUsed, + }, + }, + ) + 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) + } + + // 6. 若 LLM 先对用户说话,则伪流式推送并写回历史。 + if strings.TrimSpace(decision.Speak) != "" { + if err := emitter.EmitPseudoAssistantText( + ctx, + executeSpeakBlockID, + executeStageName, + decision.Speak, + newagentstream.DefaultPseudoStreamOptions(), + ); err != nil { + return fmt.Errorf("执行文案推送失败: %w", err) + } + // 将 LLM 的话追加到对话历史,保证下一轮上下文连续。 + // TODO: 后续需要把工具调用结果也追加到历史,这里先留占位。 + } + + // 7. 按 LLM 决策执行动作,后端信任 LLM 判断,不做语义校验。 + switch decision.Action { + case newagentmodel.ExecuteActionContinue: + // 继续当前步骤的 ReAct 循环。 + // 若有工具调用意图,则执行工具并记录证据。 + if decision.ToolCall != nil { + return executeToolCall(ctx, flowState, conversationContext, decision.ToolCall, emitter) + } + // 无工具调用,仅对话,继续下一轮。 + return nil + + case newagentmodel.ExecuteActionAskUser: + // LLM 判定缺少关键信息,打开追问交互。 + question := resolveExecuteAskUserText(decision) + runtimeState.OpenAskUserInteraction(uuid.NewString(), question, strings.TrimSpace(input.ResumeNode)) + return nil + + case newagentmodel.ExecuteActionNextPlan: + // LLM 判定当前步骤已完成,推进到下一步。 + // 后端信任 LLM 判断,不做硬校验。 + if !flowState.AdvanceStep() { + // 所有步骤已完成,进入交付阶段。 + flowState.Done() + } + return nil + + case newagentmodel.ExecuteActionDone: + // LLM 判定整个任务已完成,直接进入交付阶段。 + // 后端信任 LLM 判断,不做硬校验。 + flowState.Done() + 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 + } +} + +// prepareExecuteNodeInput 校验并准备执行节点的运行态依赖。 +// +// 职责边界: +// 1. 校验必要依赖是否注入; +// 2. 为空依赖提供兜底值,避免空指针; +// 3. 不负责持久化,不负责业务逻辑。 +func prepareExecuteNodeInput(input ExecuteNodeInput) (*newagentmodel.AgentRuntimeState, *newagentmodel.ConversationContext, *newagentstream.ChunkEmitter, error) { + if input.RuntimeState == nil { + return nil, nil, nil, fmt.Errorf("execute node: runtime state 不能为空") + } + if input.Client == nil { + return nil, nil, nil, fmt.Errorf("execute node: execute 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 +} + +// resolveExecuteAskUserText 解析追问用户的文案。 +// +// 优先级: +// 1. 优先使用 LLM 输出的 speak; +// 2. 其次使用 reason; +// 3. 最后使用默认文案。 +func resolveExecuteAskUserText(decision *newagentmodel.ExecuteDecision) 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 "执行过程中遇到不确定的情况,需要向你确认。" +} + +// executeToolCall 执行工具调用并记录证据。 +// +// 职责边界: +// 1. 只负责执行工具调用,记录结果; +// 2. 不负责判断工具调用是否成功(由 LLM 下一轮判断); +// 3. 不负责重试(由外层 Graph 循环控制)。 +// +// TODO: 当前为骨架实现,后续需要: +// 1. 接入真实的工具执行器; +// 2. 把工具调用结果追加到对话历史; +// 3. 记录 ExecuteEvidenceReceipt。 +func executeToolCall( + ctx context.Context, + flowState *newagentmodel.CommonState, + conversationContext *newagentmodel.ConversationContext, + toolCall *newagentmodel.ToolCallIntent, + emitter *newagentstream.ChunkEmitter, +) error { + if toolCall == nil { + return nil + } + + // 当前为骨架实现,仅记录工具调用意图。 + // 后续需要: + // 1. 根据 toolCall.Name 路由到具体工具执行器; + // 2. 执行工具调用,获取结果; + // 3. 记录 ExecuteEvidenceReceipt; + // 4. 把工具调用结果追加到 conversationContext.History。 + + toolName := strings.TrimSpace(toolCall.Name) + if toolName == "" { + return fmt.Errorf("工具调用缺少工具名称") + } + + // 推送工具调用状态,让前端知道当前在做什么。 + if err := emitter.EmitStatus( + executeStatusBlockID, + executeStageName, + "tool_call", + fmt.Sprintf("正在调用工具:%s", toolName), + false, + ); err != nil { + return fmt.Errorf("工具调用状态推送失败: %w", err) + } + + // TODO: 执行真实工具调用,并记录证据。 + // 伪代码: + // result := toolRegistry.Execute(ctx, toolCall.Name, toolCall.Arguments) + // evidence := ExecuteEvidenceReceipt{ + // StepIndex: flowState.CurrentStep, + // Source: ExecuteEvidenceSourceToolObservation, + // Name: toolCall.Name, + // Success: result.Success, + // Summary: result.Summary, + // } + // flowState.RecordEvidence(evidence) + + return nil +} + +// truncateText 截断文本到指定长度。 +// +// 用于状态推送时避免超长文本影响前端展示。 +func truncateText(text string, maxLen int) string { + text = strings.TrimSpace(text) + if len(text) <= maxLen { + return text + } + if maxLen <= 3 { + return text[:maxLen] + } + return text[:maxLen-3] + "..." +} diff --git a/backend/newAgent/node/plan.go b/backend/newAgent/node/plan.go index 0bcbad6..aadf5db 100644 --- a/backend/newAgent/node/plan.go +++ b/backend/newAgent/node/plan.go @@ -119,7 +119,20 @@ func RunPlanNode(ctx context.Context, input PlanNodeInput) error { writePlanPinnedBlocks(conversationContext, decision.PlanSteps) return nil default: - return fmt.Errorf("未支持的规划动作: %s", decision.Action) + // 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 } } diff --git a/backend/newAgent/prompt/chat.go b/backend/newAgent/prompt/chat.go new file mode 100644 index 0000000..106acab --- /dev/null +++ b/backend/newAgent/prompt/chat.go @@ -0,0 +1,56 @@ +package newagentprompt + +import ( + "strings" + + newagentmodel "github.com/LoveLosita/smartflow/backend/newAgent/model" + "github.com/cloudwego/eino/schema" +) + +const chatIntentSystemPrompt = ` +你是 SmartFlow 的意图分类器。 +你的唯一任务是判断用户本轮输入是"纯闲聊"还是"包含任务意图"。 + +判断规则: +1. chat:打招呼、感谢、简单问答、情感表达、闲聊,不涉及任何具体任务或操作请求。 +2. task:包含任何需要规划/执行/操作的意图,包括但不限于查询信息、创建内容、修改数据、安排日程、继续已有任务等。 + +保守原则:当不确定时,倾向于判断为 task,宁可多走一次规划也不要丢失用户意图。 + +严格输出以下 JSON(不要输出 markdown,不要在 JSON 外补文字): +{"intent":"chat或task","reply":"仅当intent=chat时填写你的闲聊回复,task时留空","reason":"简短判断依据"} +` + +// BuildChatIntentSystemPrompt 返回意图分类系统提示词。 +func BuildChatIntentSystemPrompt() string { + return strings.TrimSpace(chatIntentSystemPrompt) +} + +// BuildChatIntentMessages 组装意图分类的 messages。 +// +// 职责边界: +// 1. 只取最近 6 条历史,保证分类高效; +// 2. 不注入 pinned blocks / tool schemas,分类不需要这些信息; +// 3. 不负责解析模型输出。 +func BuildChatIntentMessages(conversationContext *newagentmodel.ConversationContext, userInput string) []*schema.Message { + messages := make([]*schema.Message, 0, 8) + + messages = append(messages, schema.SystemMessage(BuildChatIntentSystemPrompt())) + + if conversationContext != nil { + history := conversationContext.HistorySnapshot() + if len(history) > 6 { + history = history[len(history)-6:] + } + if len(history) > 0 { + messages = append(messages, history...) + } + } + + trimmedInput := strings.TrimSpace(userInput) + if trimmedInput != "" { + messages = append(messages, schema.UserMessage(trimmedInput)) + } + + return messages +}