Version: 0.9.37.dev.260423
后端: 1. Plan / Execute / Deliver 三节点真流式输出——替换 GenerateJSON/GenerateText 为 Client.Stream + 两阶段流式解析 - newAgent/router/decision_parser.go:新增 StreamDecisionParser,从 LLM 流中增量提取 <SMARTFLOW_DECISION> 标签内 JSON,标签后文本作为用户可见正文逐 token 返回;含 9 项单测覆盖正常提取、跨 chunk 拆分、fallback、解析失败、空正文等场景 - newAgent/node/deliver.go:GenerateText 替换为 Client.Stream + EmitStreamAssistantText 真流式推送,降级/机械路径仍走伪流式 - newAgent/node/plan.go:GenerateJSON 替换为 Client.Stream + DecisionParser 两阶段流式,thinking 内容独立推流,speak 正文逐 token 推送 - newAgent/node/execute.go:同上两阶段流式改造,保留完整 correction 机制(ConsecutiveCorrections / tool_call 数组检测 / 空文本回退),speak 推送段删除 EmitPseudoAssistantText - newAgent/prompt/plan.go + execute.go:系统提示词与输出协议从"只输出严格 JSON"改为 SMARTFLOW_DECISION 两阶段格式(标签内 JSON + 标签后自然语言正文),移除 speak 字段 2. 前端零改动——EmitAssistantText 产出的 SSE chunk 格式与伪流式完全一致,前端无需适配
This commit is contained in:
@@ -3,6 +3,7 @@ package newagentnode
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -67,7 +68,7 @@ func RunDeliverNode(ctx context.Context, input DeliverNodeInput) error {
|
||||
}
|
||||
|
||||
// 2. 调 LLM 生成交付总结。
|
||||
summary := generateDeliverSummary(ctx, input.Client, flowState, conversationContext, input.ThinkingEnabled, input.CompactionStore, emitter)
|
||||
summary, streamed := generateDeliverSummary(ctx, input.Client, flowState, conversationContext, input.ThinkingEnabled, input.CompactionStore, emitter)
|
||||
|
||||
// 2.1 排程完毕卡片信号:
|
||||
// 1. 仅在流程正常完成且确实产生过日程变更(粗排或写工具)时推送;
|
||||
@@ -77,20 +78,27 @@ func RunDeliverNode(ctx context.Context, input DeliverNodeInput) error {
|
||||
_ = emitter.EmitScheduleCompleted(deliverStatusBlockID, deliverStageName)
|
||||
}
|
||||
|
||||
// 3. 伪流式推送总结。
|
||||
// 3. 推送总结。LLM 路径已在 generateDeliverSummary 内部真流式推送,
|
||||
// 仅机械/降级路径需要在此伪流式补推。
|
||||
if strings.TrimSpace(summary) != "" {
|
||||
msg := schema.AssistantMessage(summary, nil)
|
||||
if err := emitter.EmitPseudoAssistantText(
|
||||
ctx,
|
||||
deliverSpeakBlockID,
|
||||
deliverStageName,
|
||||
summary,
|
||||
newagentstream.DefaultPseudoStreamOptions(),
|
||||
); err != nil {
|
||||
return fmt.Errorf("交付总结推送失败: %w", err)
|
||||
if !streamed {
|
||||
msg := schema.AssistantMessage(summary, nil)
|
||||
if err := emitter.EmitPseudoAssistantText(
|
||||
ctx,
|
||||
deliverSpeakBlockID,
|
||||
deliverStageName,
|
||||
summary,
|
||||
newagentstream.DefaultPseudoStreamOptions(),
|
||||
); err != nil {
|
||||
return fmt.Errorf("交付总结推送失败: %w", err)
|
||||
}
|
||||
conversationContext.AppendHistory(msg)
|
||||
persistVisibleAssistantMessage(ctx, input.PersistVisibleMessage, flowState, msg)
|
||||
} else {
|
||||
msg := schema.AssistantMessage(summary, nil)
|
||||
conversationContext.AppendHistory(msg)
|
||||
persistVisibleAssistantMessage(ctx, input.PersistVisibleMessage, flowState, msg)
|
||||
}
|
||||
conversationContext.AppendHistory(msg)
|
||||
persistVisibleAssistantMessage(ctx, input.PersistVisibleMessage, flowState, msg)
|
||||
}
|
||||
|
||||
// 4. 推送最终完成状态。
|
||||
@@ -106,6 +114,10 @@ func RunDeliverNode(ctx context.Context, input DeliverNodeInput) error {
|
||||
}
|
||||
|
||||
// generateDeliverSummary 尝试调用 LLM 生成交付总结,失败时降级到机械格式化。
|
||||
//
|
||||
// 返回值:
|
||||
// - summary:完整总结文本(用于历史写入);
|
||||
// - streamed:true 表示文本已通过 EmitStreamAssistantText 真流式推送到前端,调用方无需再伪流式。
|
||||
func generateDeliverSummary(
|
||||
ctx context.Context,
|
||||
client *infrallm.Client,
|
||||
@@ -114,18 +126,18 @@ func generateDeliverSummary(
|
||||
thinkingEnabled bool,
|
||||
compactionStore newagentmodel.CompactionStore,
|
||||
emitter *newagentstream.ChunkEmitter,
|
||||
) string {
|
||||
) (string, bool) {
|
||||
if flowState != nil {
|
||||
switch {
|
||||
case flowState.IsAborted():
|
||||
return normalizeSpeak(buildAbortSummary(flowState))
|
||||
return normalizeSpeak(buildAbortSummary(flowState)), false
|
||||
case flowState.IsExhaustedTerminal():
|
||||
return normalizeSpeak(buildExhaustedSummary(flowState))
|
||||
return normalizeSpeak(buildExhaustedSummary(flowState)), false
|
||||
}
|
||||
}
|
||||
|
||||
if client == nil {
|
||||
return buildMechanicalSummary(flowState)
|
||||
return buildMechanicalSummary(flowState), false
|
||||
}
|
||||
|
||||
messages := newagentprompt.BuildDeliverMessages(flowState, conversationContext)
|
||||
@@ -138,7 +150,8 @@ func generateDeliverSummary(
|
||||
StatusBlockID: deliverStatusBlockID,
|
||||
})
|
||||
logNodeLLMContext(deliverStageName, "summarizing", flowState, messages)
|
||||
result, err := client.GenerateText(
|
||||
|
||||
reader, err := client.Stream(
|
||||
ctx,
|
||||
messages,
|
||||
infrallm.GenerateOptions{
|
||||
@@ -150,11 +163,18 @@ func generateDeliverSummary(
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil || result == nil || strings.TrimSpace(result.Text) == "" {
|
||||
return buildMechanicalSummary(flowState)
|
||||
if err != nil {
|
||||
log.Printf("[WARN] deliver Stream 调用失败,降级到机械总结: %v", err)
|
||||
return buildMechanicalSummary(flowState), false
|
||||
}
|
||||
|
||||
return normalizeSpeak(result.Text)
|
||||
fullText, streamErr := emitter.EmitStreamAssistantText(ctx, reader, deliverSpeakBlockID, deliverStageName)
|
||||
if streamErr != nil || strings.TrimSpace(fullText) == "" {
|
||||
log.Printf("[WARN] deliver 流式推送失败或结果为空,降级到机械总结: streamErr=%v textLen=%d", streamErr, len(fullText))
|
||||
return buildMechanicalSummary(flowState), false
|
||||
}
|
||||
|
||||
return normalizeSpeak(fullText), true
|
||||
}
|
||||
|
||||
// buildAbortSummary 生成“流程已终止”的统一交付文案。
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"regexp"
|
||||
"strconv"
|
||||
@@ -13,6 +14,7 @@ import (
|
||||
infrallm "github.com/LoveLosita/smartflow/backend/infra/llm"
|
||||
newagentmodel "github.com/LoveLosita/smartflow/backend/newAgent/model"
|
||||
newagentprompt "github.com/LoveLosita/smartflow/backend/newAgent/prompt"
|
||||
newagentrouter "github.com/LoveLosita/smartflow/backend/newAgent/router"
|
||||
newagentstream "github.com/LoveLosita/smartflow/backend/newAgent/stream"
|
||||
newagenttools "github.com/LoveLosita/smartflow/backend/newAgent/tools"
|
||||
"github.com/LoveLosita/smartflow/backend/newAgent/tools/schedule"
|
||||
@@ -192,13 +194,14 @@ func RunExecuteNode(ctx context.Context, input ExecuteNodeInput) error {
|
||||
})
|
||||
|
||||
logNodeLLMContext(executeStageName, "decision", flowState, messages)
|
||||
decision, rawResult, err := infrallm.GenerateJSON[newagentmodel.ExecuteDecision](
|
||||
|
||||
// 两阶段流式执行:从 LLM 流中先提取 <SMARTFLOW_DECISION> 决策标签,再流式推送 speak 正文。
|
||||
reader, err := input.Client.Stream(
|
||||
ctx,
|
||||
input.Client,
|
||||
messages,
|
||||
infrallm.GenerateOptions{
|
||||
Temperature: 1.0, // thinking 模式强制要求 temperature=1
|
||||
MaxTokens: 16000, // 需为 thinking chain 留出足够预算
|
||||
Temperature: 1.0,
|
||||
MaxTokens: 16000,
|
||||
Thinking: resolveThinkingMode(input.ThinkingEnabled),
|
||||
Metadata: map[string]any{
|
||||
"stage": executeStageName,
|
||||
@@ -207,14 +210,48 @@ func RunExecuteNode(ctx context.Context, input ExecuteNodeInput) error {
|
||||
},
|
||||
},
|
||||
)
|
||||
// 提前捕获原始文本,用于日志和 correction。
|
||||
rawText := ""
|
||||
if rawResult != nil {
|
||||
rawText = strings.TrimSpace(rawResult.Text)
|
||||
if err != nil {
|
||||
return fmt.Errorf("执行阶段 Stream 调用失败: %w", err)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
if rawText != "" {
|
||||
parser := newagentrouter.NewStreamDecisionParser()
|
||||
firstChunk := true
|
||||
var decision *newagentmodel.ExecuteDecision
|
||||
var fullText strings.Builder
|
||||
rawText := ""
|
||||
|
||||
// 阶段一:解析决策标签。
|
||||
for {
|
||||
chunk, recvErr := reader.Recv()
|
||||
if recvErr == io.EOF {
|
||||
break
|
||||
}
|
||||
if recvErr != nil {
|
||||
log.Printf("[WARN] execute stream recv error chat=%s err=%v", flowState.ConversationID, recvErr)
|
||||
break
|
||||
}
|
||||
|
||||
if chunk != nil && strings.TrimSpace(chunk.ReasoningContent) != "" {
|
||||
if emitErr := emitter.EmitReasoningText(executeSpeakBlockID, executeStageName, chunk.ReasoningContent, firstChunk); emitErr != nil {
|
||||
return fmt.Errorf("执行 thinking 推送失败: %w", emitErr)
|
||||
}
|
||||
firstChunk = false
|
||||
}
|
||||
|
||||
content := ""
|
||||
if chunk != nil {
|
||||
content = chunk.Content
|
||||
}
|
||||
|
||||
visible, ready, _ := parser.Feed(content)
|
||||
if !ready {
|
||||
continue
|
||||
}
|
||||
|
||||
result := parser.Result()
|
||||
rawText = result.RawBuffer
|
||||
|
||||
if result.Fallback || result.ParseFailed {
|
||||
log.Printf("[DEBUG] execute LLM 输出解析失败 chat=%s round=%d raw=%s",
|
||||
flowState.ConversationID, flowState.RoundUsed, rawText)
|
||||
flowState.ConsecutiveCorrections++
|
||||
@@ -222,23 +259,71 @@ func RunExecuteNode(ctx context.Context, input ExecuteNodeInput) error {
|
||||
return fmt.Errorf("连续 %d 次输出非 JSON,终止执行: 原始输出=%s",
|
||||
flowState.ConsecutiveCorrections, rawText)
|
||||
}
|
||||
// 区分两种常见失败:
|
||||
// 1. tool_call 是数组(LLM 想批量调工具)→ 告知只能单次调用,保留已有上下文;
|
||||
// 2. 真正的 JSON 格式损坏 → 要求重新输出合法 JSON。
|
||||
var errorDesc, optionHint string
|
||||
if strings.Contains(rawText, `"tool_call": [`) || strings.Contains(rawText, `"tool_call":[`) {
|
||||
errorDesc = "你在 tool_call 字段传入了数组,但每轮只能调用一个工具,不支持批量格式。"
|
||||
optionHint = "请把多个工具调用拆开,每轮只调一个,拿到结果后再继续下一步。示例:{\"speak\":\"...\",\"action\":\"continue\",\"reason\":\"...\",\"tool_call\":{\"name\":\"get_task_info\",\"arguments\":{\"task_id\":1}}}"
|
||||
optionHint = "请把多个工具调用拆开,每轮只调一个,拿到结果后再继续下一步。"
|
||||
} else {
|
||||
errorDesc = "你的输出不是合法 JSON,无法解析。"
|
||||
optionHint = "你必须输出严格的 JSON 格式。合法格式示例:{\"speak\":\"...\",\"action\":\"continue\",\"reason\":\"...\",\"tool_call\":{\"name\":\"工具名\",\"arguments\":{}}}"
|
||||
errorDesc = "你的输出不包含合法的 SMARTFLOW_DECISION 标签,无法解析。"
|
||||
optionHint = "你必须先输出 <SMARTFLOW_DECISION>{JSON}</SMARTFLOW_DECISION>,然后在标签后输出正文。"
|
||||
}
|
||||
AppendLLMCorrectionWithHint(conversationContext, rawText, errorDesc, optionHint)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 模型返回空文本(常见原因:上下文过长、模型异常),走 correction 重试而非直接 fatal。
|
||||
if strings.Contains(err.Error(), "empty text") {
|
||||
var parseErr error
|
||||
decision, parseErr = infrallm.ParseJSONObject[newagentmodel.ExecuteDecision](result.DecisionJSON)
|
||||
if parseErr != nil {
|
||||
log.Printf("[DEBUG] execute LLM JSON 解析失败 chat=%s round=%d json=%s raw=%s",
|
||||
flowState.ConversationID, flowState.RoundUsed, result.DecisionJSON, rawText)
|
||||
flowState.ConsecutiveCorrections++
|
||||
if flowState.ConsecutiveCorrections >= maxConsecutiveCorrections {
|
||||
return fmt.Errorf("连续 %d 次输出非 JSON,终止执行: 原始输出=%s",
|
||||
flowState.ConsecutiveCorrections, rawText)
|
||||
}
|
||||
AppendLLMCorrectionWithHint(conversationContext, rawText,
|
||||
"决策标签内的 JSON 格式不合法。",
|
||||
"请确保 <SMARTFLOW_DECISION> 标签内是合法 JSON,然后用标签后输出正文。")
|
||||
return nil
|
||||
}
|
||||
|
||||
// 阶段二:流式推送 speak(同一 reader 继续读取)。
|
||||
if visible != "" {
|
||||
if emitErr := emitter.EmitAssistantText(executeSpeakBlockID, executeStageName, visible, firstChunk); emitErr != nil {
|
||||
return fmt.Errorf("执行文案推送失败: %w", emitErr)
|
||||
}
|
||||
fullText.WriteString(visible)
|
||||
firstChunk = false
|
||||
}
|
||||
for {
|
||||
chunk2, recvErr2 := reader.Recv()
|
||||
if recvErr2 == io.EOF {
|
||||
break
|
||||
}
|
||||
if recvErr2 != nil {
|
||||
log.Printf("[WARN] execute speak stream error chat=%s err=%v", flowState.ConversationID, recvErr2)
|
||||
break
|
||||
}
|
||||
if chunk2 == nil {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(chunk2.ReasoningContent) != "" {
|
||||
_ = emitter.EmitReasoningText(executeSpeakBlockID, executeStageName, chunk2.ReasoningContent, false)
|
||||
}
|
||||
if chunk2.Content != "" {
|
||||
if emitErr := emitter.EmitAssistantText(executeSpeakBlockID, executeStageName, chunk2.Content, firstChunk); emitErr != nil {
|
||||
return fmt.Errorf("执行文案推送失败: %w", emitErr)
|
||||
}
|
||||
fullText.WriteString(chunk2.Content)
|
||||
firstChunk = false
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// 流结束但未找到决策标签。
|
||||
if decision == nil {
|
||||
if strings.TrimSpace(rawText) == "" {
|
||||
log.Printf("[WARN] execute LLM 返回空文本 chat=%s round=%d consecutive=%d/%d",
|
||||
flowState.ConversationID, flowState.RoundUsed,
|
||||
flowState.ConsecutiveCorrections+1, maxConsecutiveCorrections)
|
||||
@@ -250,15 +335,16 @@ func RunExecuteNode(ctx context.Context, input ExecuteNodeInput) error {
|
||||
conversationContext,
|
||||
"",
|
||||
"模型没有返回任何内容。",
|
||||
"请重新输出合法 JSON 格式的执行决策。",
|
||||
"请重新输出 <SMARTFLOW_DECISION>{JSON}</SMARTFLOW_DECISION> 格式的执行决策。",
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("执行阶段模型调用失败: %w", err)
|
||||
return fmt.Errorf("执行阶段流结束但未提取到决策标签")
|
||||
}
|
||||
|
||||
// 调试日志:输出 LLM 原始返回和解析后的决策,方便排查。
|
||||
decision.Speak = fullText.String()
|
||||
|
||||
// 调试日志:输出解析后的决策,方便排查。
|
||||
log.Printf("[DEBUG] execute LLM 响应 chat=%s round=%d action=%s speak_len=%d raw_len=%d raw_preview=%.200s",
|
||||
flowState.ConversationID, flowState.RoundUsed,
|
||||
decision.Action, len(decision.Speak), len(rawText), rawText)
|
||||
@@ -338,34 +424,19 @@ func RunExecuteNode(ctx context.Context, input ExecuteNodeInput) error {
|
||||
}
|
||||
}
|
||||
|
||||
// 6. speak 推流与历史写入。
|
||||
//
|
||||
// AlwaysExecute=true 时,confirm 动作不走确认卡片,speak 和 continue 一样直接推流;
|
||||
// AlwaysExecute=false 时,confirm 的 speak 不推流(由确认卡片展示),但仍写入历史,
|
||||
// 防止 LLM 下一轮忘记自己的计划,形成重复确认循环。
|
||||
speakText := decision.Speak // 已由 normalizeSpeak 处理,末尾含 \n
|
||||
// 6. speak 已在流式循环中推送,此处仅做持久化与历史写入。
|
||||
speakText := decision.Speak
|
||||
if speakText != "" {
|
||||
isConfirmWithCard := decision.Action == newagentmodel.ExecuteActionConfirm && !input.AlwaysExecute
|
||||
isAskUser := decision.Action == newagentmodel.ExecuteActionAskUser
|
||||
isAbort := decision.Action == newagentmodel.ExecuteActionAbort
|
||||
|
||||
if !isConfirmWithCard && !isAskUser && !isAbort {
|
||||
// 推流给前端
|
||||
msg := schema.AssistantMessage(speakText, nil)
|
||||
if err := emitter.EmitPseudoAssistantText(
|
||||
ctx,
|
||||
executeSpeakBlockID,
|
||||
executeStageName,
|
||||
speakText,
|
||||
newagentstream.DefaultPseudoStreamOptions(),
|
||||
); err != nil {
|
||||
return fmt.Errorf("执行文案推送失败: %w", err)
|
||||
}
|
||||
persistVisibleAssistantMessage(ctx, input.PersistVisibleMessage, flowState, msg)
|
||||
}
|
||||
// 1. confirm / ask_user 的 speak 仍要写入历史,避免下一轮 LLM 丢失自己的执行上下文。
|
||||
// 2. abort 不在这里写历史,避免先输出中间 speak,再在 deliver 收到第二份终止文案。
|
||||
// 3. ask_user 只是不在这里伪流式推送,真正的对外展示仍由 PendingInteraction.DisplayText 承担。
|
||||
// confirm / ask_user 的 speak 仍写入历史,避免下一轮 LLM 丢失上下文;
|
||||
// abort 不写历史,避免与 deliver 终止文案冲突。
|
||||
if !isAbort {
|
||||
conversationContext.AppendHistory(&schema.Message{
|
||||
Role: schema.Assistant,
|
||||
|
||||
@@ -3,6 +3,8 @@ package newagentnode
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -11,6 +13,7 @@ import (
|
||||
infrallm "github.com/LoveLosita/smartflow/backend/infra/llm"
|
||||
newagentmodel "github.com/LoveLosita/smartflow/backend/newAgent/model"
|
||||
newagentprompt "github.com/LoveLosita/smartflow/backend/newAgent/prompt"
|
||||
newagentrouter "github.com/LoveLosita/smartflow/backend/newAgent/router"
|
||||
newagentstream "github.com/LoveLosita/smartflow/backend/newAgent/stream"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
@@ -44,9 +47,9 @@ type PlanNodeInput struct {
|
||||
//
|
||||
// 步骤说明:
|
||||
// 1. 先校验最小依赖,并推送一条"正在规划"的状态,避免用户空等;
|
||||
// 2. 单轮深度规划:开 thinking、无 token 上限,让 LLM 一步到位产出完整计划;
|
||||
// 3. 若模型先对用户说了话,则先把 speak 伪流式推给前端,并写回 history;
|
||||
// 4. 最后按 action 推进流程:
|
||||
// 2. 构造本轮规划输入,调用 LLM Stream 接口;
|
||||
// 3. 从流中提取 <SMARTFLOW_DECISION> 标签内的 JSON 决策,同时流式推送 speak 正文;
|
||||
// 4. 按 action 推进流程:
|
||||
// 4.1 continue:继续停留在 planning;
|
||||
// 4.2 ask_user:打开 pending interaction,后续交给 interrupt 收口;
|
||||
// 4.3 plan_done:固化完整计划,刷新 pinned context,并进入 waiting_confirm。
|
||||
@@ -80,10 +83,9 @@ func RunPlanNode(ctx context.Context, input PlanNodeInput) error {
|
||||
})
|
||||
logNodeLLMContext(planStageName, "planning", flowState, messages)
|
||||
|
||||
// 3. 单轮深度规划:由配置决定是否开启 thinking,不做 token 上限约束。
|
||||
decision, rawResult, err := infrallm.GenerateJSON[newagentmodel.PlanDecision](
|
||||
// 3. 两阶段流式规划:从 LLM 流中先提取 <SMARTFLOW_DECISION> 决策标签,再流式推送 speak 正文。
|
||||
reader, err := input.Client.Stream(
|
||||
ctx,
|
||||
input.Client,
|
||||
messages,
|
||||
infrallm.GenerateOptions{
|
||||
Temperature: 0.2,
|
||||
@@ -95,32 +97,113 @@ func RunPlanNode(ctx context.Context, input PlanNodeInput) error {
|
||||
},
|
||||
)
|
||||
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)
|
||||
return fmt.Errorf("规划阶段 Stream 调用失败: %w", err)
|
||||
}
|
||||
|
||||
// 4. 若模型先对用户说了话,且不是 ask_user(ask_user 交给 interrupt 收口),则先以伪流式推送,再写回 history。
|
||||
if strings.TrimSpace(decision.Speak) != "" && decision.Action != newagentmodel.PlanActionAskUser {
|
||||
msg := schema.AssistantMessage(decision.Speak, nil)
|
||||
if err := emitter.EmitPseudoAssistantText(
|
||||
ctx,
|
||||
planSpeakBlockID,
|
||||
planStageName,
|
||||
decision.Speak,
|
||||
newagentstream.DefaultPseudoStreamOptions(),
|
||||
); err != nil {
|
||||
return fmt.Errorf("规划文案推送失败: %w", err)
|
||||
parser := newagentrouter.NewStreamDecisionParser()
|
||||
firstChunk := true
|
||||
|
||||
// 3.1 阶段一:解析决策标签。
|
||||
for {
|
||||
chunk, recvErr := reader.Recv()
|
||||
if recvErr == io.EOF {
|
||||
break
|
||||
}
|
||||
conversationContext.AppendHistory(msg)
|
||||
persistVisibleAssistantMessage(ctx, input.PersistVisibleMessage, flowState, msg)
|
||||
if recvErr != nil {
|
||||
log.Printf("[WARN] plan stream recv error chat=%s err=%v", flowState.ConversationID, recvErr)
|
||||
break
|
||||
}
|
||||
|
||||
// thinking 内容独立推流。
|
||||
if chunk != nil && strings.TrimSpace(chunk.ReasoningContent) != "" {
|
||||
if emitErr := emitter.EmitReasoningText(planSpeakBlockID, planStageName, chunk.ReasoningContent, firstChunk); emitErr != nil {
|
||||
return fmt.Errorf("规划 thinking 推送失败: %w", emitErr)
|
||||
}
|
||||
firstChunk = false
|
||||
}
|
||||
|
||||
content := ""
|
||||
if chunk != nil {
|
||||
content = chunk.Content
|
||||
}
|
||||
|
||||
visible, ready, _ := parser.Feed(content)
|
||||
if !ready {
|
||||
continue
|
||||
}
|
||||
|
||||
result := parser.Result()
|
||||
if result.Fallback || result.ParseFailed {
|
||||
return fmt.Errorf("规划解析失败,原始输出=%s", result.RawBuffer)
|
||||
}
|
||||
|
||||
decision, parseErr := infrallm.ParseJSONObject[newagentmodel.PlanDecision](result.DecisionJSON)
|
||||
if parseErr != nil {
|
||||
return fmt.Errorf("规划决策 JSON 解析失败: %w (raw=%s)", parseErr, result.RawBuffer)
|
||||
}
|
||||
if validateErr := decision.Validate(); validateErr != nil {
|
||||
return fmt.Errorf("规划决策不合法: %w", validateErr)
|
||||
}
|
||||
|
||||
// 3.2 阶段二:流式推送 speak(同一 reader 继续读取)。
|
||||
var fullText strings.Builder
|
||||
if visible != "" {
|
||||
if emitErr := emitter.EmitAssistantText(planSpeakBlockID, planStageName, visible, firstChunk); emitErr != nil {
|
||||
return fmt.Errorf("规划文案推送失败: %w", emitErr)
|
||||
}
|
||||
fullText.WriteString(visible)
|
||||
firstChunk = false
|
||||
}
|
||||
for {
|
||||
chunk2, recvErr2 := reader.Recv()
|
||||
if recvErr2 == io.EOF {
|
||||
break
|
||||
}
|
||||
if recvErr2 != nil {
|
||||
log.Printf("[WARN] plan speak stream error chat=%s err=%v", flowState.ConversationID, recvErr2)
|
||||
break
|
||||
}
|
||||
if chunk2 == nil {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(chunk2.ReasoningContent) != "" {
|
||||
_ = emitter.EmitReasoningText(planSpeakBlockID, planStageName, chunk2.ReasoningContent, false)
|
||||
}
|
||||
if chunk2.Content != "" {
|
||||
if emitErr := emitter.EmitAssistantText(planSpeakBlockID, planStageName, chunk2.Content, firstChunk); emitErr != nil {
|
||||
return fmt.Errorf("规划文案推送失败: %w", emitErr)
|
||||
}
|
||||
fullText.WriteString(chunk2.Content)
|
||||
firstChunk = false
|
||||
}
|
||||
}
|
||||
decision.Speak = fullText.String()
|
||||
|
||||
// 4. 若有 speak 且不是 ask_user(ask_user 交给 interrupt 收口),写入历史。
|
||||
if strings.TrimSpace(decision.Speak) != "" && decision.Action != newagentmodel.PlanActionAskUser {
|
||||
msg := schema.AssistantMessage(decision.Speak, nil)
|
||||
conversationContext.AppendHistory(msg)
|
||||
persistVisibleAssistantMessage(ctx, input.PersistVisibleMessage, flowState, msg)
|
||||
}
|
||||
|
||||
// 5. 按规划动作推进流程状态。
|
||||
return handlePlanAction(ctx, input, runtimeState, conversationContext, emitter, flowState, decision)
|
||||
}
|
||||
|
||||
// 5. 按规划动作推进流程状态。
|
||||
// 流结束但未找到决策标签。
|
||||
return fmt.Errorf("规划阶段流结束但未提取到决策标签")
|
||||
}
|
||||
|
||||
// handlePlanAction 根据 PlanDecision.Action 推进流程状态。
|
||||
func handlePlanAction(
|
||||
ctx context.Context,
|
||||
input PlanNodeInput,
|
||||
runtimeState *newagentmodel.AgentRuntimeState,
|
||||
conversationContext *newagentmodel.ConversationContext,
|
||||
emitter *newagentstream.ChunkEmitter,
|
||||
flowState *newagentmodel.CommonState,
|
||||
decision *newagentmodel.PlanDecision,
|
||||
) error {
|
||||
switch decision.Action {
|
||||
case newagentmodel.PlanActionContinue:
|
||||
flowState.Phase = newagentmodel.PhasePlanning
|
||||
@@ -130,26 +213,16 @@ func RunPlanNode(ctx context.Context, input PlanNodeInput) error {
|
||||
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
|
||||
}
|
||||
}
|
||||
// always_execute 开启时,计划层跳过确认闸门,直接进入执行阶段。
|
||||
// 这样可以与 Execute 节点的"写工具跳过确认"语义保持一致。
|
||||
if input.AlwaysExecute {
|
||||
// 1. 自动执行模式不会经过 Confirm 卡片,因此这里先把完整计划明确展示给用户。
|
||||
// 2. 摘要格式复用 Confirm 节点,保证"手动确认"和"自动执行"两条链路文案一致。
|
||||
// 3. 推流后同步写入历史,确保后续 Execute 阶段的上下文也能看到这份计划。
|
||||
summary := strings.TrimSpace(buildPlanSummary(decision.PlanSteps))
|
||||
if summary != "" {
|
||||
msg := schema.AssistantMessage(summary, nil)
|
||||
@@ -177,9 +250,6 @@ func RunPlanNode(ctx context.Context, input PlanNodeInput) error {
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
// 1. LLM 输出了不支持的 action,不应直接报错终止,而应给它修正机会。
|
||||
// 2. 使用通用修正函数追加错误反馈,让 Graph 继续循环。
|
||||
// 3. LLM 下一轮会看到错误反馈并修正自己的输出。
|
||||
llmOutput := decision.Speak
|
||||
if strings.TrimSpace(llmOutput) == "" {
|
||||
llmOutput = decision.Reason
|
||||
|
||||
Reference in New Issue
Block a user