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