Files
smartmate/backend/newAgent/node/execute.go
LoveLosita 32bb740b75 Version: 0.9.3.dev.260407
后端:
    1.Execute 上下文修复(无限循环 / 重复确认根治)
      - 更新node/execute.go:speak 写入历史(修复旧 TODO);confirm 动作 speak 不再丢失;
        continue 无工具调用时写 reason 保证上下文推进;区分 tool_call 数组/JSON损坏两种
        correction hint;goal_check hint 区分 plan/ReAct 模式
      - 更新node/execute.go:新增 AlwaysExecute 字段,extra.always_execute=true 时写工具
        跳过确认闸门直接执行并持久化
      - 更新model/graph_run_state.go:AgentGraphRequest 新增 AlwaysExecute;新增
        WriteSchedulePreviewFunc 类型和 WriteSchedulePreview Dep
      - 更新service/agentsvc/agent.go:新增 readAgentExtraBool 辅助

    2.粗排全链路修复
      - 更新service/agentsvc/agent_newagent.go:makeRoughBuildFunc 改用 HybridScheduleEntry
        而非 TaskClassItem.EmbeddedTime,普通时段放置不再被丢弃
      - 更新conv/schedule_provider.go:LoadScheduleState 从 task class 日期范围推算多周
        规划窗口,不再硬编码当前周 7 天;DayMapping 覆盖全部相关周,粗排跨周结果不再
        被 WeekDayToDay 静默丢弃
      - 更新node/rough_build.go:pinned block 区分有/无未覆盖 pending 任务两种情况,
        有 pending 时明确操作顺序(find_free→place)和完成判定,防止 LLM 重复调
        list_tasks;新增 countPendingTasks 辅助(只统计 Slots 为空的真正未覆盖任务)
      - 更新model/common_state.go:新增 StartDirectExecute(),Chat 直接路由 execute 时
        清空旧 PlanSteps,修复跨会话 HasPlan() 误判导致 ReAct 走 plan 模式的 bug
      - 更新node/chat.go:handleRouteExecute 改用 StartDirectExecute()

    3.排程预览缓存迁移至 Deliver 节点
      - 更新node/agent_nodes.go:Deliver 节点完成后调用 WriteSchedulePreview,只有任务
        真正完成才写预览缓存,中断路径不写中间态
      - 更新service/agentsvc/agent_newagent.go:注入 makeWriteSchedulePreviewFunc;移除
        graph 结束后的内联写入;makeRoughBuildFunc 注释修正
      - 更新conv/schedule_preview.go:ScheduleStateToPreview 补设 GeneratedAt
      - 更新model/agent.go:GetSchedulePlanPreviewResponse 新增 HybridEntries 字段
      - 更新service/agentsvc/agent_schedule_preview.go:GET handler Redis/MySQL 两条路径
        均透传 HybridEntries

    4.Execute thinking 模式修复
      - 更新newAgent/llm/ark_adapter.go:thinking 开启时强制 temperature=1,MaxTokens 自
        动托底至 16000,调用方与适配层行为对齐
      - 更新node/execute.go:调用参数同步改为 temperature=1.0 / MaxTokens=16000

    undo:
    1.流式推送换行未修复(undo)
    2.上下文依然待审视

前端:无
仓库:无
2026-04-07 12:10:56 +08:00

