Files
Agent-System-Backend/internal/runtime/verify.go
T
weichenandCursor a3ae1b986d Check final replies before the direct run stops
Why: A direct run could stop on a missing file, a failed lint, or an unverified claim. The final turn now checks registered artifacts and asks a tool-less verifier, and nudges stale plans and todos.
Evidence: go test ./... passed. New runtime tests cover missing and empty artifacts, docx lint errors, verifier reject-then-pass, unparseable and failed verifier calls, nil verifier tools, todo reminders, and the false-deliverable fixture.
Impact: Direct runs with runtime.verify (default on for new runs) spend one extra lightweight call on every final turn. Setting it off keeps the previous stop behavior. Squad and SubLLMRunner are unchanged. The agent-native pin stays a34689c.
Refs: feature/agent-harness-p2
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-01 13:54:15 +00:00

253 lines
6.8 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 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请更新计划或待办,或在回复里说明为什么先不做。"
}