Version: 0.9.75.dev.260505
后端: 1.收口阶段 6 agent 结构迁移,将 newAgent 内核与 agentsvc 编排层迁入 services/agent - 切换 Agent 启动装配与 HTTP handler 直连 agent sv,移除旧 service agent bridge - 补齐 Agent 对 memory、task、task-class、schedule 的 RPC 适配与契约字段 - 扩展 schedule、task、task-class RPC/contract 支撑 Agent 查询、写入与 provider 切流 - 更新迁移文档、README 与相关注释,明确 agent 当前切流点和剩余 memory 迁移面
This commit is contained in:
80
backend/services/agent/shared/clone.go
Normal file
80
backend/services/agent/shared/clone.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package agentshared
|
||||
|
||||
import "github.com/LoveLosita/smartflow/backend/model"
|
||||
|
||||
func CloneWeekSchedules(src []model.UserWeekSchedule) []model.UserWeekSchedule {
|
||||
if len(src) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
dst := make([]model.UserWeekSchedule, 0, len(src))
|
||||
for _, week := range src {
|
||||
eventsCopy := make([]model.WeeklyEventBrief, len(week.Events))
|
||||
copy(eventsCopy, week.Events)
|
||||
dst = append(dst, model.UserWeekSchedule{
|
||||
Week: week.Week,
|
||||
Events: eventsCopy,
|
||||
})
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
func CloneHybridEntries(src []model.HybridScheduleEntry) []model.HybridScheduleEntry {
|
||||
if len(src) == 0 {
|
||||
return nil
|
||||
}
|
||||
dst := make([]model.HybridScheduleEntry, len(src))
|
||||
copy(dst, src)
|
||||
return dst
|
||||
}
|
||||
|
||||
func CloneTaskClassItems(src []model.TaskClassItem) []model.TaskClassItem {
|
||||
if len(src) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
dst := make([]model.TaskClassItem, 0, len(src))
|
||||
for _, item := range src {
|
||||
copied := item
|
||||
if item.CategoryID != nil {
|
||||
v := *item.CategoryID
|
||||
copied.CategoryID = &v
|
||||
}
|
||||
if item.Order != nil {
|
||||
v := *item.Order
|
||||
copied.Order = &v
|
||||
}
|
||||
if item.Content != nil {
|
||||
v := *item.Content
|
||||
copied.Content = &v
|
||||
}
|
||||
if item.Status != nil {
|
||||
v := *item.Status
|
||||
copied.Status = &v
|
||||
}
|
||||
if item.EmbeddedTime != nil {
|
||||
t := *item.EmbeddedTime
|
||||
copied.EmbeddedTime = &t
|
||||
}
|
||||
dst = append(dst, copied)
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
func CloneInts(src []int) []int {
|
||||
if len(src) == 0 {
|
||||
return nil
|
||||
}
|
||||
dst := make([]int, len(src))
|
||||
copy(dst, src)
|
||||
return dst
|
||||
}
|
||||
|
||||
func CloneStrings(src []string) []string {
|
||||
if len(src) == 0 {
|
||||
return nil
|
||||
}
|
||||
dst := make([]string, len(src))
|
||||
copy(dst, src)
|
||||
return dst
|
||||
}
|
||||
366
backend/services/agent/shared/deadline.go
Normal file
366
backend/services/agent/shared/deadline.go
Normal file
@@ -0,0 +1,366 @@
|
||||
package agentshared
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
deadlineLayouts = []string{
|
||||
time.RFC3339,
|
||||
"2006-01-02T15:04",
|
||||
"2006-01-02 15:04:05",
|
||||
"2006-01-02 15:04",
|
||||
"2006/01/02 15:04:05",
|
||||
"2006/01/02 15:04",
|
||||
"2006.01.02 15:04:05",
|
||||
"2006.01.02 15:04",
|
||||
"2006-01-02",
|
||||
"2006/01/02",
|
||||
"2006.01.02",
|
||||
}
|
||||
deadlineDateOnlyLayouts = map[string]struct{}{
|
||||
"2006-01-02": {},
|
||||
"2006/01/02": {},
|
||||
"2006.01.02": {},
|
||||
}
|
||||
|
||||
clockHMRegex = regexp.MustCompile(`(\d{1,2})\s*[::]\s*(\d{1,2})`)
|
||||
clockCNRegex = regexp.MustCompile(`(\d{1,2})\s*点\s*(半|(\d{1,2})\s*分?)?`)
|
||||
ymdRegex = regexp.MustCompile(`(\d{4})\s*年\s*(\d{1,2})\s*月\s*(\d{1,2})\s*[日号]?`)
|
||||
mdRegex = regexp.MustCompile(`(\d{1,2})\s*月\s*(\d{1,2})\s*[日号]?`)
|
||||
dateSepRegex = regexp.MustCompile(`\d{1,4}\s*[-/.]\s*\d{1,2}(\s*[-/.]\s*\d{1,2})?`)
|
||||
weekdayRegex = regexp.MustCompile(`(下周|下星期|下礼拜|本周|这周|本星期|这星期|周|星期|礼拜)([一二三四五六日天])`)
|
||||
relativeTokens = []string{
|
||||
"今天", "今日", "今晚", "今早", "今晨", "明天", "明日", "后天", "大后天", "昨天", "昨日",
|
||||
"早上", "早晨", "上午", "中午", "下午", "晚上", "傍晚", "夜里", "凌晨",
|
||||
}
|
||||
)
|
||||
|
||||
// ParseOptionalDeadline 解析工具输入中的可选截止时间。
|
||||
func ParseOptionalDeadline(raw string) (*time.Time, error) {
|
||||
value := normalizeDeadlineInput(raw)
|
||||
if value == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
deadline, hasHint, err := parseDeadlineFromText(value, NowToMinute())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if deadline == nil {
|
||||
if !hasHint {
|
||||
return nil, fmt.Errorf("deadline_at 格式不支持: %s", value)
|
||||
}
|
||||
return nil, fmt.Errorf("deadline_at 无法解析: %s", value)
|
||||
}
|
||||
return deadline, nil
|
||||
}
|
||||
|
||||
// ParseOptionalDeadlineWithNow 在给定时间基准下解析 deadline。
|
||||
func ParseOptionalDeadlineWithNow(raw string, now time.Time) (*time.Time, error) {
|
||||
value := normalizeDeadlineInput(raw)
|
||||
if value == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
deadline, _, err := parseDeadlineFromText(value, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if deadline == nil {
|
||||
return nil, fmt.Errorf("deadline_at 格式不支持: %s", value)
|
||||
}
|
||||
return deadline, nil
|
||||
}
|
||||
|
||||
func parseDeadlineFromText(value string, now time.Time) (*time.Time, bool, error) {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
loc := ShanghaiLocation()
|
||||
now = now.In(loc)
|
||||
hasHint := hasDeadlineHint(value)
|
||||
|
||||
if abs, ok := tryParseAbsoluteDeadline(value, loc); ok {
|
||||
return abs, true, nil
|
||||
}
|
||||
if rel, recognized, err := tryParseRelativeDeadline(value, now, loc); recognized {
|
||||
if err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
return rel, true, nil
|
||||
}
|
||||
if hasHint {
|
||||
return nil, true, fmt.Errorf("deadline_at 格式不支持: %s", value)
|
||||
}
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
func normalizeDeadlineInput(raw string) string {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
replacer := strings.NewReplacer(
|
||||
":", ":",
|
||||
",", ",",
|
||||
"。", ".",
|
||||
" ", " ",
|
||||
)
|
||||
return strings.TrimSpace(replacer.Replace(trimmed))
|
||||
}
|
||||
|
||||
func hasDeadlineHint(value string) bool {
|
||||
if clockHMRegex.MatchString(value) ||
|
||||
clockCNRegex.MatchString(value) ||
|
||||
ymdRegex.MatchString(value) ||
|
||||
mdRegex.MatchString(value) ||
|
||||
dateSepRegex.MatchString(value) ||
|
||||
weekdayRegex.MatchString(value) {
|
||||
return true
|
||||
}
|
||||
for _, token := range relativeTokens {
|
||||
if strings.Contains(value, token) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func tryParseAbsoluteDeadline(value string, loc *time.Location) (*time.Time, bool) {
|
||||
for _, layout := range deadlineLayouts {
|
||||
var (
|
||||
parsed time.Time
|
||||
err error
|
||||
)
|
||||
if layout == time.RFC3339 {
|
||||
parsed, err = time.Parse(layout, value)
|
||||
if err == nil {
|
||||
parsed = parsed.In(loc)
|
||||
}
|
||||
} else {
|
||||
parsed, err = time.ParseInLocation(layout, value, loc)
|
||||
}
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if _, dateOnly := deadlineDateOnlyLayouts[layout]; dateOnly {
|
||||
parsed = time.Date(parsed.Year(), parsed.Month(), parsed.Day(), 23, 59, 0, 0, loc)
|
||||
} else {
|
||||
parsed = time.Date(parsed.Year(), parsed.Month(), parsed.Day(), parsed.Hour(), parsed.Minute(), 0, 0, loc)
|
||||
}
|
||||
return &parsed, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func tryParseRelativeDeadline(value string, now time.Time, loc *time.Location) (*time.Time, bool, error) {
|
||||
baseDate, recognized := inferBaseDate(value, now, loc)
|
||||
if !recognized {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
hour, minute, hasExplicitClock, err := extractClock(value)
|
||||
if err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
if !hasExplicitClock {
|
||||
hour, minute = defaultClockByHint(value)
|
||||
}
|
||||
|
||||
deadline := time.Date(baseDate.Year(), baseDate.Month(), baseDate.Day(), hour, minute, 0, 0, loc)
|
||||
return &deadline, true, nil
|
||||
}
|
||||
|
||||
func inferBaseDate(value string, now time.Time, loc *time.Location) (time.Time, bool) {
|
||||
if matched := ymdRegex.FindStringSubmatch(value); len(matched) == 4 {
|
||||
year, _ := strconv.Atoi(matched[1])
|
||||
month, _ := strconv.Atoi(matched[2])
|
||||
day, _ := strconv.Atoi(matched[3])
|
||||
if isValidDate(year, month, day) {
|
||||
return time.Date(year, time.Month(month), day, 0, 0, 0, 0, loc), true
|
||||
}
|
||||
}
|
||||
|
||||
if matched := mdRegex.FindStringSubmatch(value); len(matched) == 3 {
|
||||
month, _ := strconv.Atoi(matched[1])
|
||||
day, _ := strconv.Atoi(matched[2])
|
||||
year := now.Year()
|
||||
if !isValidDate(year, month, day) {
|
||||
return time.Time{}, false
|
||||
}
|
||||
candidate := time.Date(year, time.Month(month), day, 0, 0, 0, 0, loc)
|
||||
if candidate.Before(startOfDay(now)) {
|
||||
year++
|
||||
if !isValidDate(year, month, day) {
|
||||
return time.Time{}, false
|
||||
}
|
||||
candidate = time.Date(year, time.Month(month), day, 0, 0, 0, 0, loc)
|
||||
}
|
||||
return candidate, true
|
||||
}
|
||||
|
||||
if matched := weekdayRegex.FindStringSubmatch(value); len(matched) == 3 {
|
||||
prefix := matched[1]
|
||||
target, ok := toWeekday(matched[2])
|
||||
if ok {
|
||||
return resolveWeekdayDate(now, prefix, target), true
|
||||
}
|
||||
}
|
||||
|
||||
today := startOfDay(now)
|
||||
switch {
|
||||
case strings.Contains(value, "大后天"):
|
||||
return today.AddDate(0, 0, 3), true
|
||||
case strings.Contains(value, "后天"):
|
||||
return today.AddDate(0, 0, 2), true
|
||||
case strings.Contains(value, "明天") || strings.Contains(value, "明日"):
|
||||
return today.AddDate(0, 0, 1), true
|
||||
case strings.Contains(value, "今天") || strings.Contains(value, "今日") || strings.Contains(value, "今晚") || strings.Contains(value, "今早") || strings.Contains(value, "今晨"):
|
||||
return today, true
|
||||
case strings.Contains(value, "昨天") || strings.Contains(value, "昨日"):
|
||||
return today.AddDate(0, 0, -1), true
|
||||
default:
|
||||
return time.Time{}, false
|
||||
}
|
||||
}
|
||||
|
||||
func extractClock(value string) (int, int, bool, error) {
|
||||
hour := 0
|
||||
minute := 0
|
||||
hasClock := false
|
||||
|
||||
if matched := clockHMRegex.FindStringSubmatch(value); len(matched) == 3 {
|
||||
h, errH := strconv.Atoi(matched[1])
|
||||
m, errM := strconv.Atoi(matched[2])
|
||||
if errH != nil || errM != nil {
|
||||
return 0, 0, true, fmt.Errorf("deadline_at 时间解析失败: %s", value)
|
||||
}
|
||||
hour = h
|
||||
minute = m
|
||||
hasClock = true
|
||||
} else if matched := clockCNRegex.FindStringSubmatch(value); len(matched) >= 2 {
|
||||
h, errH := strconv.Atoi(matched[1])
|
||||
if errH != nil {
|
||||
return 0, 0, true, fmt.Errorf("deadline_at 时间解析失败: %s", value)
|
||||
}
|
||||
hour = h
|
||||
minute = 0
|
||||
hasClock = true
|
||||
if len(matched) >= 3 {
|
||||
if matched[2] == "半" {
|
||||
minute = 30
|
||||
} else if len(matched) >= 4 && strings.TrimSpace(matched[3]) != "" {
|
||||
m, errM := strconv.Atoi(strings.TrimSpace(matched[3]))
|
||||
if errM != nil {
|
||||
return 0, 0, true, fmt.Errorf("deadline_at 时间解析失败: %s", value)
|
||||
}
|
||||
minute = m
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !hasClock {
|
||||
return 0, 0, false, nil
|
||||
}
|
||||
|
||||
if isPMHint(value) && hour < 12 {
|
||||
hour += 12
|
||||
}
|
||||
if isNoonHint(value) && hour >= 1 && hour <= 10 {
|
||||
hour += 12
|
||||
}
|
||||
if strings.Contains(value, "凌晨") && hour == 12 {
|
||||
hour = 0
|
||||
}
|
||||
|
||||
if hour < 0 || hour > 23 || minute < 0 || minute > 59 {
|
||||
return 0, 0, true, fmt.Errorf("deadline_at 时间超出范围: %s", value)
|
||||
}
|
||||
return hour, minute, true, nil
|
||||
}
|
||||
|
||||
func defaultClockByHint(value string) (int, int) {
|
||||
switch {
|
||||
case strings.Contains(value, "凌晨"):
|
||||
return 1, 0
|
||||
case strings.Contains(value, "早上") || strings.Contains(value, "早晨") || strings.Contains(value, "上午") || strings.Contains(value, "今早") || strings.Contains(value, "明早"):
|
||||
return 9, 0
|
||||
case strings.Contains(value, "中午"):
|
||||
return 12, 0
|
||||
case strings.Contains(value, "下午"):
|
||||
return 15, 0
|
||||
case strings.Contains(value, "晚上") || strings.Contains(value, "今晚") || strings.Contains(value, "傍晚") || strings.Contains(value, "夜里"):
|
||||
return 20, 0
|
||||
default:
|
||||
return 23, 59
|
||||
}
|
||||
}
|
||||
|
||||
func isPMHint(value string) bool {
|
||||
return strings.Contains(value, "下午") || strings.Contains(value, "晚上") || strings.Contains(value, "今晚") || strings.Contains(value, "傍晚")
|
||||
}
|
||||
|
||||
func isNoonHint(value string) bool {
|
||||
return strings.Contains(value, "中午")
|
||||
}
|
||||
|
||||
func startOfDay(t time.Time) time.Time {
|
||||
loc := t.Location()
|
||||
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, loc)
|
||||
}
|
||||
|
||||
func isValidDate(year, month, day int) bool {
|
||||
if month < 1 || month > 12 || day < 1 || day > 31 {
|
||||
return false
|
||||
}
|
||||
candidate := time.Date(year, time.Month(month), day, 0, 0, 0, 0, time.UTC)
|
||||
return candidate.Year() == year && int(candidate.Month()) == month && candidate.Day() == day
|
||||
}
|
||||
|
||||
func toWeekday(chinese string) (time.Weekday, bool) {
|
||||
switch chinese {
|
||||
case "一":
|
||||
return time.Monday, true
|
||||
case "二":
|
||||
return time.Tuesday, true
|
||||
case "三":
|
||||
return time.Wednesday, true
|
||||
case "四":
|
||||
return time.Thursday, true
|
||||
case "五":
|
||||
return time.Friday, true
|
||||
case "六":
|
||||
return time.Saturday, true
|
||||
case "日", "天":
|
||||
return time.Sunday, true
|
||||
default:
|
||||
return time.Sunday, false
|
||||
}
|
||||
}
|
||||
|
||||
func resolveWeekdayDate(now time.Time, prefix string, target time.Weekday) time.Time {
|
||||
today := startOfDay(now)
|
||||
weekdayOffset := (int(today.Weekday()) + 6) % 7
|
||||
weekStart := today.AddDate(0, 0, -weekdayOffset)
|
||||
targetOffset := (int(target) + 6) % 7
|
||||
candidateThisWeek := weekStart.AddDate(0, 0, targetOffset)
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(prefix, "下"):
|
||||
return candidateThisWeek.AddDate(0, 0, 7)
|
||||
case strings.HasPrefix(prefix, "本"), strings.HasPrefix(prefix, "这"):
|
||||
return candidateThisWeek
|
||||
default:
|
||||
if candidateThisWeek.Before(today) {
|
||||
return candidateThisWeek.AddDate(0, 0, 7)
|
||||
}
|
||||
return candidateThisWeek
|
||||
}
|
||||
}
|
||||
117
backend/services/agent/shared/node_correction.go
Normal file
117
backend/services/agent/shared/node_correction.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package agentshared
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/services/agent/model"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
const (
|
||||
correctionHistoryKindKey = "newagent_history_kind"
|
||||
correctionHistoryKindCorrectionUser = "llm_correction_prompt"
|
||||
)
|
||||
|
||||
// AppendLLMCorrection 追加 LLM 修正提示到对话历史。
|
||||
//
|
||||
// 设计目的:
|
||||
// 1. 当 LLM 输出不符合预期(如不支持的 action、格式错误等),不应直接报错终止;
|
||||
// 2. 应该给 LLM 一个自我修正的机会,把错误反馈写回历史,让它重新生成;
|
||||
// 3. 该函数封装了“追加 assistant 消息 + 追加纠正提示”的通用流程。
|
||||
//
|
||||
// 参数说明:
|
||||
// - conversationContext: 对话上下文,用于追加历史消息;
|
||||
// - llmOutput: LLM 的原始输出内容,会作为 assistant 消息追加;
|
||||
// - validOptionsDesc: 合法选项的描述,用于构造纠正提示。
|
||||
func AppendLLMCorrection(
|
||||
conversationContext *agentmodel.ConversationContext,
|
||||
llmOutput string,
|
||||
validOptionsDesc string,
|
||||
) {
|
||||
if conversationContext == nil {
|
||||
return
|
||||
}
|
||||
|
||||
assistantContent := strings.TrimSpace(llmOutput)
|
||||
appendCorrectionAssistantIfNeeded(conversationContext, assistantContent)
|
||||
|
||||
correctionContent := fmt.Sprintf(
|
||||
"你的输出不符合预期。%s 请重新分析当前状态,输出正确的内容。",
|
||||
validOptionsDesc,
|
||||
)
|
||||
conversationContext.AppendHistory(&schema.Message{
|
||||
Role: schema.User,
|
||||
Content: correctionContent,
|
||||
Extra: map[string]any{
|
||||
correctionHistoryKindKey: correctionHistoryKindCorrectionUser,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// AppendLLMCorrectionWithHint 追加 LLM 修正提示(带自定义错误描述)。
|
||||
//
|
||||
// 相比 AppendLLMCorrection,该函数允许调用方提供更详细的错误描述,
|
||||
// 适用于需要明确告知 LLM 具体哪里出错的场景。
|
||||
func AppendLLMCorrectionWithHint(
|
||||
conversationContext *agentmodel.ConversationContext,
|
||||
llmOutput string,
|
||||
errorDesc string,
|
||||
validOptionsDesc string,
|
||||
) {
|
||||
if conversationContext == nil {
|
||||
return
|
||||
}
|
||||
|
||||
assistantContent := strings.TrimSpace(llmOutput)
|
||||
appendCorrectionAssistantIfNeeded(conversationContext, assistantContent)
|
||||
|
||||
correctionContent := fmt.Sprintf(
|
||||
"%s %s 请重新分析当前状态,输出正确的内容。",
|
||||
errorDesc,
|
||||
validOptionsDesc,
|
||||
)
|
||||
conversationContext.AppendHistory(&schema.Message{
|
||||
Role: schema.User,
|
||||
Content: correctionContent,
|
||||
Extra: map[string]any{
|
||||
correctionHistoryKindKey: correctionHistoryKindCorrectionUser,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// appendCorrectionAssistantIfNeeded 在纠错回灌前做最小降噪。
|
||||
//
|
||||
// 1. 空文本直接跳过,避免写入“占位噪音”;
|
||||
// 2. 若与“最近一条 assistant 文本”完全一致则跳过,避免同句反复回灌;
|
||||
// 3. 仅负责“是否回灌”判定,不负责生成纠错 user 提示。
|
||||
func appendCorrectionAssistantIfNeeded(
|
||||
conversationContext *agentmodel.ConversationContext,
|
||||
assistantContent string,
|
||||
) {
|
||||
if conversationContext == nil {
|
||||
return
|
||||
}
|
||||
assistantContent = strings.TrimSpace(assistantContent)
|
||||
if assistantContent == "" {
|
||||
return
|
||||
}
|
||||
|
||||
history := conversationContext.HistorySnapshot()
|
||||
for i := len(history) - 1; i >= 0; i-- {
|
||||
msg := history[i]
|
||||
if msg == nil || msg.Role != schema.Assistant {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(msg.Content) == assistantContent {
|
||||
return
|
||||
}
|
||||
// 只看最近一条 assistant,避免误去重很久以前的正常重复表达。
|
||||
break
|
||||
}
|
||||
|
||||
conversationContext.AppendHistory(&schema.Message{
|
||||
Role: schema.Assistant,
|
||||
Content: assistantContent,
|
||||
})
|
||||
}
|
||||
121
backend/services/agent/shared/node_llm_debug.go
Normal file
121
backend/services/agent/shared/node_llm_debug.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package agentshared
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/services/agent/model"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
// LogNodeLLMContext 将某个节点即将送入 LLM 的完整消息上下文按统一格式打印到日志。
|
||||
//
|
||||
// 步骤化说明:
|
||||
// 1. 统一输出 stage / phase / chat / round,方便按一次请求内的多次 LLM 调用串联排查;
|
||||
// 2. 完整展开 messages,不做截断,保证问题复现时能直接对照 prompt 组装结果;
|
||||
// 3. 该函数只负责调试日志,不参与任何业务判断,也不修改上下文内容。
|
||||
func LogNodeLLMContext(
|
||||
stage string,
|
||||
phase string,
|
||||
flowState *agentmodel.CommonState,
|
||||
messages []*schema.Message,
|
||||
) {
|
||||
chatID := ""
|
||||
roundUsed := 0
|
||||
if flowState != nil {
|
||||
chatID = flowState.ConversationID
|
||||
roundUsed = flowState.RoundUsed
|
||||
}
|
||||
|
||||
log.Printf(
|
||||
"[DEBUG] %s LLM context begin phase=%s chat=%s round=%d message_count=%d\n%s\n[DEBUG] %s LLM context end phase=%s chat=%s round=%d",
|
||||
stage,
|
||||
strings.TrimSpace(phase),
|
||||
chatID,
|
||||
roundUsed,
|
||||
len(messages),
|
||||
formatLLMMessagesForDebug(messages),
|
||||
stage,
|
||||
strings.TrimSpace(phase),
|
||||
chatID,
|
||||
roundUsed,
|
||||
)
|
||||
}
|
||||
|
||||
// formatLLMMessagesForDebug 将本轮送入 LLM 的完整消息上下文展开成可读多行日志。
|
||||
//
|
||||
// 说明:
|
||||
// 1. 按消息索引逐条输出,便于和上游上下文构造步骤逐项对齐;
|
||||
// 2. 完整输出 content / reasoning_content / tool_calls / extra,不做截断;
|
||||
// 3. 仅用于调试打点,不参与业务决策。
|
||||
func formatLLMMessagesForDebug(messages []*schema.Message) string {
|
||||
if len(messages) == 0 {
|
||||
return "(empty messages)"
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
for i, msg := range messages {
|
||||
sb.WriteString(fmt.Sprintf("----- message[%d] -----\n", i))
|
||||
if msg == nil {
|
||||
sb.WriteString("role: <nil>\n\n")
|
||||
continue
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("role: %s\n", msg.Role))
|
||||
|
||||
if strings.TrimSpace(msg.ToolCallID) != "" {
|
||||
sb.WriteString(fmt.Sprintf("tool_call_id: %s\n", msg.ToolCallID))
|
||||
}
|
||||
if strings.TrimSpace(msg.ToolName) != "" {
|
||||
sb.WriteString(fmt.Sprintf("tool_name: %s\n", msg.ToolName))
|
||||
}
|
||||
|
||||
if len(msg.ToolCalls) > 0 {
|
||||
sb.WriteString("tool_calls:\n")
|
||||
for j, call := range msg.ToolCalls {
|
||||
sb.WriteString(fmt.Sprintf(" - [%d] id=%s type=%s function=%s\n", j, call.ID, call.Type, call.Function.Name))
|
||||
sb.WriteString(" arguments:\n")
|
||||
sb.WriteString(indentMultilineForDebug(call.Function.Arguments, " "))
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
if strings.TrimSpace(msg.ReasoningContent) != "" {
|
||||
sb.WriteString("reasoning_content:\n")
|
||||
sb.WriteString(indentMultilineForDebug(msg.ReasoningContent, " "))
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
sb.WriteString("content:\n")
|
||||
sb.WriteString(indentMultilineForDebug(msg.Content, " "))
|
||||
sb.WriteString("\n")
|
||||
|
||||
if len(msg.Extra) > 0 {
|
||||
sb.WriteString("extra:\n")
|
||||
raw, err := json.MarshalIndent(msg.Extra, "", " ")
|
||||
if err != nil {
|
||||
sb.WriteString(indentMultilineForDebug("<marshal_error>", " "))
|
||||
} else {
|
||||
sb.WriteString(indentMultilineForDebug(string(raw), " "))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// indentMultilineForDebug 为多行文本统一添加前缀缩进,避免日志折行后难以阅读。
|
||||
func indentMultilineForDebug(text, prefix string) string {
|
||||
if text == "" {
|
||||
return prefix + "<empty>"
|
||||
}
|
||||
lines := strings.Split(text, "\n")
|
||||
for i := range lines {
|
||||
lines[i] = prefix + lines[i]
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
10
backend/services/agent/shared/node_thinking.go
Normal file
10
backend/services/agent/shared/node_thinking.go
Normal file
@@ -0,0 +1,10 @@
|
||||
package agentshared
|
||||
|
||||
import llmservice "github.com/LoveLosita/smartflow/backend/services/llm"
|
||||
|
||||
func ResolveThinkingMode(enabled bool) llmservice.ThinkingMode {
|
||||
if enabled {
|
||||
return llmservice.ThinkingModeEnabled
|
||||
}
|
||||
return llmservice.ThinkingModeDisabled
|
||||
}
|
||||
290
backend/services/agent/shared/node_unified_compact.go
Normal file
290
backend/services/agent/shared/node_unified_compact.go
Normal file
@@ -0,0 +1,290 @@
|
||||
package agentshared
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/pkg"
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/services/agent/model"
|
||||
agentprompt "github.com/LoveLosita/smartflow/backend/services/agent/prompt"
|
||||
agentstream "github.com/LoveLosita/smartflow/backend/services/agent/stream"
|
||||
llmservice "github.com/LoveLosita/smartflow/backend/services/llm"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
// UnifiedCompactInput 是统一压缩入口的参数。
|
||||
//
|
||||
// 设计说明:
|
||||
// 1. 从各节点输入中提取压缩所需的公共字段,消除对具体节点实现的直接依赖;
|
||||
// 2. 各节点(Plan/Chat/Deliver/Execute)构造此参数时,只需填充自己已有的运行时能力;
|
||||
// 3. StageName 和 StatusBlockID 用于区分日志来源与 SSE 状态推送目标。
|
||||
type UnifiedCompactInput struct {
|
||||
// Client 用于调用 LLM 压缩 msg1/msg2。
|
||||
Client *llmservice.Client
|
||||
// CompactionStore 用于持久化压缩摘要和 token 统计,为 nil 时跳过持久化。
|
||||
CompactionStore agentmodel.CompactionStore
|
||||
// FlowState 提供 userID / conversationID / roundUsed 等定位信息。
|
||||
FlowState *agentmodel.CommonState
|
||||
// Emitter 用于推送压缩进度 SSE 事件。
|
||||
Emitter *agentstream.ChunkEmitter
|
||||
// StageName 标识当前阶段,如 execute / plan / chat / deliver。
|
||||
StageName string
|
||||
// StatusBlockID 是 SSE 状态推送的 block ID,各节点使用自己的 block ID。
|
||||
StatusBlockID string
|
||||
}
|
||||
|
||||
// CompactUnifiedMessagesIfNeeded 检查统一消息结构的 token 预算,
|
||||
// 超限时对 msg1(历史对话)和 msg2(阶段工作区)执行 LLM 压缩。
|
||||
//
|
||||
// 消息布局约定(由统一消息构造器返回):
|
||||
// [0] system - msg0: 系统规则 + 工具简表
|
||||
// [1] assistant - msg1: 历史对话上下文
|
||||
// [2] assistant - msg2: 阶段工作区(Execute=ReAct Loop,其余通常为“暂无”)
|
||||
// [3] system - msg3: 阶段状态 + 记忆 + 指令
|
||||
//
|
||||
// 压缩策略:
|
||||
// 1. msg1 超过可用预算一半时触发 LLM 压缩(合并已有摘要 + 新内容);
|
||||
// 2. msg1 压缩后仍超限,则对 msg2 也做 LLM 压缩;
|
||||
// 3. 压缩结果持久化到 CompactionStore,下一轮可复用摘要避免重复计算。
|
||||
func CompactUnifiedMessagesIfNeeded(
|
||||
ctx context.Context,
|
||||
messages []*schema.Message,
|
||||
input UnifiedCompactInput,
|
||||
) []*schema.Message {
|
||||
if input.FlowState == nil {
|
||||
log.Printf("[COMPACT:%s] FlowState is nil, skip token stats refresh", input.StageName)
|
||||
return messages
|
||||
}
|
||||
|
||||
// 1. 非严格 4 段式时,退化成按角色汇总的统计,确保 context_token_stats 仍能刷新。
|
||||
if len(messages) != 4 {
|
||||
breakdown := estimateFallbackStageTokenBreakdown(messages)
|
||||
log.Printf(
|
||||
"[COMPACT:%s] fallback token stats refresh: total=%d budget=%d count=%d (msg0=%d msg1=%d msg2=%d msg3=%d)",
|
||||
input.StageName, breakdown.Total, breakdown.Budget, len(messages),
|
||||
breakdown.Msg0, breakdown.Msg1, breakdown.Msg2, breakdown.Msg3,
|
||||
)
|
||||
saveUnifiedTokenStats(ctx, input, breakdown)
|
||||
return messages
|
||||
}
|
||||
|
||||
// 2. 提取四条消息的文本内容,供预算检查与后续压缩使用。
|
||||
msg0 := messages[0].Content
|
||||
msg1 := messages[1].Content
|
||||
msg2 := messages[2].Content
|
||||
msg3 := messages[3].Content
|
||||
|
||||
// 3. 执行 token 预算检查,判断是否需要压缩历史对话或阶段工作区。
|
||||
breakdown, overBudget, needCompactMsg1, needCompactMsg2 := pkg.CheckStageTokenBudget(msg0, msg1, msg2, msg3)
|
||||
|
||||
log.Printf(
|
||||
"[COMPACT:%s] token budget check: total=%d budget=%d over=%v compactMsg1=%v compactMsg2=%v (msg0=%d msg1=%d msg2=%d msg3=%d)",
|
||||
input.StageName, breakdown.Total, breakdown.Budget, overBudget, needCompactMsg1, needCompactMsg2,
|
||||
breakdown.Msg0, breakdown.Msg1, breakdown.Msg2, breakdown.Msg3,
|
||||
)
|
||||
|
||||
if !overBudget {
|
||||
// 4. 未超限时仅记录 token 分布,不做压缩。
|
||||
saveUnifiedTokenStats(ctx, input, breakdown)
|
||||
return messages
|
||||
}
|
||||
|
||||
// 5. 先压缩 msg1(历史对话),它通常是最主要的 token 消耗来源。
|
||||
if needCompactMsg1 {
|
||||
msg1 = compactUnifiedMsg1(ctx, input, msg1)
|
||||
messages[1].Content = msg1
|
||||
breakdown = pkg.EstimateStageMessagesTokens(msg0, msg1, msg2, msg3)
|
||||
}
|
||||
|
||||
// 6. 若 msg1 压缩后仍超限,再压缩 msg2(阶段工作区 / ReAct 记录)。
|
||||
if needCompactMsg2 || breakdown.Total > pkg.StageTokenBudget {
|
||||
msg2 = compactUnifiedMsg2(ctx, input, msg2)
|
||||
messages[2].Content = msg2
|
||||
breakdown = pkg.EstimateStageMessagesTokens(msg0, msg1, msg2, msg3)
|
||||
}
|
||||
|
||||
// 7. 记录最终 token 分布,供后续调试与监控使用。
|
||||
saveUnifiedTokenStats(ctx, input, breakdown)
|
||||
|
||||
log.Printf(
|
||||
"[COMPACT:%s] after compaction: total=%d budget=%d (msg0=%d msg1=%d msg2=%d msg3=%d)",
|
||||
input.StageName, breakdown.Total, breakdown.Budget,
|
||||
breakdown.Msg0, breakdown.Msg1, breakdown.Msg2, breakdown.Msg3,
|
||||
)
|
||||
return messages
|
||||
}
|
||||
|
||||
// estimateFallbackStageTokenBreakdown 在非统一 4 段式场景下按消息角色做近似统计。
|
||||
//
|
||||
// 步骤说明:
|
||||
// 1. 先按消息类型汇总 token,保证总量准确;
|
||||
// 2. 再把最后一个 user 消息尽量视作 msg3,保留阶段指令语义;
|
||||
// 3. 其他历史内容归入 msg1 / msg2,确保上下文统计不会因为结构不标准而断更。
|
||||
func estimateFallbackStageTokenBreakdown(messages []*schema.Message) pkg.StageTokenBreakdown {
|
||||
breakdown := pkg.StageTokenBreakdown{Budget: pkg.StageTokenBudget}
|
||||
if len(messages) == 0 {
|
||||
return breakdown
|
||||
}
|
||||
|
||||
lastUserIndex := -1
|
||||
for i := len(messages) - 1; i >= 0; i-- {
|
||||
msg := messages[i]
|
||||
if msg == nil {
|
||||
continue
|
||||
}
|
||||
if msg.Role == schema.User {
|
||||
lastUserIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
for i, msg := range messages {
|
||||
if msg == nil {
|
||||
continue
|
||||
}
|
||||
tokens := pkg.EstimateMessageTokens(msg)
|
||||
breakdown.Total += tokens
|
||||
|
||||
switch msg.Role {
|
||||
case schema.System:
|
||||
breakdown.Msg0 += tokens
|
||||
case schema.User:
|
||||
if i == lastUserIndex {
|
||||
breakdown.Msg3 += tokens
|
||||
} else {
|
||||
breakdown.Msg1 += tokens
|
||||
}
|
||||
case schema.Tool:
|
||||
breakdown.Msg2 += tokens
|
||||
case schema.Assistant:
|
||||
if len(msg.ToolCalls) > 0 {
|
||||
breakdown.Msg2 += tokens
|
||||
} else {
|
||||
breakdown.Msg1 += tokens
|
||||
}
|
||||
default:
|
||||
breakdown.Msg1 += tokens
|
||||
}
|
||||
}
|
||||
|
||||
return breakdown
|
||||
}
|
||||
|
||||
// compactUnifiedMsg1 对 msg1(历史对话)执行 LLM 压缩。
|
||||
//
|
||||
// 步骤化说明:
|
||||
// 1. CompactionStore 为 nil 时跳过(测试环境 / 骨架期);
|
||||
// 2. 先加载该阶段已有的压缩摘要,与当前 msg1 合并后调 LLM 压缩;
|
||||
// 3. 压缩失败时降级为原始文本,不中断主流程;
|
||||
// 4. 压缩成功后持久化新摘要,供下一轮复用。
|
||||
func compactUnifiedMsg1(
|
||||
ctx context.Context,
|
||||
input UnifiedCompactInput,
|
||||
msg1 string,
|
||||
) string {
|
||||
if input.CompactionStore == nil {
|
||||
log.Printf("[COMPACT:%s] CompactionStore is nil, skip msg1 compaction", input.StageName)
|
||||
return msg1
|
||||
}
|
||||
|
||||
existingSummary, _, err := input.CompactionStore.LoadStageCompaction(ctx, input.FlowState.UserID, input.FlowState.ConversationID, input.StageName)
|
||||
if err != nil {
|
||||
log.Printf("[COMPACT:%s] load existing compaction failed: %v, proceed without cache", input.StageName, err)
|
||||
}
|
||||
|
||||
tokenBefore := pkg.EstimateTextTokens(msg1)
|
||||
_ = input.Emitter.EmitStatus(
|
||||
input.StatusBlockID, input.StageName, "context_compact_start",
|
||||
fmt.Sprintf("正在压缩对话历史(%d tokens)...", tokenBefore),
|
||||
false,
|
||||
)
|
||||
|
||||
newSummary, err := agentprompt.CompactMsg1(ctx, input.Client, msg1, existingSummary)
|
||||
if err != nil {
|
||||
log.Printf("[COMPACT:%s] compact msg1 failed: %v", input.StageName, err)
|
||||
_ = input.Emitter.EmitStatus(
|
||||
input.StatusBlockID, input.StageName, "context_compact_done",
|
||||
"对话历史压缩失败,使用原始文本",
|
||||
false,
|
||||
)
|
||||
return msg1
|
||||
}
|
||||
|
||||
tokenAfter := pkg.EstimateTextTokens(newSummary)
|
||||
_ = input.Emitter.EmitStatus(
|
||||
input.StatusBlockID, input.StageName, "context_compact_done",
|
||||
fmt.Sprintf("对话历史已压缩:%d → %d tokens", tokenBefore, tokenAfter),
|
||||
false,
|
||||
)
|
||||
|
||||
if err := input.CompactionStore.SaveStageCompaction(ctx, input.FlowState.UserID, input.FlowState.ConversationID, input.StageName, newSummary, input.FlowState.RoundUsed); err != nil {
|
||||
log.Printf("[COMPACT:%s] save compaction failed: %v", input.StageName, err)
|
||||
}
|
||||
|
||||
return newSummary
|
||||
}
|
||||
|
||||
// compactUnifiedMsg2 对 msg2(阶段工作区)执行 LLM 压缩。
|
||||
//
|
||||
// 步骤化说明:
|
||||
// 1. 非 Execute 阶段的 msg2 通常内容较少,压缩即使收益有限也不应出错;
|
||||
// 2. Execute 阶段的 msg2 包含 ReAct loop 记录,压缩可显著节省 token;
|
||||
// 3. 压缩失败时降级为原始文本,不中断主流程。
|
||||
func compactUnifiedMsg2(
|
||||
ctx context.Context,
|
||||
input UnifiedCompactInput,
|
||||
msg2 string,
|
||||
) string {
|
||||
tokenBefore := pkg.EstimateTextTokens(msg2)
|
||||
_ = input.Emitter.EmitStatus(
|
||||
input.StatusBlockID, input.StageName, "context_compact_start",
|
||||
fmt.Sprintf("正在压缩执行记录(%d tokens)...", tokenBefore),
|
||||
false,
|
||||
)
|
||||
|
||||
compressed, err := agentprompt.CompactMsg2(ctx, input.Client, msg2)
|
||||
if err != nil {
|
||||
log.Printf("[COMPACT:%s] compact msg2 failed: %v", input.StageName, err)
|
||||
_ = input.Emitter.EmitStatus(
|
||||
input.StatusBlockID, input.StageName, "context_compact_done",
|
||||
"执行记录压缩失败,使用原始文本",
|
||||
false,
|
||||
)
|
||||
return msg2
|
||||
}
|
||||
|
||||
tokenAfter := pkg.EstimateTextTokens(compressed)
|
||||
_ = input.Emitter.EmitStatus(
|
||||
input.StatusBlockID, input.StageName, "context_compact_done",
|
||||
fmt.Sprintf("执行记录已压缩:%d → %d tokens", tokenBefore, tokenAfter),
|
||||
false,
|
||||
)
|
||||
|
||||
return compressed
|
||||
}
|
||||
|
||||
// saveUnifiedTokenStats 持久化当前 token 分布到存储层。
|
||||
//
|
||||
// 步骤化说明:
|
||||
// 1. CompactionStore 为 nil 时跳过(测试环境 / 骨架期);
|
||||
// 2. 序列化失败只记日志,不中断主流程;
|
||||
// 3. 写入失败只记日志,不中断主流程。
|
||||
func saveUnifiedTokenStats(
|
||||
ctx context.Context,
|
||||
input UnifiedCompactInput,
|
||||
breakdown pkg.StageTokenBreakdown,
|
||||
) {
|
||||
if input.CompactionStore == nil || input.FlowState == nil {
|
||||
return
|
||||
}
|
||||
statsJSON, err := json.Marshal(breakdown)
|
||||
if err != nil {
|
||||
log.Printf("[COMPACT:%s] marshal token stats failed: %v", input.StageName, err)
|
||||
return
|
||||
}
|
||||
if err := input.CompactionStore.SaveContextTokenStats(ctx, input.FlowState.UserID, input.FlowState.ConversationID, string(statsJSON)); err != nil {
|
||||
log.Printf("[COMPACT:%s] save token stats failed: %v", input.StageName, err)
|
||||
}
|
||||
}
|
||||
37
backend/services/agent/shared/node_visible_message.go
Normal file
37
backend/services/agent/shared/node_visible_message.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package agentshared
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/services/agent/model"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
// PersistVisibleAssistantMessage 负责把“真正要展示给用户”的 assistant 文本交给 service 层持久化。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只处理可见的 assistant 消息,不处理内部纠错提示、工具调用结果和纯状态文案;
|
||||
// 2. 持久化失败只记日志,不反向中断节点主流程,避免“已经对外输出但后端补写失败”时把用户请求打断;
|
||||
// 3. 具体的 Redis / MySQL / 乐观缓存写入由 service 回调统一完成。
|
||||
func PersistVisibleAssistantMessage(
|
||||
ctx context.Context,
|
||||
persist agentmodel.PersistVisibleMessageFunc,
|
||||
state *agentmodel.CommonState,
|
||||
msg *schema.Message,
|
||||
) {
|
||||
if persist == nil || state == nil || msg == nil {
|
||||
return
|
||||
}
|
||||
|
||||
role := strings.TrimSpace(string(msg.Role))
|
||||
content := strings.TrimSpace(msg.Content)
|
||||
if role != string(schema.Assistant) || content == "" {
|
||||
return
|
||||
}
|
||||
|
||||
if err := persist(ctx, state, msg); err != nil {
|
||||
log.Printf("[WARN] persist visible assistant message failed chat=%s phase=%s err=%v", state.ConversationID, state.Phase, err)
|
||||
}
|
||||
}
|
||||
85
backend/services/agent/shared/retry.go
Normal file
85
backend/services/agent/shared/retry.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package agentshared
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RetryOptions 描述公共重试策略。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 这里只定义“是否重试、最多几次、间隔多久”;
|
||||
// 2. 不关心具体业务是工具调用失败、模型 JSON 失败还是 DB 暂时不可用;
|
||||
// 3. 真正的业务兜底文案仍应由上层 node 决定。
|
||||
type RetryOptions struct {
|
||||
MaxAttempts int
|
||||
Interval time.Duration
|
||||
ShouldRetry func(err error) bool
|
||||
OnRetry func(attempt int, err error)
|
||||
}
|
||||
|
||||
// Do 执行一个只返回 error 的重试任务。
|
||||
//
|
||||
// 执行规则:
|
||||
// 1. 第一次执行也算一次 attempt;
|
||||
// 2. 任意一次成功即立即返回;
|
||||
// 3. 上下文取消、达到最大次数、或 ShouldRetry=false 时立即停止。
|
||||
func Do(ctx context.Context, options RetryOptions, fn func(attempt int) error) error {
|
||||
_, err := DoValue[struct{}](ctx, options, func(attempt int) (struct{}, error) {
|
||||
return struct{}{}, fn(attempt)
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// DoValue 执行一个带返回值的通用重试任务。
|
||||
//
|
||||
// 设计说明:
|
||||
// 1. 旧 agent 里后续很多地方都会出现“失败重试 2~3 次”的模式;
|
||||
// 2. 这里先把循环骨架统一,避免每个 skill 自己写 for + sleep + ctx.Done;
|
||||
// 3. 上层只需关心“本轮失败要不要继续”,而不是重复造轮子。
|
||||
func DoValue[T any](ctx context.Context, options RetryOptions, fn func(attempt int) (T, error)) (T, error) {
|
||||
var zero T
|
||||
|
||||
maxAttempts := options.MaxAttempts
|
||||
if maxAttempts <= 0 {
|
||||
maxAttempts = 1
|
||||
}
|
||||
|
||||
for attempt := 1; attempt <= maxAttempts; attempt++ {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return zero, err
|
||||
}
|
||||
|
||||
value, err := fn(attempt)
|
||||
if err == nil {
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// 1. 到最后一次了,直接返回原错误,避免无意义等待。
|
||||
if attempt >= maxAttempts {
|
||||
return zero, err
|
||||
}
|
||||
// 2. 业务显式声明“不值得重试”时,立刻停止。
|
||||
if options.ShouldRetry != nil && !options.ShouldRetry(err) {
|
||||
return zero, err
|
||||
}
|
||||
// 3. 把重试钩子留给上层,用于打点或阶段提示。
|
||||
if options.OnRetry != nil {
|
||||
options.OnRetry(attempt, err)
|
||||
}
|
||||
// 4. 没有配置间隔则马上下一轮;配置了则等待,同时尊重 ctx 取消。
|
||||
if options.Interval <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
timer := time.NewTimer(options.Interval)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return zero, ctx.Err()
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
|
||||
return zero, nil
|
||||
}
|
||||
35
backend/services/agent/shared/task_priority.go
Normal file
35
backend/services/agent/shared/task_priority.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package agentshared
|
||||
|
||||
const (
|
||||
TaskPriorityImportantUrgent = 1
|
||||
TaskPriorityImportantNotUrgent = 2
|
||||
TaskPrioritySimpleNotImportant = 3
|
||||
TaskPriorityComplexNotImportant = 4
|
||||
)
|
||||
|
||||
// QuickNote 优先级别名,保持与旧 agent/model 命名兼容。
|
||||
const (
|
||||
QuickNotePriorityImportantUrgent = TaskPriorityImportantUrgent
|
||||
QuickNotePriorityImportantNotUrgent = TaskPriorityImportantNotUrgent
|
||||
QuickNotePrioritySimpleNotImportant = TaskPrioritySimpleNotImportant
|
||||
QuickNotePriorityComplexNotImportant = TaskPriorityComplexNotImportant
|
||||
)
|
||||
|
||||
func IsValidTaskPriority(priority int) bool {
|
||||
return priority >= TaskPriorityImportantUrgent && priority <= TaskPriorityComplexNotImportant
|
||||
}
|
||||
|
||||
func PriorityLabelCN(priority int) string {
|
||||
switch priority {
|
||||
case TaskPriorityImportantUrgent:
|
||||
return "重要且紧急"
|
||||
case TaskPriorityImportantNotUrgent:
|
||||
return "重要不紧急"
|
||||
case TaskPrioritySimpleNotImportant:
|
||||
return "简单不重要"
|
||||
case TaskPriorityComplexNotImportant:
|
||||
return "复杂不重要"
|
||||
default:
|
||||
return "未知优先级"
|
||||
}
|
||||
}
|
||||
49
backend/services/agent/shared/time.go
Normal file
49
backend/services/agent/shared/time.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package agentshared
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// MinuteLayout 是 Agent 内部统一的分钟级时间文本格式。
|
||||
//
|
||||
// 设计原因:
|
||||
// 1. agent 里大量场景只需要精确到分钟;
|
||||
// 2. 秒级精度会增加提示词噪声,也容易让“同一请求内的当前时间”出现抖动;
|
||||
// 3. 先统一成一份常量,后续 quicknote / schedule 都直接复用。
|
||||
MinuteLayout = "2006-01-02 15:04"
|
||||
)
|
||||
|
||||
var (
|
||||
shanghaiLocOnce sync.Once
|
||||
shanghaiLoc *time.Location
|
||||
)
|
||||
|
||||
// ShanghaiLocation 返回 Agent 内部统一使用的东八区时区。
|
||||
func ShanghaiLocation() *time.Location {
|
||||
shanghaiLocOnce.Do(func() {
|
||||
loc, err := time.LoadLocation("Asia/Shanghai")
|
||||
if err != nil {
|
||||
// 兜底使用固定东八区,避免极端环境下因为系统时区文件缺失导致整个链路失败。
|
||||
loc = time.FixedZone("CST", 8*3600)
|
||||
}
|
||||
shanghaiLoc = loc
|
||||
})
|
||||
return shanghaiLoc
|
||||
}
|
||||
|
||||
// NowToMinute 返回当前北京时间,并截断到分钟级。
|
||||
func NowToMinute() time.Time {
|
||||
return time.Now().In(ShanghaiLocation()).Truncate(time.Minute)
|
||||
}
|
||||
|
||||
// NormalizeToMinute 把任意时间统一到北京时间分钟粒度。
|
||||
func NormalizeToMinute(t time.Time) time.Time {
|
||||
return t.In(ShanghaiLocation()).Truncate(time.Minute)
|
||||
}
|
||||
|
||||
// FormatMinute 把时间格式化为统一分钟级文本。
|
||||
func FormatMinute(t time.Time) string {
|
||||
return NormalizeToMinute(t).Format(MinuteLayout)
|
||||
}
|
||||
Reference in New Issue
Block a user