Why: Tool calls stayed invisible until execution finished, and turn, tool, timeout, and output caps stopped long tasks early. Evidence: go test ./internal/runtime/ ./internal/tools/ ./internal/prompts/ ./internal/squadadapter/ ./internal/runmgr/ ./internal/app/ ./internal/settings/ Impact: tool.call.delta and tool.output reach the client; default profile, subagents, and squad no longer cap turns or tool calls; exec timeout defaults to 600s with no hard ceiling. A profile or subagent maxTurns above 0 still applies. Refs: feature/stream-display-overhaul 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 := openLLMClient(route.Base, 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请更新计划或待办,或在回复里说明为什么先不做。"
|
||
}
|