diff --git a/backend/newAgent/node/deliver.go b/backend/newAgent/node/deliver.go index f236c15..a361e27 100644 --- a/backend/newAgent/node/deliver.go +++ b/backend/newAgent/node/deliver.go @@ -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 生成“流程已终止”的统一交付文案。 diff --git a/backend/newAgent/node/execute.go b/backend/newAgent/node/execute.go index b135111..d5130d1 100644 --- a/backend/newAgent/node/execute.go +++ b/backend/newAgent/node/execute.go @@ -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 流中先提取 决策标签,再流式推送 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 = "你必须先输出 {JSON},然后在标签后输出正文。" } 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 格式不合法。", + "请确保 标签内是合法 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 格式的执行决策。", + "请重新输出 {JSON} 格式的执行决策。", ) 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, diff --git a/backend/newAgent/node/plan.go b/backend/newAgent/node/plan.go index abf9ee4..60573a6 100644 --- a/backend/newAgent/node/plan.go +++ b/backend/newAgent/node/plan.go @@ -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. 从流中提取 标签内的 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 流中先提取 决策标签,再流式推送 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 diff --git a/backend/newAgent/prompt/execute.go b/backend/newAgent/prompt/execute.go index ec8ea3d..8b6bcf2 100644 --- a/backend/newAgent/prompt/execute.go +++ b/backend/newAgent/prompt/execute.go @@ -36,7 +36,7 @@ const executeSystemPromptWithPlan = ` 14. web_search 拿到 summary 后通常已够用;仅当需要页面详细内容时才调用 web_fetch。 执行规则: -1. 只输出严格 JSON,不要输出 markdown,不要在 JSON 外补充文本。 +1. 输出格式:先输出一行 {JSON 决策},然后换行输出给用户看的自然语言正文。JSON 中不要包含 speak 字段——用户可见的话放在标签之后。 2. 读操作:action=continue + tool_call。 3. 写操作(日程变更,如 place/move/swap/batch_move/unplace/spread_even/min_context_switch):action=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. 输出格式:先输出一行 {JSON 决策},然后换行输出给用户看的自然语言正文。JSON 中不要包含 speak 字段——用户可见的话放在标签之后。 2. 读操作:action=continue + tool_call。 3. 写操作(日程变更,如 place/move/swap/batch_move/unplace/spread_even/min_context_switch):action=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;标签之后换行输出给用户看的自然语言正文。 +决策标签格式:{JSON} + +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 字段。给用户看的话放在 标签之后。 示例: -{ - "speak": "我先查看当前整体安排。", - "action": "%s", - "reason": "需要先调用 get_overview 获取事实", - "tool_call": { - "name": "get_overview", - "arguments": {} - } -} -{ - "speak": "当前步骤已完成。", - "action": "%s", - "reason": "已完成当前步骤所需查询与校验", - "goal_check": "已满足当前步骤 done_when 条件" -} +{"action":"%s","reason":"需要先调用 get_overview 获取事实","tool_call":{"name":"get_overview","arguments":{}}} +我先查看当前整体安排。 -{ - "speak": "", - "action": "%s", - "reason": "整体任务已完成" -} +{"action":"%s","reason":"已完成当前步骤所需查询与校验","goal_check":"已满足当前步骤 done_when 条件"} +当前步骤已完成。 + +{"action":"%s","reason":"整体任务已完成"} `, newagentmodel.ExecuteActionContinue, newagentmodel.ExecuteActionAskUser, @@ -151,41 +142,29 @@ func BuildExecuteDecisionContractText() string { // BuildExecuteReActContractText 返回自由执行模式输出协议。 func BuildExecuteReActContractText() string { return strings.TrimSpace(fmt.Sprintf(` -输出协议(严格 JSON): -- speak:给用户看的话 +输出协议(两阶段格式): + +先输出一行决策标签,标签内是 JSON;标签之后换行输出给用户看的自然语言正文。 +决策标签格式:{JSON} + +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 字段。给用户看的话放在 标签之后。 示例: -{ - "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} - } -} +{"action":"%s","reason":"先读取概览再决定微调方向","tool_call":{"name":"get_overview","arguments":{}}} +我先看一下现在的安排分布。 -{ - "speak": "已完成你的请求。", - "action": "%s", - "reason": "微调执行完毕并已校验结果", - "goal_check": "目标任务类已完成微调,且关键约束满足" -} +{"action":"%s","reason":"写操作需要确认","tool_call":{"name":"swap","arguments":{"task_a":1,"task_b":2}}} +我准备把两项任务对调位置,你确认后执行。 + +{"action":"%s","reason":"微调执行完毕并已校验结果","goal_check":"目标任务类已完成微调,且关键约束满足"} +已完成你的请求。 `, 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;标签之后换行输出给用户看的自然语言正文。 +决策标签格式:{JSON} + +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 字段。给用户看的话放在 标签之后。若 action=%s,标签后通常留空。 + 示例: -{ - "speak": "我先查看当前安排。", - "action": "%s", - "reason": "先读取事实再决策", - "tool_call": { - "name": "get_overview", - "arguments": {} - } -} -{ - "speak": "当前步骤完成。", - "action": "%s", - "reason": "步骤完成条件满足", - "goal_check": "已满足当前步骤 done_when" -} +{"action":"%s","reason":"先读取事实再决策","tool_call":{"name":"get_overview","arguments":{}}} +我先查看当前安排。 -{ - "speak": "", - "action": "%s", - "reason": "流程不应继续执行", - "abort": { - "code": "execute_abort", - "user_message": "当前流程无法继续执行,本轮先终止。", - "internal_reason": "execute declared abort" - } -} +{"action":"%s","reason":"步骤完成条件满足","goal_check":"已满足当前步骤 done_when"} +当前步骤完成。 + +{"action":"%s","reason":"流程不应继续执行","abort":{"code":"execute_abort","user_message":"当前流程无法继续执行,本轮先终止。","internal_reason":"execute declared abort"}} `, - 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;标签之后换行输出给用户看的自然语言正文。 +决策标签格式:{JSON} + +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 字段。给用户看的话放在 标签之后。若 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} - } -} +{"action":"%s","reason":"先获取事实再决策","tool_call":{"name":"get_overview","arguments":{}}} +我先读取当前安排。 -{ - "speak": "", - "action": "%s", - "reason": "当前流程不应继续执行", - "abort": { - "code": "domain_abort", - "user_message": "当前流程无法继续执行,本轮先终止。", - "internal_reason": "execute declared abort" - } -} +{"action":"%s","reason":"写操作需要确认","tool_call":{"name":"move","arguments":{"task_id":5,"new_day":3,"new_slot_start":1}}} +我准备执行写操作,等待你确认。 + +{"action":"%s","reason":"当前流程不应继续执行","abort":{"code":"domain_abort","user_message":"当前流程无法继续执行,本轮先终止。","internal_reason":"execute declared abort"}} `, - 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. 标签内只放 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 标签格式输出。 +输出格式:先输出 {JSON 决策},然后换行输出给用户看的正文。 补充格式要求: +- JSON 中不要包含 speak 字段,给用户看的话放在 标签之后 - 与当前 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 标签格式输出。 +输出格式:先输出 {JSON 决策},然后换行输出给用户看的正文。 `) } // BuildExecuteReActUserPrompt 构造自由执行模式的用户提示词。 func BuildExecuteReActUserPrompt(_ *newagentmodel.CommonState) string { return strings.TrimSpace(` -请继续当前任务的执行阶段,严格输出 JSON。 -输出字段: -- speak -- action -- reason -- goal_check -- tool_call -- abort +请继续当前任务的执行阶段,严格按 SMARTFLOW_DECISION 标签格式输出。 +输出格式:先输出 {JSON 决策},然后换行输出给用户看的正文。 `) } diff --git a/backend/newAgent/prompt/plan.go b/backend/newAgent/prompt/plan.go index 4d8a486..c53ab3e 100644 --- a/backend/newAgent/prompt/plan.go +++ b/backend/newAgent/prompt/plan.go @@ -18,7 +18,7 @@ const planSystemPrompt = ` 3. 若当前计划仍不完整,就继续围绕当前任务补全计划,不要跳去执行细节。 4. 若你认为计划已经完整可执行,请返回 action=plan_done,并附带完整 plan_steps。 5. plan_steps 必须使用自然语言,便于后端将完整 plan 重新注入到后续上下文顶部。 -6. 只输出 JSON,不要输出 markdown,不要输出额外解释,不要在 JSON 外再补文字。 +6. 输出格式:先输出一行 {JSON 决策},然后换行输出给用户看的自然语言正文。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;标签之后换行输出给用户看的自然语言正文。 +决策标签格式:{JSON} + +JSON 字段说明: - action:只能是 %s / %s / %s - reason:给后端和日志看的简短说明 - complexity:任务复杂度,只能是 simple / moderate / complex @@ -96,40 +100,19 @@ func BuildPlanDecisionContractText() string { - needs_rough_build:仅当满足粗排识别规则时为 true,否则省略;为 true 时后端自动运行粗排算法 - task_class_ids:needs_rough_build=true 时必填,从上下文"任务类 ID"字段读取 +注意:JSON 中不要包含 speak 字段。给用户看的话放在 标签之后。 + 合法示例: -{ - "speak": "我先把计划再收束一下。", - "action": "%s", - "reason": "当前信息已足够继续规划", - "complexity": "moderate", -} -{ - "speak": "你更希望我优先安排今天,还是按整周来规划?", - "action": "%s", - "reason": "当前时间范围仍不明确", - "complexity": "simple", -} +{"action":"%s","reason":"当前信息已足够继续规划","complexity":"moderate"} +我先把计划再收束一下。 -{ - "speak": "计划已经整理好了,我先给你确认一下。", - "action": "%s", - "reason": "当前计划已具备执行条件", - "complexity": "simple", - - "plan_steps": [ - { - "content": "先确认本周可用时间范围", - "done_when": "拿到明确的可用时间段列表" - }, - { - "content": "基于可用时间生成执行安排", - "done_when": "得到一份用户可确认的安排方案" - } - ] -} +{"action":"%s","reason":"当前时间范围仍不明确","complexity":"simple"} +你更希望我优先安排今天,还是按整周来规划? + +{"action":"%s","reason":"当前计划已具备执行条件","complexity":"simple","plan_steps":[{"content":"先确认本周可用时间范围","done_when":"拿到明确的可用时间段列表"},{"content":"基于可用时间生成执行安排","done_when":"得到一份用户可确认的安排方案"}]} +计划已经整理好了,我先给你确认一下。 `, - newagentmodel.PlanActionAskUser, newagentmodel.PlanActionContinue, newagentmodel.PlanActionAskUser, newagentmodel.PlanActionDone, diff --git a/backend/newAgent/router/decision_parser.go b/backend/newAgent/router/decision_parser.go new file mode 100644 index 0000000..a699342 --- /dev/null +++ b/backend/newAgent/router/decision_parser.go @@ -0,0 +1,201 @@ +package newagentrouter + +import ( + "fmt" + "regexp" + "strings" +) + +var ( + // decisionTagRegex 从模型流式输出中提取 ... 标签。 + // + // 格式示例: + // {"action":"continue","reason":"...","tool_call":{...}} + // 用户可见的友好文案在这里流式输出... + // + // 使用 (?s) dotall 模式使 . 匹配换行符(JSON 可能包含换行), + // 非贪婪 (.*?) 避免匹配到多个标签时过度消耗。 + decisionTagRegex = regexp.MustCompile( + `(?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 流式输出中增量提取 标签内的 JSON。 +// +// 协议约定:模型先输出 {json},然后输出用户可见正文。 +// 调用方在 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 "" +}