674 lines
25 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package newagentnode
import (
"context"
"encoding/json"
"fmt"
"log"
"regexp"
"strings"
"time"
newagentllm "github.com/LoveLosita/smartflow/backend/newAgent/llm"
newagentmodel "github.com/LoveLosita/smartflow/backend/newAgent/model"
newagentprompt "github.com/LoveLosita/smartflow/backend/newAgent/prompt"
newagentstream "github.com/LoveLosita/smartflow/backend/newAgent/stream"
newagenttools "github.com/LoveLosita/smartflow/backend/newAgent/tools"
"github.com/cloudwego/eino/schema"
"github.com/google/uuid"
)
const (
executeStageName = "execute"
executeStatusBlockID = "execute.status"
executeSpeakBlockID = "execute.speak"
executePinnedKey = "execution_context"
// maxConsecutiveCorrections 是 Execute 节点连续修正次数上限。
// 超过此阈值后终止执行,防止 LLM 陷入无限修正循环。
// 适用场景JSON 解析失败、决策不合法、goal_check 为空、工具名不存在。
maxConsecutiveCorrections = 3
)
// ExecuteNodeInput 描述执行节点单轮运行所需的最小依赖。
//
// 职责边界:
// 1. 只承载"本轮执行"需要的输入,不负责持久化;
// 2. RuntimeState 提供 plan 步骤与轮次预算;
// 3. ConversationContext 提供历史对话与置顶上下文;
// 4. ToolRegistry 提供工具注册表;
// 5. ScheduleState 提供工具操作的内存数据源(可为 nil由调用方按需加载
// 6. SchedulePersistor 用于写工具执行后持久化变更;
// 7. OriginalScheduleState 是首次加载时的原始快照,用于 diff。
type ExecuteNodeInput struct {
RuntimeState *newagentmodel.AgentRuntimeState
ConversationContext *newagentmodel.ConversationContext
UserInput string
Client *newagentllm.Client
ChunkEmitter *newagentstream.ChunkEmitter
ResumeNode string
ToolRegistry *newagenttools.ToolRegistry
ScheduleState *newagenttools.ScheduleState
SchedulePersistor newagentmodel.SchedulePersistor
OriginalScheduleState *newagenttools.ScheduleState
AlwaysExecute bool // true 时写工具跳过确认闸门直接执行
}
// ExecuteRoundObservation 记录执行阶段每轮的关键观察。
//
// 设计说明:
// 1. 参考 coding agent 模式,后端只记录事实,不做语义校验;
// 2. ToolResult 存储工具调用的原始返回,供 LLM 下一轮决策;
// 3. 该结构后续可扩展用于调试、回放、审计。
type ExecuteRoundObservation struct {
Round int `json:"round"`
StepIndex int `json:"step_index"`
GoalCheck string `json:"goal_check,omitempty"`
Decision string `json:"decision,omitempty"`
ToolName string `json:"tool_name,omitempty"`
ToolParams string `json:"tool_params,omitempty"`
ToolSuccess bool `json:"tool_success"`
ToolResult string `json:"tool_result,omitempty"`
}
// RunExecuteNode 执行一轮执行节点逻辑。
//
// 核心设计原则:
// 1. LLM 主导LLM 自己判断 done_when 是否满足,自己决定何时推进/完成;
// 2. 后端兜底:只做资源控制(轮次预算)、安全兜底(防无限循环)、证据记录;
// 3. 不做硬校验:后端不质疑 LLM 的 advance/complete 决策,信任 LLM 判断。
//
// 步骤说明:
// 1. 校验最小依赖,推送"正在执行"状态,避免用户空等;
// 2. 检查当前是否有可执行的 plan 步骤,无计划则报错;
// 3. 构造执行阶段 prompt调用 LLM 获取决策;
// 4. 若 LLM 先对用户说话,则伪流式推送并写回历史;
// 5. 按 LLM 决策执行动作:
// 5.1 call_tool执行工具调用记录证据推进轮次
// 5.2 ask_user打开追问交互等待用户回复
// 5.3 advanceLLM 判定当前步骤完成,推进到下一步;
// 5.4 completeLLM 判定整个任务完成,进入交付阶段;
// 6. 安全兜底:轮次耗尽时强制进入交付,避免无限循环。
func RunExecuteNode(ctx context.Context, input ExecuteNodeInput) error {
// 1. 校验依赖并准备运行态。
runtimeState, conversationContext, emitter, err := prepareExecuteNodeInput(input)
if err != nil {
return err
}
flowState := runtimeState.EnsureCommonState()
// 1.5. 确认执行分支:如果用户已确认写操作,直接执行工具。
if runtimeState.PendingConfirmTool != nil {
return executePendingTool(ctx, runtimeState, conversationContext, input.ToolRegistry, input.ScheduleState, input.SchedulePersistor, input.OriginalScheduleState, emitter)
}
// 2. 推送执行阶段状态,让前端知道当前进度。
if flowState.HasCurrentPlanStep() {
// 有 plan显示步骤进度。
current, total := flowState.PlanProgress()
currentStep, _ := flowState.CurrentPlanStep()
if err := emitter.EmitStatus(
executeStatusBlockID,
executeStageName,
"executing",
fmt.Sprintf("正在执行第 %d/%d 步:%s", current, total, truncateText(currentStep.Content, 60)),
false,
); err != nil {
return fmt.Errorf("执行阶段状态推送失败: %w", err)
}
} else {
// 无 plan纯 ReAct 模式。
if err := emitter.EmitStatus(
executeStatusBlockID,
executeStageName,
"executing",
"正在处理你的请求...",
false,
); err != nil {
return fmt.Errorf("执行阶段状态推送失败: %w", err)
}
}
// 4. 消耗一轮预算,并检查是否耗尽。
if !flowState.NextRound() {
// 轮次耗尽,强制进入交付阶段。
flowState.Done()
return nil
}
// 5. 构造本轮执行输入,请求 LLM 输出 ExecuteDecision。
messages := newagentprompt.BuildExecuteMessages(flowState, conversationContext)
decision, rawResult, err := newagentllm.GenerateJSON[newagentmodel.ExecuteDecision](
ctx,
input.Client,
messages,
newagentllm.GenerateOptions{
Temperature: 1.0, // thinking 模式强制要求 temperature=1
MaxTokens: 16000, // 需为 thinking chain 留出足够预算
Thinking: newagentllm.ThinkingModeEnabled,
Metadata: map[string]any{
"stage": executeStageName,
"step_index": flowState.CurrentStep,
"round_used": flowState.RoundUsed,
},
},
)
// 提前捕获原始文本,用于日志和 correction。
rawText := ""
if rawResult != nil {
rawText = strings.TrimSpace(rawResult.Text)
}
if err != nil {
if rawText != "" {
log.Printf("[DEBUG] execute LLM 输出解析失败 chat=%s round=%d raw=%s",
flowState.ConversationID, flowState.RoundUsed, rawText)
flowState.ConsecutiveCorrections++
if flowState.ConsecutiveCorrections >= maxConsecutiveCorrections {
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}}}"
} else {
errorDesc = "你的输出不是合法 JSON无法解析。"
optionHint = "你必须输出严格的 JSON 格式。合法格式示例:{\"speak\":\"...\",\"action\":\"continue\",\"reason\":\"...\",\"tool_call\":{\"name\":\"工具名\",\"arguments\":{}}}"
}
AppendLLMCorrectionWithHint(conversationContext, rawText, errorDesc, optionHint)
return nil
}
// 模型返回空文本(常见原因:上下文过长、模型异常),走 correction 重试而非直接 fatal。
if strings.Contains(err.Error(), "empty text") {
log.Printf("[WARN] execute LLM 返回空文本 chat=%s round=%d consecutive=%d/%d",
flowState.ConversationID, flowState.RoundUsed,
flowState.ConsecutiveCorrections+1, maxConsecutiveCorrections)
flowState.ConsecutiveCorrections++
if flowState.ConsecutiveCorrections >= maxConsecutiveCorrections {
return fmt.Errorf("连续 %d 次模型返回空文本,终止执行", flowState.ConsecutiveCorrections)
}
AppendLLMCorrectionWithHint(
conversationContext,
"",
"模型没有返回任何内容。",
"请重新输出合法 JSON 格式的执行决策。",
)
return nil
}
return fmt.Errorf("执行阶段模型调用失败: %w", err)
}
// 调试日志:输出 LLM 原始返回和解析后的决策,方便排查。
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)
if err := decision.Validate(); err != nil {
flowState.ConsecutiveCorrections++
log.Printf("[WARN] execute 决策不合法 chat=%s round=%d consecutive=%d/%d err=%s",
flowState.ConversationID, flowState.RoundUsed,
flowState.ConsecutiveCorrections, maxConsecutiveCorrections, err.Error())
if flowState.ConsecutiveCorrections >= maxConsecutiveCorrections {
return fmt.Errorf("连续 %d 次决策不合法,终止执行: %s (原始输出: %s)",
flowState.ConsecutiveCorrections, err.Error(), rawText)
}
// 给 LLM 修正机会。
AppendLLMCorrectionWithHint(
conversationContext,
rawText,
fmt.Sprintf("你的执行决策不合法:%s", err.Error()),
"合法的 action 包括continue继续当前步骤、ask_user追问用户、confirm写操作确认、next_plan推进到下一步、done任务完成。",
)
return nil
}
// 决策合法,重置连续修正计数。
flowState.ConsecutiveCorrections = 0
// speak 后处理:补列表序号换行 + 末尾加 \n 防止连续 speak 在前端粘连。
decision.Speak = normalizeSpeak(decision.Speak) // 末尾已含 \n
// 自省校验next_plan / done 必须附带 goal_check否则不推进追加修正让 LLM 重试。
if decision.Action == newagentmodel.ExecuteActionNextPlan ||
decision.Action == newagentmodel.ExecuteActionDone {
if strings.TrimSpace(decision.GoalCheck) == "" {
flowState.ConsecutiveCorrections++
if flowState.ConsecutiveCorrections >= maxConsecutiveCorrections {
return fmt.Errorf("连续 %d 次 goal_check 为空,终止执行", flowState.ConsecutiveCorrections)
}
// hint 区分有 plan / ReAct 两种模式:
// - 有 plan要求对照 done_when 逐条验证;
// - ReAct没有 done_when只要求总结完成事实。
var goalCheckHint string
if flowState.HasPlan() {
goalCheckHint = fmt.Sprintf("输出 %s 时,必须在 goal_check 中对照 done_when 逐条说明完成依据。", decision.Action)
} else {
goalCheckHint = fmt.Sprintf("输出 %s 时,必须在 goal_check 中总结任务已完成的事实证据(调用了哪些工具、得到了什么结果)。", decision.Action)
}
AppendLLMCorrectionWithHint(
conversationContext,
decision.Speak,
fmt.Sprintf("你输出了 action=%s但 goal_check 为空。", decision.Action),
goalCheckHint,
)
return nil
}
}
// 6. speak 推流与历史写入。
//
// AlwaysExecute=true 时confirm 动作不走确认卡片speak 和 continue 一样直接推流;
// AlwaysExecute=false 时confirm 的 speak 不推流(由确认卡片展示),但仍写入历史,
// 防止 LLM 下一轮忘记自己的计划,形成重复确认循环。
speakText := decision.Speak // 已由 normalizeSpeak 处理,末尾含 \n
if speakText != "" {
isConfirmWithCard := decision.Action == newagentmodel.ExecuteActionConfirm && !input.AlwaysExecute
isAskUser := decision.Action == newagentmodel.ExecuteActionAskUser
if !isConfirmWithCard && !isAskUser {
// 推流给前端
if err := emitter.EmitPseudoAssistantText(
ctx,
executeSpeakBlockID,
executeStageName,
speakText,
newagentstream.DefaultPseudoStreamOptions(),
); err != nil {
return fmt.Errorf("执行文案推送失败: %w", err)
}
}
// 始终写入历史confirm 卡片场景下也写,保证上下文连续)
conversationContext.AppendHistory(&schema.Message{
Role: schema.Assistant,
Content: speakText,
})
}
// 7. 按 LLM 决策执行动作,后端信任 LLM 判断,不做语义校验。
switch decision.Action {
case newagentmodel.ExecuteActionContinue:
// 继续当前步骤的 ReAct 循环。
// 若有工具调用意图,则执行工具并记录证据。
if decision.ToolCall != nil {
return executeToolCall(ctx, flowState, conversationContext, decision.ToolCall, emitter, input.ToolRegistry, input.ScheduleState)
}
// 无工具调用且 speak 为空speak 非空时已在步骤 6 写入历史)。
// 若 history 本轮完全没有更新,下一轮 LLM 会收到完全相同的上下文,容易死循环。
// 把 reason 写入历史,保证上下文向前推进。
if strings.TrimSpace(decision.Speak) == "" && strings.TrimSpace(decision.Reason) != "" {
conversationContext.AppendHistory(&schema.Message{
Role: schema.Assistant,
Content: decision.Reason,
})
}
return nil
case newagentmodel.ExecuteActionAskUser:
// LLM 判定缺少关键信息,打开追问交互。
question := resolveExecuteAskUserText(decision)
runtimeState.OpenAskUserInteraction(uuid.NewString(), question, strings.TrimSpace(input.ResumeNode))
return nil
case newagentmodel.ExecuteActionConfirm:
// AlwaysExecute=true跳过确认闸门直接执行写工具并持久化不走 confirm 节点。
if input.AlwaysExecute && decision.ToolCall != nil {
if err := executeToolCall(ctx, flowState, conversationContext, decision.ToolCall, emitter, input.ToolRegistry, input.ScheduleState); err != nil {
return err
}
if input.SchedulePersistor != nil && input.OriginalScheduleState != nil {
cs := runtimeState.EnsureCommonState()
if persistErr := input.SchedulePersistor.PersistScheduleChanges(ctx, input.OriginalScheduleState, input.ScheduleState, cs.UserID); persistErr != nil {
log.Printf("[WARN] execute always-execute 持久化失败: %v", persistErr)
}
}
return nil
}
// AlwaysExecute=false默认暂存工具意图设 Phase → 下游 confirm 节点接管。
return handleExecuteActionConfirm(decision, runtimeState, flowState)
case newagentmodel.ExecuteActionNextPlan:
// LLM 判定当前步骤已完成,推进到下一步。
// 后端信任 LLM 判断,不做硬校验。
if !flowState.AdvanceStep() {
// 所有步骤已完成,进入交付阶段。
flowState.Done()
}
return nil
case newagentmodel.ExecuteActionDone:
// LLM 判定整个任务已完成,直接进入交付阶段。
// 后端信任 LLM 判断,不做硬校验。
flowState.Done()
return nil
default:
// 1. LLM 输出了不支持的 action不应直接报错终止而应给它修正机会。
// 2. 使用通用修正函数追加错误反馈,让 Graph 继续循环。
// 3. LLM 下一轮会看到错误反馈并修正自己的输出。
llmOutput := decision.Speak
if strings.TrimSpace(llmOutput) == "" {
llmOutput = decision.Reason
}
AppendLLMCorrectionWithHint(
conversationContext,
llmOutput,
fmt.Sprintf("你输出的 action \"%s\" 不是合法的执行动作。", decision.Action),
"合法的 action 包括continue继续当前步骤、ask_user追问用户、next_plan推进到下一步、done任务完成。",
)
return nil
}
}
// prepareExecuteNodeInput 校验并准备执行节点的运行态依赖。
//
// 职责边界:
// 1. 校验必要依赖是否注入;
// 2. 为空依赖提供兜底值,避免空指针;
// 3. 不负责持久化,不负责业务逻辑。
func prepareExecuteNodeInput(input ExecuteNodeInput) (*newagentmodel.AgentRuntimeState, *newagentmodel.ConversationContext, *newagentstream.ChunkEmitter, error) {
if input.RuntimeState == nil {
return nil, nil, nil, fmt.Errorf("execute node: runtime state 不能为空")
}
if input.Client == nil {
return nil, nil, nil, fmt.Errorf("execute node: execute client 未注入")
}
input.RuntimeState.EnsureCommonState()
if input.ConversationContext == nil {
input.ConversationContext = newagentmodel.NewConversationContext("")
}
if input.ChunkEmitter == nil {
input.ChunkEmitter = newagentstream.NewChunkEmitter(newagentstream.NoopPayloadEmitter(), "", "", time.Now().Unix())
}
return input.RuntimeState, input.ConversationContext, input.ChunkEmitter, nil
}
// resolveExecuteAskUserText 解析追问用户的文案。
//
// 优先级:
// 1. 优先使用 LLM 输出的 speak
// 2. 其次使用 reason
// 3. 最后使用默认文案。
func resolveExecuteAskUserText(decision *newagentmodel.ExecuteDecision) string {
if decision == nil {
return "执行过程中遇到不确定的情况,需要向你确认。"
}
if strings.TrimSpace(decision.Speak) != "" {
return strings.TrimSpace(decision.Speak)
}
if strings.TrimSpace(decision.Reason) != "" {
return strings.TrimSpace(decision.Reason)
}
return "执行过程中遇到不确定的情况,需要向你确认。"
}
// handleExecuteActionConfirm 处理 LLM 申报的写操作确认请求。
//
// 步骤:
// 1. 把 ToolCallIntent 转成 PendingToolCallSnapshot 暂存到运行态;
// 2. 设 Phase = PhaseWaitingConfirm让下游 confirm 节点接管;
// 3. 不执行工具,也不生成确认事件 — 这些都是 confirm 节点的职责。
func handleExecuteActionConfirm(
decision *newagentmodel.ExecuteDecision,
runtimeState *newagentmodel.AgentRuntimeState,
flowState *newagentmodel.CommonState,
) error {
toolCall := decision.ToolCall
// 序列化工具参数。
argsJSON := ""
if toolCall.Arguments != nil {
if raw, err := json.Marshal(toolCall.Arguments); err == nil {
argsJSON = string(raw)
}
}
// 暂存到运行态邮箱confirm 节点会读出来。
runtimeState.PendingConfirmTool = &newagentmodel.PendingToolCallSnapshot{
ToolName: toolCall.Name,
ArgsJSON: argsJSON,
Summary: strings.TrimSpace(decision.Speak),
}
// 设 Phase让 branchAfterExecute 路由到 confirm 节点。
flowState.Phase = newagentmodel.PhaseWaitingConfirm
return nil
}
// executeToolCall 执行工具调用并记录证据。
//
// 职责边界:
// 1. 只负责执行工具调用,记录结果;
// 2. 不负责判断工具调用是否成功(由 LLM 下一轮判断);
// 3. 不负责重试(由外层 Graph 循环控制)。
func executeToolCall(
ctx context.Context,
flowState *newagentmodel.CommonState,
conversationContext *newagentmodel.ConversationContext,
toolCall *newagentmodel.ToolCallIntent,
emitter *newagentstream.ChunkEmitter,
registry *newagenttools.ToolRegistry,
scheduleState *newagenttools.ScheduleState,
) error {
if toolCall == nil {
return nil
}
toolName := strings.TrimSpace(toolCall.Name)
if toolName == "" {
return fmt.Errorf("工具调用缺少工具名称")
}
// 推送工具调用状态,让前端知道当前在做什么。
if err := emitter.EmitStatus(
executeStatusBlockID,
executeStageName,
"tool_call",
fmt.Sprintf("正在调用工具:%s", toolName),
false,
); err != nil {
return fmt.Errorf("工具调用状态推送失败: %w", err)
}
// 1. 校验依赖。
if registry == nil {
return fmt.Errorf("工具注册表未注入")
}
if scheduleState == nil {
return fmt.Errorf("日程状态未加载,无法执行工具")
}
if !registry.HasTool(toolName) {
// LLM 拼错或编造了工具名,走 correction 机制给重试机会,而非直接 fatal。
// 与 action 不合法、决策校验失败等路径一致:追加错误反馈 → Graph 循环 → LLM 修正。
flowState.ConsecutiveCorrections++
if flowState.ConsecutiveCorrections >= maxConsecutiveCorrections {
return fmt.Errorf("连续 %d 次调用未知工具,终止执行: %s可用工具%s",
flowState.ConsecutiveCorrections, toolName, strings.Join(registry.ToolNames(), "、"))
}
log.Printf("[WARN] execute 工具名不合法 chat=%s round=%d tool=%s consecutive=%d/%d available=%v",
flowState.ConversationID, flowState.RoundUsed, toolName,
flowState.ConsecutiveCorrections, maxConsecutiveCorrections, registry.ToolNames())
AppendLLMCorrectionWithHint(
conversationContext,
"",
fmt.Sprintf("你调用的工具 \"%s\" 不存在。", toolName),
fmt.Sprintf("可用工具:%s。请检查拼写后重新输出。", strings.Join(registry.ToolNames(), "、")),
)
return nil
}
// 2. 执行工具。
result := registry.Execute(scheduleState, toolName, toolCall.Arguments)
// 2.5 截断过大的工具结果,防止上下文膨胀导致后续 LLM 调用返回空或超限。
const maxToolResultLen = 3000
if len(result) > maxToolResultLen {
result = result[:maxToolResultLen] + fmt.Sprintf("\n...(结果已截断,原始长度 %d 字符)", len(result))
}
// 3. 将工具调用和结果以合法的 assistant+tool 消息对追加到对话历史。
//
// 修复说明:
// 旧实现直接追加裸 Tool 消息(无 ToolCallID、无前置 assistant tool_calls
// 违反 OpenAI 兼容 API 消息格式约束,导致 API 拒绝请求、连接断开。
// 正确做法:先追加带 ToolCalls 的 assistant 消息,再追加带匹配 ToolCallID 的 tool 消息。
toolCallID := uuid.NewString()
argsJSON := "{}"
if toolCall.Arguments != nil {
if raw, err := json.Marshal(toolCall.Arguments); err == nil {
argsJSON = string(raw)
}
}
conversationContext.AppendHistory(&schema.Message{
Role: schema.Assistant,
Content: "",
ToolCalls: []schema.ToolCall{
{
ID: toolCallID,
Type: "function",
Function: schema.FunctionCall{
Name: toolName,
Arguments: argsJSON,
},
},
},
})
conversationContext.AppendHistory(&schema.Message{
Role: schema.Tool,
Content: result,
ToolCallID: toolCallID,
ToolName: toolName,
})
return nil
}
// executePendingTool 执行用户已确认的写工具。
//
// 职责边界:
// 1. 从 PendingConfirmTool 读取工具名和参数(已序列化);
// 2. 反序列化参数后调用工具执行;
// 3. 将结果追加到历史,清空 PendingConfirmTool
// 4. 执行成功后调用 persistor 持久化变更;
// 5. 不调用 LLM直接返回让下一轮继续。
func executePendingTool(
ctx context.Context,
runtimeState *newagentmodel.AgentRuntimeState,
conversationContext *newagentmodel.ConversationContext,
registry *newagenttools.ToolRegistry,
scheduleState *newagenttools.ScheduleState,
persistor newagentmodel.SchedulePersistor,
originalState *newagenttools.ScheduleState,
emitter *newagentstream.ChunkEmitter,
) error {
pending := runtimeState.PendingConfirmTool
if pending == nil {
return nil
}
// 1. 反序列化参数。
var args map[string]any
if err := json.Unmarshal([]byte(pending.ArgsJSON), &args); err != nil {
return fmt.Errorf("解析工具参数失败: %w", err)
}
// 2. 推送状态。
if err := emitter.EmitStatus(
executeStatusBlockID,
executeStageName,
"tool_call",
fmt.Sprintf("正在执行工具:%s", pending.ToolName),
false,
); err != nil {
return fmt.Errorf("工具调用状态推送失败: %w", err)
}
// 3. 校验依赖:写工具必须持有有效的日程状态。
if scheduleState == nil {
return fmt.Errorf("日程状态未加载,无法执行已确认的写工具 %s", pending.ToolName)
}
// 4. 执行工具。
result := registry.Execute(scheduleState, pending.ToolName, args)
// 5. 将工具调用和结果以合法的 assistant+tool 消息对追加到历史。
//
// 修复说明:同 executeToolCall需要配对的 assistant+tool 消息。
toolCallID := uuid.NewString()
conversationContext.AppendHistory(&schema.Message{
Role: schema.Assistant,
Content: "",
ToolCalls: []schema.ToolCall{
{
ID: toolCallID,
Type: "function",
Function: schema.FunctionCall{
Name: pending.ToolName,
Arguments: pending.ArgsJSON,
},
},
},
})
conversationContext.AppendHistory(&schema.Message{
Role: schema.Tool,
Content: result,
ToolCallID: toolCallID,
ToolName: pending.ToolName,
})
// 6. 清空临时邮箱,避免重复执行。
runtimeState.PendingConfirmTool = nil
// 7. 持久化变更(如果有 persistor
if persistor != nil && originalState != nil {
if err := persistor.PersistScheduleChanges(ctx, originalState, scheduleState, runtimeState.UserID); err != nil {
return fmt.Errorf("持久化日程变更失败: %w", err)
}
}
return nil
}
// listItemRe 匹配被粘连在一起的列表序号(如 "2. " "水课3. "),用于自动补换行。
// 规则:非换行字符后紧跟 2-9 的序号("2. " "3、" 等),说明 LLM 漏写了换行。
var listItemRe = regexp.MustCompile(`([^\n])([2-9][\.、]\s)`)
// normalizeSpeak 对 LLM 输出的 speak 做后处理:
// 1. 在列表序号2. 3. …)前补 \n防止列表项粘连
// 2. 统一补尾部 \n防止多轮 speak 推流时文字头尾粘连。
func normalizeSpeak(speak string) string {
speak = strings.TrimSpace(speak)
if speak == "" {
return speak
}
if !strings.Contains(speak, "\n") {
speak = listItemRe.ReplaceAllString(speak, "$1\n$2")
}
return speak + "\n"
}
// truncateText 截断文本到指定长度。
//
// 用于状态推送时避免超长文本影响前端展示。
func truncateText(text string, maxLen int) string {
text = strings.TrimSpace(text)
if len(text) <= maxLen {
return text
}
if maxLen <= 3 {
return text[:maxLen]
}
return text[:maxLen-3] + "..."
}