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:
LoveLosita
2026-04-23 16:28:45 +08:00
parent 3c2f3c0b71
commit 7b37db64eb
6 changed files with 556 additions and 277 deletions

View File

@@ -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完整总结文本用于历史写入
// - streamedtrue 表示文本已通过 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 生成“流程已终止”的统一交付文案。

View File

@@ -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,

View File

@@ -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_userask_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_userask_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

View File

@@ -36,7 +36,7 @@ const executeSystemPromptWithPlan = `
14. web_search 拿到 summary 后通常已够用;仅当需要页面详细内容时才调用 web_fetch。
执行规则:
1. 输出严格 JSON不要输出 markdown不要在 JSON 外补充文本
1. 输出格式:先输出一行 <SMARTFLOW_DECISION>{JSON 决策}</SMARTFLOW_DECISION>然后换行输出给用户看的自然语言正文。JSON 中不要包含 speak 字段——用户可见的话放在标签之后
2. 读操作action=continue + tool_call。
3. 写操作(日程变更,如 place/move/swap/batch_move/unplace/spread_even/min_context_switchaction=confirm + tool_call。
4. quick_note_create记录任务/提醒若信息足够action=continue + tool_call并显式填写 priority_group若信息不足且无法可靠推断action=ask_user 先追问。quick_note_create 调用时和调用后 speak 必须留空,收口由 deliver 阶段统一完成调用成功后可继续done/next_plan/continue处理其他任务但不要为 quick_note_create 本身补充说明。
@@ -79,7 +79,7 @@ const executeSystemPromptReAct = `
13. web_search 拿到 summary 后通常已够用;仅当需要页面详细内容时才调用 web_fetch。
执行规则:
1. 输出严格 JSON不要输出 markdown不要在 JSON 外补充文本
1. 输出格式:先输出一行 <SMARTFLOW_DECISION>{JSON 决策}</SMARTFLOW_DECISION>然后换行输出给用户看的自然语言正文。JSON 中不要包含 speak 字段——用户可见的话放在标签之后
2. 读操作action=continue + tool_call。
3. 写操作(日程变更,如 place/move/swap/batch_move/unplace/spread_even/min_context_switchaction=confirm + tool_call。
4. quick_note_create记录任务/提醒若信息足够action=continue + tool_call并显式填写 priority_group若信息不足且无法可靠推断action=ask_user 先追问。quick_note_create 调用时和调用后 speak 必须留空,收口由 deliver 阶段统一完成调用成功后可继续done/next_plan/continue处理其他任务但不要为 quick_note_create 本身补充说明。
@@ -101,37 +101,28 @@ func BuildExecuteReActSystemPrompt() string {
// BuildExecuteDecisionContractText 返回执行阶段输出协议(有 plan 模式)。
func BuildExecuteDecisionContractText() string {
return strings.TrimSpace(fmt.Sprintf(`
输出协议(严格 JSON
- speak给用户看的话
输出协议(两阶段格式
先输出一行决策标签,标签内是 JSON标签之后换行输出给用户看的自然语言正文。
决策标签格式:<SMARTFLOW_DECISION>{JSON}</SMARTFLOW_DECISION>
JSON 字段说明:
- action只能是 %s / %s / %s / %s / %s
- reason给后端和日志看的简短说明
- goal_check输出 %s 或 %s 时必填,对照 done_when 逐条验证
- tool_call输出 %s写操作需 confirm或 %s读操作时可附带
- tool_call 格式:{"name":"工具名","arguments":{...}}
- tool_call输出 %s写操作需 confirm或 %s读操作时可附带,格式 {"name":"工具名","arguments":{...}}
注意JSON 中不要包含 speak 字段。给用户看的话放在 </SMARTFLOW_DECISION> 标签之后。
示例:
{
"speak": "我先查看当前整体安排。",
"action": "%s",
"reason": "需要先调用 get_overview 获取事实",
"tool_call": {
"name": "get_overview",
"arguments": {}
}
}
{
"speak": "当前步骤已完成。",
"action": "%s",
"reason": "已完成当前步骤所需查询与校验",
"goal_check": "已满足当前步骤 done_when 条件"
}
<SMARTFLOW_DECISION>{"action":"%s","reason":"需要先调用 get_overview 获取事实","tool_call":{"name":"get_overview","arguments":{}}}</SMARTFLOW_DECISION>
我先查看当前整体安排。
{
"speak": "",
"action": "%s",
"reason": "整体任务已完成"
}
<SMARTFLOW_DECISION>{"action":"%s","reason":"已完成当前步骤所需查询与校验","goal_check":"已满足当前步骤 done_when 条件"}</SMARTFLOW_DECISION>
当前步骤已完成。
<SMARTFLOW_DECISION>{"action":"%s","reason":"整体任务已完成"}</SMARTFLOW_DECISION>
`,
newagentmodel.ExecuteActionContinue,
newagentmodel.ExecuteActionAskUser,
@@ -151,41 +142,29 @@ func BuildExecuteDecisionContractText() string {
// BuildExecuteReActContractText 返回自由执行模式输出协议。
func BuildExecuteReActContractText() string {
return strings.TrimSpace(fmt.Sprintf(`
输出协议(严格 JSON
- speak给用户看的话
输出协议(两阶段格式
先输出一行决策标签,标签内是 JSON标签之后换行输出给用户看的自然语言正文。
决策标签格式:<SMARTFLOW_DECISION>{JSON}</SMARTFLOW_DECISION>
JSON 字段说明:
- action只能是 %s / %s / %s / %s
- reason给后端和日志看的简短说明
- goal_check输出 %s 时必填,总结任务完成证据
- tool_call输出 %s写操作需 confirm或 %s读操作时可附带
- tool_call 格式:{"name":"工具名","arguments":{...}}
- tool_call输出 %s写操作需 confirm或 %s读操作时可附带,格式 {"name":"工具名","arguments":{...}}
注意JSON 中不要包含 speak 字段。给用户看的话放在 </SMARTFLOW_DECISION> 标签之后。
示例:
{
"speak": "我先看一下现在的安排分布。",
"action": "%s",
"reason": "先读取概览再决定微调方向",
"tool_call": {
"name": "get_overview",
"arguments": {}
}
}
{
"speak": "我准备把两项任务对调位置,你确认后执行。",
"action": "%s",
"reason": "写操作需要确认",
"tool_call": {
"name": "swap",
"arguments": {"task_a": 1, "task_b": 2}
}
}
<SMARTFLOW_DECISION>{"action":"%s","reason":"先读取概览再决定微调方向","tool_call":{"name":"get_overview","arguments":{}}}</SMARTFLOW_DECISION>
我先看一下现在的安排分布。
{
"speak": "已完成你的请求。",
"action": "%s",
"reason": "微调执行完毕并已校验结果",
"goal_check": "目标任务类已完成微调,且关键约束满足"
}
<SMARTFLOW_DECISION>{"action":"%s","reason":"写操作需要确认","tool_call":{"name":"swap","arguments":{"task_a":1,"task_b":2}}}</SMARTFLOW_DECISION>
我准备把两项任务对调位置,你确认后执行。
<SMARTFLOW_DECISION>{"action":"%s","reason":"微调执行完毕并已校验结果","goal_check":"目标任务类已完成微调,且关键约束满足"}</SMARTFLOW_DECISION>
已完成你的请求。
`,
newagentmodel.ExecuteActionContinue,
newagentmodel.ExecuteActionAskUser,
@@ -203,45 +182,31 @@ func BuildExecuteReActContractText() string {
// BuildExecuteDecisionContractTextV2 返回补齐 abort 协议后的执行输出契约(有 plan 模式)。
func BuildExecuteDecisionContractTextV2() string {
return strings.TrimSpace(fmt.Sprintf(`
输出协议(严格 JSON
- speak给用户看的话若 action=%s通常留空
输出协议(两阶段格式
先输出一行决策标签,标签内是 JSON标签之后换行输出给用户看的自然语言正文。
决策标签格式:<SMARTFLOW_DECISION>{JSON}</SMARTFLOW_DECISION>
JSON 字段说明:
- action只能是 %s / %s / %s / %s / %s / %s
- reason给后端和日志看的简短说明
- goal_check输出 %s 或 %s 时必填,对照 done_when 逐条验证
- tool_call输出 %s写操作需 confirm或 %s读操作时可附带
- tool_call输出 %s写操作需 confirm或 %s读操作时可附带,格式 {"name":"工具名","arguments":{...}}
- abort仅在 action=%s 时必填,格式为 {"code":"...","user_message":"...","internal_reason":"..."}
- tool_call 与 abort 互斥,禁止同时出现
注意JSON 中不要包含 speak 字段。给用户看的话放在 </SMARTFLOW_DECISION> 标签之后。若 action=%s标签后通常留空。
示例:
{
"speak": "我先查看当前安排。",
"action": "%s",
"reason": "先读取事实再决策",
"tool_call": {
"name": "get_overview",
"arguments": {}
}
}
{
"speak": "当前步骤完成。",
"action": "%s",
"reason": "步骤完成条件满足",
"goal_check": "已满足当前步骤 done_when"
}
<SMARTFLOW_DECISION>{"action":"%s","reason":"先读取事实再决策","tool_call":{"name":"get_overview","arguments":{}}}</SMARTFLOW_DECISION>
我先查看当前安排。
{
"speak": "",
"action": "%s",
"reason": "流程不应继续执行",
"abort": {
"code": "execute_abort",
"user_message": "当前流程无法继续执行,本轮先终止。",
"internal_reason": "execute declared abort"
}
}
<SMARTFLOW_DECISION>{"action":"%s","reason":"步骤完成条件满足","goal_check":"已满足当前步骤 done_when"}</SMARTFLOW_DECISION>
当前步骤完成。
<SMARTFLOW_DECISION>{"action":"%s","reason":"流程不应继续执行","abort":{"code":"execute_abort","user_message":"当前流程无法继续执行,本轮先终止。","internal_reason":"execute declared abort"}}</SMARTFLOW_DECISION>
`,
newagentmodel.ExecuteActionAbort,
newagentmodel.ExecuteActionContinue,
newagentmodel.ExecuteActionAskUser,
newagentmodel.ExecuteActionConfirm,
@@ -253,6 +218,7 @@ func BuildExecuteDecisionContractTextV2() string {
newagentmodel.ExecuteActionConfirm,
newagentmodel.ExecuteActionContinue,
newagentmodel.ExecuteActionAbort,
newagentmodel.ExecuteActionAbort,
newagentmodel.ExecuteActionContinue,
newagentmodel.ExecuteActionNextPlan,
newagentmodel.ExecuteActionAbort,
@@ -262,48 +228,31 @@ func BuildExecuteDecisionContractTextV2() string {
// BuildExecuteReActContractTextV2 返回补齐 abort 协议后的自由执行输出契约。
func BuildExecuteReActContractTextV2() string {
return strings.TrimSpace(fmt.Sprintf(`
输出协议(严格 JSON
- speak给用户看的话若 action=%s通常留空
输出协议(两阶段格式
先输出一行决策标签,标签内是 JSON标签之后换行输出给用户看的自然语言正文。
决策标签格式:<SMARTFLOW_DECISION>{JSON}</SMARTFLOW_DECISION>
JSON 字段说明:
- action只能是 %s / %s / %s / %s / %s
- reason给后端和日志看的简短说明
- goal_check输出 %s 时必填,总结任务完成证据
- tool_call输出 %s写操作需 confirm或 %s读操作时可附带
- tool_call输出 %s写操作需 confirm或 %s读操作时可附带,格式 {"name":"工具名","arguments":{...}}
- abort仅在 action=%s 时必填,格式为 {"code":"...","user_message":"...","internal_reason":"..."}
- tool_call 与 abort 互斥,禁止同时出现
注意JSON 中不要包含 speak 字段。给用户看的话放在 </SMARTFLOW_DECISION> 标签之后。若 action=%s标签后通常留空。
示例:
{
"speak": "我先读取当前安排。",
"action": "%s",
"reason": "先获取事实再决策",
"tool_call": {
"name": "get_overview",
"arguments": {}
}
}
{
"speak": "我准备执行写操作,等待你确认。",
"action": "%s",
"reason": "写操作需要确认",
"tool_call": {
"name": "move",
"arguments": {"task_id": 5, "new_day": 3, "new_slot_start": 1}
}
}
<SMARTFLOW_DECISION>{"action":"%s","reason":"先获取事实再决策","tool_call":{"name":"get_overview","arguments":{}}}</SMARTFLOW_DECISION>
我先读取当前安排。
{
"speak": "",
"action": "%s",
"reason": "当前流程不应继续执行",
"abort": {
"code": "domain_abort",
"user_message": "当前流程无法继续执行,本轮先终止。",
"internal_reason": "execute declared abort"
}
}
<SMARTFLOW_DECISION>{"action":"%s","reason":"写操作需要确认","tool_call":{"name":"move","arguments":{"task_id":5,"new_day":3,"new_slot_start":1}}}</SMARTFLOW_DECISION>
我准备执行写操作,等待你确认。
<SMARTFLOW_DECISION>{"action":"%s","reason":"当前流程不应继续执行","abort":{"code":"domain_abort","user_message":"当前流程无法继续执行,本轮先终止。","internal_reason":"execute declared abort"}}</SMARTFLOW_DECISION>
`,
newagentmodel.ExecuteActionAbort,
newagentmodel.ExecuteActionContinue,
newagentmodel.ExecuteActionAskUser,
newagentmodel.ExecuteActionConfirm,
@@ -313,6 +262,7 @@ func BuildExecuteReActContractTextV2() string {
newagentmodel.ExecuteActionConfirm,
newagentmodel.ExecuteActionContinue,
newagentmodel.ExecuteActionAbort,
newagentmodel.ExecuteActionAbort,
newagentmodel.ExecuteActionContinue,
newagentmodel.ExecuteActionConfirm,
newagentmodel.ExecuteActionAbort,
@@ -391,7 +341,8 @@ func buildExecutePromptWithFormatGuard(base string) string {
2. 若输出 tool_call参数字段名只能是 arguments禁止写成 parameters。
3. tool_call 只能是单个对象:{"name":"工具名","arguments":{...}},不能输出数组。
4. 只有 action=abort 时才允许输出 abort 字段;非 abort 动作不要输出 abort。
5. action=continue / ask_user / confirm 时,speak 必须是非空自然语言。`)
5. action=continue / ask_user / confirm 时,标签后的正文必须是非空自然语言。
6. <SMARTFLOW_DECISION> 标签内只放 JSON不要放自然语言。`)
if base == "" {
return guard
}
@@ -401,21 +352,16 @@ func buildExecutePromptWithFormatGuard(base string) string {
// buildExecuteStrictJSONUserPrompt 统一构造 execute 阶段面向模型的最终用户指令。
func buildExecuteStrictJSONUserPrompt() string {
return strings.TrimSpace(`
请继续当前任务的执行阶段,严格输出 JSON
输出字段:
- speak
- action
- reason
- goal_check
- tool_call
- abort
请继续当前任务的执行阶段,严格按 SMARTFLOW_DECISION 标签格式输出。
输出格式:先输出 <SMARTFLOW_DECISION>{JSON 决策}</SMARTFLOW_DECISION>,然后换行输出给用户看的正文。
补充格式要求:
- JSON 中不要包含 speak 字段,给用户看的话放在 </SMARTFLOW_DECISION> 标签之后
- 与当前 action 无关的字段直接省略,不要输出空字符串、空对象、空数组或 null 占位
- tool_call 只能写 {"name":"工具名","arguments":{...}},且每轮最多一个
- 不要写 {"tool_call":{"name":"工具名","parameters":{...}}}
- 非 abort 动作不要输出 abort 字段
- action 为 continue / ask_user / confirm 时,必须输出非空 speak
- action 为 continue / ask_user / confirm 时,标签后必须输出非空正文
- 若读工具结果与已知事实明显冲突,先修正参数并重查一次,再决定是否 ask_user
- 不要连续两轮调用"同一读工具 + 等价 arguments";若上一轮已成功返回,下一轮必须换工具或进入 confirm
- 若用户本轮给了二次微调方向,优先满足该方向,再考虑通用均衡优化
@@ -431,27 +377,15 @@ func buildExecuteStrictJSONUserPrompt() string {
// BuildExecuteUserPrompt 构造有 plan 模式的用户提示词。
func BuildExecuteUserPrompt(_ *newagentmodel.CommonState) string {
return strings.TrimSpace(`
请继续当前任务的执行阶段,严格输出 JSON
输出字段:
- speak
- action
- reason
- goal_check
- tool_call
- abort
请继续当前任务的执行阶段,严格按 SMARTFLOW_DECISION 标签格式输出。
输出格式:先输出 <SMARTFLOW_DECISION>{JSON 决策}</SMARTFLOW_DECISION>,然后换行输出给用户看的正文。
`)
}
// BuildExecuteReActUserPrompt 构造自由执行模式的用户提示词。
func BuildExecuteReActUserPrompt(_ *newagentmodel.CommonState) string {
return strings.TrimSpace(`
请继续当前任务的执行阶段,严格输出 JSON
输出字段:
- speak
- action
- reason
- goal_check
- tool_call
- abort
请继续当前任务的执行阶段,严格按 SMARTFLOW_DECISION 标签格式输出。
输出格式:先输出 <SMARTFLOW_DECISION>{JSON 决策}</SMARTFLOW_DECISION>,然后换行输出给用户看的正文。
`)
}

View File

@@ -18,7 +18,7 @@ const planSystemPrompt = `
3. 若当前计划仍不完整,就继续围绕当前任务补全计划,不要跳去执行细节。
4. 若你认为计划已经完整可执行,请返回 action=plan_done并附带完整 plan_steps。
5. plan_steps 必须使用自然语言,便于后端将完整 plan 重新注入到后续上下文顶部。
6. 只输出 JSON不要输出 markdown不要输出额外解释不要在 JSON 外再补文字
6. 输出格式:先输出一行 <SMARTFLOW_DECISION>{JSON 决策}</SMARTFLOW_DECISION>然后换行输出给用户看的自然语言正文。JSON 中不要包含 speak 字段——用户可见的话放在标签之后
7. 每次输出前先评估任务复杂度simple简单明确无复杂依赖、moderate多步操作需要一定推理、complex需要深度推理、多方案比较或复杂依赖关系
8. 粗排识别规则:若满足以下两个条件,在 action=plan_done 时附加 needs_rough_build=true 和 task_class_ids
条件1用户输入中存在"任务类 ID"字段(见上下文"任务类 ID"部分);
@@ -68,7 +68,7 @@ func BuildPlanMessages(state *newagentmodel.CommonState, ctx *newagentmodel.Conv
func BuildPlanUserPrompt(state *newagentmodel.CommonState, userInput string) string {
var sb strings.Builder
sb.WriteString("请继续当前任务的规划阶段,严格输出 JSON。\n")
sb.WriteString("请继续当前任务的规划阶段,严格按 SMARTFLOW_DECISION 标签格式输出。\n")
sb.WriteString("目标:围绕最近对话和规划工作区信息,产出一份稳定、可执行的自然语言计划;若关键信息不足,请明确 ask_user。\n\n")
sb.WriteString(BuildPlanDecisionContractText())
@@ -85,8 +85,12 @@ func BuildPlanUserPrompt(state *newagentmodel.CommonState, userInput string) str
// BuildPlanDecisionContractText 返回规划阶段的输出协议说明。
func BuildPlanDecisionContractText() string {
return strings.TrimSpace(fmt.Sprintf(`
输出协议(严格 JSON
- speak给用户看的话若 action=%s这里通常就是要追问用户的问题
输出协议(两阶段格式
先输出一行决策标签,标签内是 JSON标签之后换行输出给用户看的自然语言正文。
决策标签格式:<SMARTFLOW_DECISION>{JSON}</SMARTFLOW_DECISION>
JSON 字段说明:
- action只能是 %s / %s / %s
- reason给后端和日志看的简短说明
- complexity任务复杂度只能是 simple / moderate / complex
@@ -96,40 +100,19 @@ func BuildPlanDecisionContractText() string {
- needs_rough_build仅当满足粗排识别规则时为 true否则省略为 true 时后端自动运行粗排算法
- task_class_idsneeds_rough_build=true 时必填,从上下文"任务类 ID"字段读取
注意JSON 中不要包含 speak 字段。给用户看的话放在 </SMARTFLOW_DECISION> 标签之后。
合法示例:
{
"speak": "我先把计划再收束一下。",
"action": "%s",
"reason": "当前信息已足够继续规划",
"complexity": "moderate",
}
{
"speak": "你更希望我优先安排今天,还是按整周来规划?",
"action": "%s",
"reason": "当前时间范围仍不明确",
"complexity": "simple",
}
<SMARTFLOW_DECISION>{"action":"%s","reason":"当前信息已足够继续规划","complexity":"moderate"}</SMARTFLOW_DECISION>
我先把计划再收束一下。
{
"speak": "计划已经整理好了,我先给你确认一下。",
"action": "%s",
"reason": "当前计划已具备执行条件",
"complexity": "simple",
"plan_steps": [
{
"content": "先确认本周可用时间范围",
"done_when": "拿到明确的可用时间段列表"
},
{
"content": "基于可用时间生成执行安排",
"done_when": "得到一份用户可确认的安排方案"
}
]
}
<SMARTFLOW_DECISION>{"action":"%s","reason":"当前时间范围仍不明确","complexity":"simple"}</SMARTFLOW_DECISION>
你更希望我优先安排今天,还是按整周来规划?
<SMARTFLOW_DECISION>{"action":"%s","reason":"当前计划已具备执行条件","complexity":"simple","plan_steps":[{"content":"先确认本周可用时间范围","done_when":"拿到明确的可用时间段列表"},{"content":"基于可用时间生成执行安排","done_when":"得到一份用户可确认的安排方案"}]}</SMARTFLOW_DECISION>
计划已经整理好了,我先给你确认一下。
`,
newagentmodel.PlanActionAskUser,
newagentmodel.PlanActionContinue,
newagentmodel.PlanActionAskUser,
newagentmodel.PlanActionDone,

View File

@@ -0,0 +1,201 @@
package newagentrouter
import (
"fmt"
"regexp"
"strings"
)
var (
// decisionTagRegex 从模型流式输出中提取 <SMARTFLOW_DECISION>...</SMARTFLOW_DECISION> 标签。
//
// 格式示例:
// <SMARTFLOW_DECISION>{"action":"continue","reason":"...","tool_call":{...}}</SMARTFLOW_DECISION>
// 用户可见的友好文案在这里流式输出...
//
// 使用 (?s) dotall 模式使 . 匹配换行符JSON 可能包含换行),
// 非贪婪 (.*?) 避免匹配到多个标签时过度消耗。
decisionTagRegex = regexp.MustCompile(
`(?s)<\s*SMARTFLOW_DECISION\s*>(.*?)</\s*SMARTFLOW_DECISION\s*>`)
)
// StreamDecisionResult 描述解析器的最终输出状态。
type StreamDecisionResult struct {
// DecisionJSON 是标签内提取的完整 JSON 字符串。
// 调用方应使用 infrallm.ParseJSONObject[T] 将其解析为具体决策类型。
DecisionJSON string
// Fallback=true 表示流中未找到决策标签(超过 500 字符阈值),
// RawBuffer 包含全部累积文本,调用方应走 correction 路径。
Fallback bool
// ParseFailed=true 表示找到了标签但内部 JSON 为空或括号计数提取失败,
// RawBuffer 包含全部累积文本,调用方应走 correction 路径。
ParseFailed bool
// RawBuffer 是流式累积的原始文本,用于 correction / 日志。
RawBuffer string
}
// StreamDecisionParser 从 LLM 流式输出中增量提取 <SMARTFLOW_DECISION> 标签内的 JSON。
//
// 协议约定:模型先输出 <SMARTFLOW_DECISION>{json}</SMARTFLOW_DECISION>,然后输出用户可见正文。
// 调用方在 ready=true 后通过 DecisionJSON() 获取 JSON 字符串并自行解析,
// 同一个 StreamReader 继续读取标签后的正文逐 token 推流。
//
// 职责边界:
// 1. 只负责从流式 chunk 中提取决策标签和 JSON 字符串;
// 2. 不负责 JSON 反序列化(由调用方用 ParseJSONObject 完成);
// 3. 不负责推送 SSE chunk。
type StreamDecisionParser struct {
buf strings.Builder
decisionFound bool
decisionJSON string
rawBuf string // 用于 fallback/correction
}
// NewStreamDecisionParser 创建决策标签流式解析器。
func NewStreamDecisionParser() *StreamDecisionParser {
return &StreamDecisionParser{}
}
// Feed 写入一段 chunk content。
//
// 返回值:
// - visible决策标签之后的文本用户可见内容可能为空
// - ready决策是否已提取完毕成功或 fallback
// - err非 nil 时表示 fallback 或解析失败。
//
// 调用方应在 ready=true 后:
// 1. 调用 Result() 获取解析结果;
// 2. 若 Fallback/ParseFailed 则走 correction 路径;
// 3. 否则用 DecisionJSON 解析为具体决策类型;
// 4. 继续读取同一个 reader逐 token 推流 visible 及后续 chunk。
func (p *StreamDecisionParser) Feed(content string) (visible string, ready bool, err error) {
if p.decisionFound {
return content, true, nil
}
p.buf.WriteString(content)
text := p.buf.String()
match := decisionTagRegex.FindStringSubmatchIndex(text)
if match == nil {
// 标签尚未完整,检查 fallback 阈值。
if len(text) > 500 {
p.decisionFound = true
p.rawBuf = text
return text, true, fmt.Errorf("决策标签解析超时,未找到 SMARTFLOW_DECISION 标签")
}
return "", false, nil
}
// 提取标签内文本(子组 1
groups := decisionTagRegex.FindStringSubmatch(text)
if len(groups) < 2 {
p.decisionFound = true
p.rawBuf = text
return "", true, fmt.Errorf("决策标签正则子组不足")
}
inner := groups[1]
jsonStr := extractJSONFromTag(inner)
if jsonStr == "" {
p.decisionFound = true
p.rawBuf = text
return "", true, fmt.Errorf("决策标签内未找到有效 JSON")
}
p.decisionFound = true
p.decisionJSON = jsonStr
p.rawBuf = text
// 提取标签之后的文本作为 visible。
fullMatch := groups[0]
tagEndIdx := strings.Index(text, fullMatch)
if tagEndIdx >= 0 {
afterTag := text[tagEndIdx+len(fullMatch):]
afterTag = strings.TrimPrefix(afterTag, "\r\n")
afterTag = strings.TrimPrefix(afterTag, "\n")
return afterTag, true, nil
}
return "", true, nil
}
// Ready 返回决策是否已提取完毕。
func (p *StreamDecisionParser) Ready() bool {
return p.decisionFound
}
// DecisionJSON 返回标签内提取的 JSON 字符串。
// 仅在 Ready()=true 且 Result().Fallback=false && Result().ParseFailed=false 时有效。
func (p *StreamDecisionParser) DecisionJSON() string {
return p.decisionJSON
}
// Result 返回完整解析结果,包含 fallback/parseFailed 状态和原始缓冲。
func (p *StreamDecisionParser) Result() *StreamDecisionResult {
r := &StreamDecisionResult{
DecisionJSON: p.decisionJSON,
RawBuffer: p.rawBuf,
}
if p.rawBuf != "" && p.decisionJSON == "" {
// 没有提取到 JSON判断是 fallback 还是 parseFailed。
// fallback = buf 里根本没有标签parseFailed = 有标签但 JSON 提取失败。
if decisionTagRegex.FindStringSubmatchIndex(p.rawBuf) != nil {
r.ParseFailed = true
} else {
r.Fallback = true
}
}
return r
}
// extractJSONFromTag 从标签内文本中提取第一个完整 JSON 对象。
// 复用括号计数逻辑,与 infrallm.ExtractJSONObject 一致。
func extractJSONFromTag(text string) string {
clean := strings.TrimSpace(text)
if clean == "" {
return ""
}
start := strings.Index(clean, "{")
if start < 0 {
return ""
}
depth := 0
inString := false
escaped := false
for idx := start; idx < len(clean); idx++ {
ch := clean[idx]
if escaped {
escaped = false
continue
}
if ch == '\\' && inString {
escaped = true
continue
}
if ch == '"' {
inString = !inString
continue
}
if inString {
continue
}
switch ch {
case '{':
depth++
case '}':
depth--
if depth == 0 {
return clean[start : idx+1]
}
}
}
return ""
}