package runtime import ( "encoding/json" "fmt" "path/filepath" "strings" agentllm "agent-native/runtime/llm" "agent-system-backend/internal/quality" core "agent-system-core" ) const ( // maxVerifyContinuations 与内核 Stop 的 3 次上限对齐。 maxVerifyContinuations = 3 // reminderToolGap 连续这么多个工具轮没有更新计划或待办,就提醒一次。 reminderToolGap = 3 // maxChecklistReminders 每个 run 最多提醒这么多次。 maxChecklistReminders = 3 ) const verifyCapNotice = "本轮回复未通过终轮检查。" const verifierUnavailableNotice = "本轮回复未通过独立验证(验证器不可用)" const verifyFallbackPrompt = `你是独立验证器,不调用任何工具。根据用户要求、终轮回复、工具证据和工件清单判断这一轮是否已经完成。 拿不准时 passed 必须为 false。只输出一个 JSON 对象,不要写其他文字:{"passed":false,"reason":"为什么没通过","nextAction":"主模型下一步该做什么"}。` type verifyVerdict struct { Passed bool `json:"passed"` Reason string `json:"reason"` NextAction string `json:"nextAction"` } // checkDeliverables 确认本轮登记的工件存在且非空。 // md、txt、docx 还要过 quality;其他类型只检查存在与非空。 func checkDeliverables(rc RunCtx, refs []*core.ArtifactRef) []string { var reasons []string for _, ref := range refs { if ref == nil || ref.ArtifactID == "" { reasons = append(reasons, "工件引用缺少 ID") continue } art, content, ok := rc.Artifact(ref.ArtifactID) name := ref.Name if name == "" { name = string(ref.ArtifactID) } if !ok { reasons = append(reasons, name+" 不存在") continue } if art.Name != "" { name = art.Name } if len(content) == 0 { reasons = append(reasons, name+" 内容为空") continue } switch strings.ToLower(filepath.Ext(name)) { case ".md", ".txt", ".docx": report := quality.ScoreBytes(name, content) if !report.Passed { why := strings.Join(report.Violations, ";") if why == "" { why = "评分未通过" } reasons = append(reasons, name+":"+why) } } } return reasons } func (l *LLMRunner) runVerifier(rc RunCtx, body string, evidence []toolEvidence, refs []*core.ArtifactRef) (verifyVerdict, error) { route := ResolveLightweightModel(rc.Profile(), rc.Settings()) stream := l.VerifyStream if stream == nil { if route.Model == "" || route.Base == "" { return verifyVerdict{}, fmt.Errorf("验证器调用失败") } client := &agentllm.Client{BaseURL: route.Base, APIKey: route.Key} stream = client.Stream } model := route.Model if model == "" { model = "verify" } msgs := []agentllm.Message{ {Role: agentllm.RoleSystem, Content: l.verifyPrompt(rc.Profile().Owner)}, {Role: agentllm.RoleUser, Content: evidencePrompt(currentUserText(rc), body, evidence, refs)}, } var b strings.Builder for ev := range stream(rc.Context(), model, msgs, nil, agentllm.StreamOptions{}) { switch ev.Kind { case agentllm.KindTextDelta: b.WriteString(ev.Text) case agentllm.KindError: return verifyVerdict{}, fmt.Errorf("验证器调用失败") case agentllm.KindMessageEnd: if b.Len() == 0 { b.WriteString(ev.Message.Content) } } } verdict, err := parseVerdict(b.String()) if err != nil { return verifyVerdict{}, fmt.Errorf("验证器返回无法解析") } return verdict, nil } func (l *LLMRunner) verifyPrompt(owner string) string { if l.Prompts != nil { if body, ok, err := l.Prompts.ModuleBody(owner, "aux.verify.final"); err == nil && ok && strings.TrimSpace(body) != "" { return body } } return verifyFallbackPrompt } func evidencePrompt(user, body string, evidence []toolEvidence, refs []*core.ArtifactRef) string { var b strings.Builder b.WriteString("用户要求:\n") if strings.TrimSpace(user) == "" { b.WriteString("(无)\n") } else { b.WriteString(user) b.WriteString("\n") } b.WriteString("\n终轮回复:\n") b.WriteString(body) b.WriteString("\n\n本轮工具:\n") if len(evidence) == 0 { b.WriteString("(无)\n") } for _, ev := range evidence { fmt.Fprintf(&b, "- %s %s %s\n", ev.Name, ev.Status, ev.Summary) } b.WriteString("\n本轮工件:\n") if len(refs) == 0 { b.WriteString("(无)\n") } for _, ref := range refs { if ref == nil { continue } fmt.Fprintf(&b, "- %s %s\n", ref.ArtifactID, ref.Name) } return b.String() } func parseVerdict(raw string) (verifyVerdict, error) { s := stripFence(raw) start := strings.Index(s, "{") end := strings.LastIndex(s, "}") if start < 0 || end < start { return verifyVerdict{}, fmt.Errorf("验证器返回无法解析") } s = s[start : end+1] var probe map[string]json.RawMessage if err := json.Unmarshal([]byte(s), &probe); err != nil { return verifyVerdict{}, err } if _, ok := probe["passed"]; !ok { return verifyVerdict{}, fmt.Errorf("验证器返回无法解析") } var verdict verifyVerdict if err := json.Unmarshal([]byte(s), &verdict); err != nil { return verifyVerdict{}, err } return verdict, nil } func stripFence(s string) string { s = strings.TrimSpace(s) if !strings.HasPrefix(s, "```") { return s } s = strings.TrimPrefix(s, "```") if i := strings.IndexByte(s, '\n'); i >= 0 { s = s[i+1:] } s = strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(s), "```")) return s } func (st *directRun) loadChecklist() { if src, ok := st.rc.(interface{ Todos() (core.TodoList, bool) }); ok { if list, ok := src.Todos(); ok { st.todos = list } } if src, ok := st.rc.(interface { Plan() (core.PlanSnapshot, bool) }); ok { if p, ok := src.Plan(); ok { st.plan = p } } } func (st *directRun) reminders(turn int) []string { if st.closedToolTurn { if st.checklistTouched { st.toolTurnsSince = 0 } else { st.toolTurnsSince++ } st.closedToolTurn = false st.checklistTouched = false } if st.toolTurnsSince < reminderToolGap || st.checklistReminders >= maxChecklistReminders { return nil } text := st.unfinishedReminder() if text == "" { return nil } st.toolTurnsSince = 0 st.checklistReminders++ return []string{text} } func (st *directRun) unfinishedReminder() string { var lines []string for _, item := range st.todos.Items { if item.Status == "done" { continue } text := strings.TrimSpace(item.Text) if text == "" { continue } lines = append(lines, "- 待办:"+text) } for _, step := range st.plan.Steps { if step.Status == "done" || step.Status == "skipped" { continue } text := strings.TrimSpace(step.Text) if text == "" { continue } lines = append(lines, "- 计划:"+text) } if len(lines) == 0 { return "" } return "计划和待办已连续 3 个工具轮没有更新。未完成项:\n" + strings.Join(lines, "\n") + "\n请更新计划或待办,或在回复里说明为什么先不做。" }