Files
xk-ai-agent/internal/service/reactloop.go
2026-08-14 21:50:48 +08:00

780 lines
26 KiB
Go
Raw Permalink 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 service
// ========================================================================
// ReactLoop —— 完整 ReAct 循环Planning + Reflection + JSON 自修复)
// ========================================================================
// 本模块是 EnhanceService 的"增强路径",独立于 agent.Runner。
//
// 为什么不复用 agent.Runner.Run
// 1. Runner.Run 是按 sessionID 操作的(带会话管理、并发清理协程),
// 而 EnhanceService 是"无状态一次性请求",没有 session 概念;
// 2. Runner.Run 只返回最终文本无法返回步骤明细token/耗时/思考过程),
// 而我们需要把每一步落到 xk_ai_generation_step 子表;
// 3. Runner.Run 没有预算管理、没有 Reflection、没有 JSON 修复。
//
// 本循环的执行步骤(每一步都生成 EnhanceStep 落表):
// [1] Planning可选让模型先输出 plan JSON
// [2] 多轮 Think-Act-Observe
// for i := 0; i < maxIter; i++ {
// budget 检查(超限中止)
// LLM 调用(带 tools可触发 Function Calling
// 累加 token写 llm_call step
// 若触发工具 → 执行 → 工具结果入栈 → continue
// 否则进入 Reflection
// }
// [3] Reflection可选让模型自检输出温度更低
// [4] JSON 修复可选JSON 不合法时让模型修复
//
// 设计原则:
// - 任一步骤失败都不中断整体(除非超预算或 ctx 取消)
// - 所有 LLM 调用都通过 TokenBudget 控制
// - 步骤明细实时累加到 resp.Steps即使中途失败也要返回已生成的部分
// ========================================================================
import (
"context"
"encoding/json"
"fmt"
"log"
"strings"
"time"
"tcm-agent/internal/agent"
"tcm-agent/internal/agentcfg"
"tcm-agent/internal/llm"
"tcm-agent/internal/types"
)
// ------------------------------------------------------------------
// 入参与返回结构
// ------------------------------------------------------------------
// ReactLoopRequest ReactLoop 的一次调用入参
//
// 与 EnhanceRequest 区别:
// - Messages 已注入 KB 检索结果KB 步骤已由 EnhancerService 完成)
// - Tools 是可选的(若客户端模型不支持 Function Calling传 nil 走纯 chat
// - Client 已通过 resolveClient 解析好(避免 ReactLoop 重复解析)
type ReactLoopRequest struct {
Scene string // 场景(仅用于日志/step 记录)
Messages []types.Message // 注入 KB 后的消息列表(会被本循环修改)
Tools []types.Tool // 可用工具集(可空)
Client llm.LLMClient // 已解析的 LLM 客户端
Provider string // 客户端对应的 provider 名
// Temperature 主生成温度覆盖PHP 按场景显式传入时 >0
// 只影响主生成调用Planning/Reflection/JSON 修复仍用各自的低温配置
Temperature float64
}
// ReactLoopResult ReactLoop 的执行结果
type ReactLoopResult struct {
Content string // 最终生成内容(最后一次 LLM 返回的文本)
Steps []EnhanceStep // 全部步骤明细Planning + 多轮 Chat + Reflection + JSON 修复)
Aborted bool // 是否因预算超限而中止true 时 Content 仍是已生成部分)
Budget agent.BudgetSnapshot // 预算使用快照
}
// ------------------------------------------------------------------
// ReactLoop 主体
// ------------------------------------------------------------------
// runReactLoop 执行完整 ReAct 循环
//
// 调用方EnhancerService.Enhance仅在 agentcfg.ReAct.Enabled=true 时调用)
//
// 参数:
// - ctx - 请求上下文(支持超时取消)
// - req - 循环入参messages/client/tools 已就绪)
// - reactCfg - ReAct 配置maxIter/Planning/Reflection 等,来自 agentcfg
// - budgetCfg - Token 预算配置(用于创建 budget 累加器)
//
// 返回:循环结果(含步骤明细 + 最终内容)
func (s *EnhancerService) runReactLoop(
ctx context.Context,
req *ReactLoopRequest,
reactCfg agentcfg.ReActConfig,
budgetCfg agentcfg.TokenBudgetConfig,
) *ReactLoopResult {
result := &ReactLoopResult{Steps: []EnhanceStep{}}
// 创建 Token 预算器limit=0 表示不限制,按配置决定)
var limit int
if budgetCfg.Enabled {
limit = budgetCfg.PerRequest
}
budget := agent.NewTokenBudget(limit, budgetCfg.MaxTokensPerCall)
// 消息快照(循环内修改的是这份拷贝,不污染调用方原 messages
messages := make([]types.Message, len(req.Messages))
copy(messages, req.Messages)
// ===== [1] Planning 阶段(可选) =====
if reactCfg.PlanningEnabled {
planStep := s.doPlanning(ctx, req, reactCfg, budget, &messages)
result.Steps = append(result.Steps, planStep)
if budget.IsExceeded() {
result.Aborted = true
result.Budget = budget.Snapshot()
return result
}
}
// ===== [2] Think-Act-Observe 多轮循环 =====
maxIter := reactCfg.MaxIterations
if maxIter < 1 {
maxIter = 3
}
var lastContent string
var lastProvider = req.Provider
var lastModel = req.Client.Name()
for i := 0; i < maxIter; i++ {
// 预算检查(超限立即中止,返回已生成的部分)
if budget.IsExceeded() {
result.Aborted = true
log.Printf("[ReactLoop] scene=%s 预算超限中止 at iter=%d used=%d limit=%d",
req.Scene, i, budget.Used(), limit)
break
}
// 计算本次调用 max_tokens取 min(配置单次上限, 剩余预算)
maxTokens := budget.CalcMaxTokensForCall(budgetCfg.MaxTokensPerCall)
// 主生成温度PHP 按场景显式指定时优先(处方 0.3→可调低),否则用默认 0.3
mainTemp := 0.3
if req.Temperature > 0 {
mainTemp = req.Temperature
}
opts := llm.ChatOpts{
MaxTokens: maxTokens,
Temperature: mainTemp,
}
// 调用 LLM带工具
callStep := s.callLLMOnce(ctx, req, opts, messages, "llm_call")
result.Steps = append(result.Steps, callStep)
// 累加预算
budget.Consume(callStep.PromptTokens, callStep.CompletionTokens)
if callStep.Status != 1 {
// 调用失败:直接返回(上层决定要不要走降级)
break
}
// 取 LLM 实际返回的 MessagecallLLMOnce 已通过 LastChatResult 拿到 token
// 这里要拿 Message 内容 + ToolCall 信息)
llmMsg := callStep.lastMessage
if llmMsg == nil {
break
}
lastContent = llmMsg.Content
lastProvider = req.Provider
lastModel = req.Client.Name()
// 触发工具调用 → 执行工具 → 工具结果入栈 → 进入下一轮
if llmMsg.ToolCall != nil {
toolStep := s.executeToolCall(ctx, llmMsg.ToolCall)
result.Steps = append(result.Steps, toolStep)
// 【协议正确性】assistant 帧 + tool 帧都入栈(与 runner.go 修复一致)
// tool 帧必须带 ToolCallID 与 assistant 帧 tool_calls[].id 对应,
// 否则 OpenAI 协议厂商(讯飞/DeepSeek会拒绝下一轮请求
messages = append(messages, *llmMsg)
messages = append(messages, types.Message{
Role: "tool",
Content: toolStep.Detail,
ToolCallID: llmMsg.ToolCall.ID,
Timestamp: time.Now().Unix(),
})
continue
}
// 未触发工具:本轮就是最终答案,跳出循环进入 Reflection
break
}
// ===== [3] Reflection 阶段(可选) =====
if reactCfg.ReflectionEnabled && lastContent != "" && !budget.IsExceeded() {
reflectStep, reflectOK := s.doReflection(ctx, req, reactCfg, budgetCfg, budget, lastContent)
result.Steps = append(result.Steps, reflectStep)
// 反思未通过:把反思详情放到 lastContent 末尾,让用户看到模型自检结果
if !reflectOK && reflectStep.Detail != "" {
lastContent += "\n\n[反思反馈] " + reflectStep.Detail
}
}
// ===== [4] JSON 修复(可选) =====
if reactCfg.JSONRepairEnabled && lastContent != "" {
repaired, repairStep := s.repairJSONIfNeeded(ctx, req, reactCfg, budget, lastContent)
if repairStep != nil {
result.Steps = append(result.Steps, *repairStep)
if repaired != "" {
lastContent = repaired
}
}
}
result.Content = lastContent
_ = lastProvider
_ = lastModel
result.Budget = budget.Snapshot()
return result
}
// ------------------------------------------------------------------
// [1] Planning 阶段
// ------------------------------------------------------------------
// doPlanning 让模型先输出任务计划(先查什么 → 再查什么 → 最后生成)
//
// 设计目的:
// - 让模型在"消耗大量 token 之前"先规划清楚步骤
// - 复杂任务(如开中药处方)效果显著:模型可能意识到要"先查 18 反禁忌"
// - 计划本身也作为 step 落表,运维可以审计
//
// prompt 策略:
// - 单独一次 LLM 调用,只产出 plan不让它真的执行
// - 用低温度0.15+ 强约束 prompt避免计划发散
func (s *EnhancerService) doPlanning(
ctx context.Context,
req *ReactLoopRequest,
reactCfg agentcfg.ReActConfig,
budget *agent.TokenBudget,
messages *[]types.Message,
) EnhanceStep {
step := EnhanceStep{
StepType: "plan",
StartedAt: time.Now().Unix(),
}
start := time.Now()
// 构造规划 prompt复用业务 system + 追加"请先输出计划"指令
//
// 【prompt 锚定】明确提示"这是医疗任务"避免轻量模型spark-lite 等)
// 把"执行计划"模式匹配成"软件项目管理计划"套话(已实际发生过的跑题案例)
planPrompt := "【Planning 阶段】你正在处理一个中医/医疗任务(见上面的 system 与用户消息)。" +
"请基于该医疗任务输出一个简短的执行计划(不超过 5 步,每步一句话," +
"说明你打算查什么医学知识/做什么医疗判断,例如:先辨证、查配伍禁忌、再组方)。" +
"计划必须紧扣上面的医疗任务本身,不要输出通用的项目管理步骤。" +
"不要执行任务,只输出计划。"
tryMessages := append([]types.Message{}, *messages...)
tryMessages = append(tryMessages, types.Message{
Role: "user",
Content: planPrompt,
Timestamp: time.Now().Unix(),
})
opts := llm.ChatOpts{
MaxTokens: budget.CalcMaxTokensForCall(512), // 计划阶段最多 512 token
Temperature: 0.15, // 低温保证稳定
}
msg, chatResult := s.callLLMWithMeta(ctx, req.Client, tryMessages, nil, opts)
step.DurationMs = int(time.Since(start).Milliseconds())
step.FinishedAt = time.Now().Unix()
step.Provider = req.Provider
step.Model = req.Client.Name()
if msg == nil || msg.Content == "" {
step.Status = 2
step.Detail = "Planning 调用失败"
if chatResult != nil {
budget.Consume(chatResult.PromptTokens, chatResult.CompletionTokens)
step.PromptTokens = chatResult.PromptTokens
step.CompletionTokens = chatResult.CompletionTokens
step.TotalTokens = chatResult.TotalTokens
}
return step
}
// 累加预算
if chatResult != nil {
budget.Consume(chatResult.PromptTokens, chatResult.CompletionTokens)
step.PromptTokens = chatResult.PromptTokens
step.CompletionTokens = chatResult.CompletionTokens
step.TotalTokens = chatResult.TotalTokens
step.Usage = chatResult.Usage
}
// ===== 计划跑题检测:只有命中医疗白名单的计划才允许注入 =====
//
// 为什么用白名单制(而不是"没命中黑名单就放行"
// 轻量模型可能输出既无医疗词也无黑名单词的泛泛计划(如"1.收集信息 2.分析 3.输出"
// 这种计划注入后同样会稀释医疗任务焦点、污染后续生成
// 实际案例plan 跑题成项目管理 → 最终处方输出也变成了项目管理步骤)。
// 丢弃计划无副作用step 记录保留status=2 便于后台审计),主循环按无计划模式继续。
planGuard := validateMedicalRelevance([]types.Message{{Content: msg.Content}})
if len(planGuard.HitWhitelist) == 0 {
log.Printf("[ReactLoop] ⚠️ scene=%s Planning 输出未命中任何医疗关键词已丢弃(命中非医疗词=%v主循环按无计划模式继续",
req.Scene, planGuard.HitBlacklist)
step.Status = 2
step.Detail = "计划跑题已丢弃: " + truncateForLog(msg.Content, 200)
return step
}
// ===== 把计划追加到对话历史,让后续主循环能参考 =====
//
// 【关键约定】messages 序列最后必须是 user 角色才能让下一次 LLM 调用生效。
// 之前直接把 plan 当 assistant 帧追加,导致序列以 assistant 结尾,
// 触发讯飞 Lite "10003 用户的消息格式有错误"Pro/Max 宽松Lite 严格)。
//
// 正确做法:
// 1) 先把 plan 当 user 帧(标明是"计划"内容)追加 → 让 LLM 知道计划是什么
// 2) 再追加一个 user 帧作为"执行指令" → 保证最后一条是 user
*messages = append(*messages, types.Message{
Role: "user",
Content: "[Planning 阶段输出的计划]\n" + msg.Content,
Timestamp: time.Now().Unix(),
})
*messages = append(*messages, types.Message{
Role: "user",
Content: "【执行阶段】请严格按照上述计划完成最初的医疗任务,并按 system 要求的格式输出最终结果。",
Timestamp: time.Now().Unix(),
})
step.Status = 1
step.Detail = "计划: " + truncateForLog(msg.Content, 200)
return step
}
// ------------------------------------------------------------------
// [2] 单次 LLM 调用(带预算与 step 记录)
// ------------------------------------------------------------------
// callLLMOnce 调用一次 LLM包装成 EnhanceStep含 token 统计)
//
// 调用方runReactLoop 主循环
func (s *EnhancerService) callLLMOnce(
ctx context.Context,
req *ReactLoopRequest,
opts llm.ChatOpts,
messages []types.Message,
stepType string,
) EnhanceStep {
step := EnhanceStep{
StepType: stepType,
StartedAt: time.Now().Unix(),
}
start := time.Now()
msg, chatResult := s.callLLMWithMeta(ctx, req.Client, messages, req.Tools, opts)
step.DurationMs = int(time.Since(start).Milliseconds())
step.FinishedAt = time.Now().Unix()
step.Provider = req.Provider
step.Model = req.Client.Name()
step.lastMessage = msg
if chatResult != nil {
step.PromptTokens = chatResult.PromptTokens
step.CompletionTokens = chatResult.CompletionTokens
step.TotalTokens = chatResult.TotalTokens
step.Usage = chatResult.Usage
step.APIKeyID = chatResult.APIKeyID
}
if msg == nil {
step.Status = 2
step.Detail = "LLM 返回空"
return step
}
step.Status = 1
if msg.ToolCall != nil {
step.Detail = fmt.Sprintf("触发工具调用: %s", msg.ToolCall.ToolName)
} else {
step.Detail = "成功 | finish_reason=" + chatResult.FinishReason
}
return step
}
// callLLMWithMeta 调用 LLM 并提取 token 元数据(统一入口)
//
// 策略:
// 1. 若客户端实现 OptAwareClientDeepSeek/Spark/OpenAI→ 用 ChatWithOpts 透传参数
// 2. 否则回落到 LLMClient.Chat
// 3. 若客户端实现 TokenAwareClient → 取 LastChatResult 拿 token/finish_reason
func (s *EnhancerService) callLLMWithMeta(
ctx context.Context,
client llm.LLMClient,
messages []types.Message,
tools []types.Tool,
opts llm.ChatOpts,
) (*types.Message, *types.ChatResult) {
var (
msg *types.Message
err error
)
// 单次调用的闭包(优先用 ChatWithOpts
doCall := func() (*types.Message, error) {
if optClient, ok := client.(llm.OptAwareClient); ok {
return optClient.ChatWithOpts(ctx, messages, tools, opts)
}
return client.Chat(ctx, messages, tools)
}
msg, err = doCall()
// ---------- 稳定性加固:网络类错误自动重试 1 次 ----------
// 只重试瞬时网络抖动(连接失败/超时/reset不重试业务错误4xx/5xx API 返回):
// - 业务错误重试大概率还是失败,白白双倍消耗 token 配额
// - 网络抖动重试一次通常就能恢复,显著提升单点抖动下的成功率
// 间隔 1s给对端 LB/连接池一个恢复窗口,也避免瞬间重试风暴
if err != nil && isRetryableNetworkError(err) && ctx.Err() == nil {
log.Printf("[Enhancer] LLM 网络类错误1s 后自动重试 1 次: %v", err)
select {
case <-time.After(1 * time.Second):
msg, err = doCall()
case <-ctx.Done():
// 上游已取消(如客户端断开),不再重试
}
}
if err != nil {
log.Printf("[Enhancer] LLM 调用失败: %v", err)
return nil, nil
}
// 尝试取 token 用量DeepSeek/Spark 实现 TokenAwareClient
var chatResult *types.ChatResult
if tac, ok := client.(llm.TokenAwareClient); ok {
chatResult = tac.LastChatResult()
}
if chatResult == nil {
chatResult = &types.ChatResult{}
}
return msg, chatResult
}
// isRetryableNetworkError 判断 LLM 调用错误是否属于「可重试的网络类错误」
//
// 判定思路LLM 客户端返回的错误是 fmt.Errorf 包装的字符串,
// 无法用 errors.Is 精确匹配,只能按错误文案特征识别。
// 白名单只收纯网络故障(连接/超时/reset凡是「API 返回 xxx」
// 这种服务端已给出响应的业务错误一律不重试,避免双倍烧 token。
func isRetryableNetworkError(err error) bool {
if err == nil {
return false
}
s := strings.ToLower(err.Error())
// 服务端已返回明确响应(业务错误),不属于网络抖动
if strings.Contains(s, "api 返回") {
return false
}
// 常见网络故障特征Go net/http 错误文案)
networkHints := []string{
"timeout", "deadline exceeded", // 超时
"connection refused", "connection reset", // 连接被拒/被重置
"broken pipe", "unexpected eof", "eof", // 传输中断
"no such host", "dial tcp", // DNS/建连失败
"tls handshake", // TLS 握手失败
"请求失败", // 客户端封装的通用请求失败前缀
}
for _, hint := range networkHints {
if strings.Contains(s, hint) {
return true
}
}
return false
}
// ------------------------------------------------------------------
// [3] Reflection 阶段
// ------------------------------------------------------------------
// doReflection 让模型自检生成结果是否合格
//
// prompt 策略:
// - 用更低温度reactCfg.ReflectionTemperature默认 0.2
// - 强约束输出:要求模型回答"合格"或"不合格 + 原因"
// - 用预算的剩余部分作为 max_tokens避免反思膨胀
//
// 返回:
// - EnhanceStep反思过程记录
// - bool是否合格true=通过false=不通过,调用方可决定是否重试)
func (s *EnhancerService) doReflection(
ctx context.Context,
req *ReactLoopRequest,
reactCfg agentcfg.ReActConfig,
budgetCfg agentcfg.TokenBudgetConfig,
budget *agent.TokenBudget,
generatedContent string,
) (EnhanceStep, bool) {
step := EnhanceStep{
StepType: "reflection",
StartedAt: time.Now().Unix(),
}
start := time.Now()
// 构造反思 prompt让模型扮演"审核者"角色
reflectPrompt := fmt.Sprintf(
"【Reflection 反思阶段】请对以下 AI 生成内容进行严格审核:\n\n"+
"---\n%s\n---\n\n"+
"审核标准:\n"+
"1. 是否有医学常识性错误(剂量、配伍禁忌、诊断矛盾)\n"+
"2. 是否漏掉关键字段(处方缺剂量/用法;病历缺主诉对应诊断)\n"+
"3. 是否符合中文医疗文案规范(无歧义、无营销用语)\n\n"+
"输出格式:第一行必须是「合格」或「不合格」;如果不合格,第二行起说明原因。\n"+
"不要重新生成内容,只做审核。",
generatedContent,
)
// 反思用独立 messages避免污染主对话
reflectMessages := []types.Message{
{
Role: "system",
Content: "你是一名严谨的中医临床审核专家。",
Timestamp: time.Now().Unix(),
},
{
Role: "user",
Content: reflectPrompt,
Timestamp: time.Now().Unix(),
},
}
temp := reactCfg.ReflectionTemperature
if temp <= 0 {
temp = 0.2
}
maxTokens := budget.CalcMaxTokensForCall(1024) // 反思最多 1024 token
opts := llm.ChatOpts{
MaxTokens: maxTokens,
Temperature: temp,
}
msg, chatResult := s.callLLMWithMeta(ctx, req.Client, reflectMessages, nil, opts)
step.DurationMs = int(time.Since(start).Milliseconds())
step.FinishedAt = time.Now().Unix()
step.Provider = req.Provider
step.Model = req.Client.Name()
if chatResult != nil {
budget.Consume(chatResult.PromptTokens, chatResult.CompletionTokens)
step.PromptTokens = chatResult.PromptTokens
step.CompletionTokens = chatResult.CompletionTokens
step.TotalTokens = chatResult.TotalTokens
step.Usage = chatResult.Usage
}
if msg == nil {
step.Status = 2
step.Detail = "反思调用失败(不阻断,按合格处理)"
return step, true
}
// 解析"合格/不合格"
content := strings.TrimSpace(msg.Content)
firstLine := strings.ToLower(strings.SplitN(content, "\n", 2)[0])
passed := true
if strings.Contains(firstLine, "不合格") {
passed = false
}
step.Status = 1
if passed {
step.Detail = "审核通过"
} else {
step.Detail = "审核不通过: " + truncateForLog(content, 300)
}
return step, passed
}
// ------------------------------------------------------------------
// [4] JSON 修复阶段
// ------------------------------------------------------------------
// repairJSONIfNeeded 检查内容是否为合法 JSON不合法时让模型修复
//
// 返回:
// - string修复后的内容无需修复或修复失败时返回空字符串
// - *EnhanceStep修复步骤无需修复时返回 nil
func (s *EnhancerService) repairJSONIfNeeded(
ctx context.Context,
req *ReactLoopRequest,
reactCfg agentcfg.ReActConfig,
budget *agent.TokenBudget,
content string,
) (string, *EnhanceStep) {
trimmed := strings.TrimSpace(content)
// 已经是合法 JSON不修复
if json.Valid([]byte(trimmed)) {
return "", nil
}
// 不是 JSON 但不是预期的 JSON 场景(内容不以 { 或 [ 开头):也不修复
// 避免对纯文本响应(如知识问答)误触发修复
if !strings.HasPrefix(trimmed, "{") && !strings.HasPrefix(trimmed, "[") {
return "", nil
}
maxRetries := reactCfg.JSONRepairMaxRetries
if maxRetries < 1 {
maxRetries = 2
}
var lastRepaired string
var lastStep *EnhanceStep
for retry := 0; retry < maxRetries; retry++ {
if budget.IsExceeded() {
break
}
repairStep, repaired := s.doJSONRepairOnce(ctx, req, budget, content, retry)
lastStep = repairStep
if repaired != "" && json.Valid([]byte(repaired)) {
lastRepaired = repaired
break // 修复成功
}
// 用修复后的内容(即使还不合法)作为下次输入
if repaired != "" {
content = repaired
}
}
return lastRepaired, lastStep
}
// doJSONRepairOnce 一次 JSON 修复尝试
func (s *EnhancerService) doJSONRepairOnce(
ctx context.Context,
req *ReactLoopRequest,
budget *agent.TokenBudget,
brokenContent string,
retry int,
) (*EnhanceStep, string) {
step := EnhanceStep{
StepType: "json_repair",
StartedAt: time.Now().Unix(),
}
start := time.Now()
repairPrompt := fmt.Sprintf(
"以下内容应当是合法 JSON 但解析失败,请修复并只输出修复后的 JSON不要解释、不要 markdown\n\n---\n%s\n---",
brokenContent,
)
messages := []types.Message{
{
Role: "system",
Content: "你是一个 JSON 修复工具,只输出合法的 JSON 内容,不要任何其他文字。",
Timestamp: time.Now().Unix(),
},
{
Role: "user",
Content: repairPrompt,
Timestamp: time.Now().Unix(),
},
}
opts := llm.ChatOpts{
MaxTokens: budget.CalcMaxTokensForCall(2048),
Temperature: 0.1, // 极低温度保证修复稳定
}
msg, chatResult := s.callLLMWithMeta(ctx, req.Client, messages, nil, opts)
step.DurationMs = int(time.Since(start).Milliseconds())
step.FinishedAt = time.Now().Unix()
step.Provider = req.Provider
step.Model = req.Client.Name()
step.Detail = fmt.Sprintf("第 %d 次修复尝试", retry+1)
if chatResult != nil {
budget.Consume(chatResult.PromptTokens, chatResult.CompletionTokens)
step.PromptTokens = chatResult.PromptTokens
step.CompletionTokens = chatResult.CompletionTokens
step.TotalTokens = chatResult.TotalTokens
step.Usage = chatResult.Usage
}
if msg == nil {
step.Status = 2
step.Detail = "修复调用失败"
return &step, ""
}
repaired := strings.TrimSpace(msg.Content)
// 剥离可能的 markdown 代码块包裹
if strings.HasPrefix(repaired, "```json") {
repaired = strings.TrimPrefix(repaired, "```json")
repaired = strings.TrimSuffix(repaired, "```")
repaired = strings.TrimSpace(repaired)
} else if strings.HasPrefix(repaired, "```") {
repaired = strings.TrimPrefix(repaired, "```")
repaired = strings.TrimSuffix(repaired, "```")
repaired = strings.TrimSpace(repaired)
}
if json.Valid([]byte(repaired)) {
step.Status = 1
step.Detail = fmt.Sprintf("第 %d 次修复成功", retry+1)
} else {
step.Status = 2
step.Detail = fmt.Sprintf("第 %d 次修复仍不合法", retry+1)
}
return &step, repaired
}
// ------------------------------------------------------------------
// [5] 工具调用执行
// ------------------------------------------------------------------
// executeToolCall 执行一次工具调用并记录 step
//
// 注意:本服务目前没有直接持有 tool 注册表(工具注册在 agent.Runner 里),
// 这里通过 s.toolRegistry 引用 runner 的工具集。如果 toolRegistry 为 nil
// 本方法返回"工具未注册"的失败 step。
func (s *EnhancerService) executeToolCall(ctx context.Context, call *types.ToolCallInfo) EnhanceStep {
step := EnhanceStep{
StepType: "tool_call",
StartedAt: time.Now().Unix(),
}
start := time.Now()
step.Detail = fmt.Sprintf("调用工具: %s", call.ToolName)
var result string
var err error
if s.toolRegistry != nil {
if t, ok := s.toolRegistry[call.ToolName]; ok {
result, err = t.Execute(ctx, call.Params)
} else {
err = fmt.Errorf("工具 %s 未注册", call.ToolName)
}
} else {
err = fmt.Errorf("工具注册表为空")
}
step.DurationMs = int(time.Since(start).Milliseconds())
step.FinishedAt = time.Now().Unix()
if err != nil {
step.Status = 2
step.Detail = fmt.Sprintf("工具 %s 执行失败: %v", call.ToolName, err)
// 失败时仍返回错误内容作为 Detail让 LLM 下一轮知道工具挂了
} else {
step.Status = 1
step.Detail = truncateForLog(result, 2000) // 工具结果可能很长,截断
}
return step
}
// ------------------------------------------------------------------
// 工具方法
// ------------------------------------------------------------------
// truncateForLog 截断字符串用于日志(保留前 n 字符)
func truncateForLog(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "...(截断)"
}