Why: A parent turn should be able to finish while children keep working, and a review should keep only findings that a second pass confirms. Users also need to override a profile's tools, model, and turn cap without adding dispatch tools. Evidence: go test ./... passed. New cases cover overlay rejection, role mapping, queued notices, and dropping FAIL or unparsed findings. Impact: Notices live only in memory, and each conversation allows three background children. Squad writer steps now use the writer profile. Refs: feature/agent-harness-p3b Co-authored-by: Cursor <cursoragent@cursor.com>
188 lines
4.5 KiB
Go
188 lines
4.5 KiB
Go
package runtime
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"sort"
|
||
"strings"
|
||
"sync"
|
||
|
||
core "agent-system-core"
|
||
)
|
||
|
||
const reviewProfileID = "agent.sub.review"
|
||
const verifyProfileID = "agent.sub.verify"
|
||
|
||
type reviewFinding struct {
|
||
Location string `json:"location"`
|
||
Severity string `json:"severity"`
|
||
Issue string `json:"issue"`
|
||
Evidence string `json:"evidence"`
|
||
}
|
||
|
||
func (st *directRun) reviewFindings(args string) string {
|
||
var a struct {
|
||
Target string
|
||
Focus string
|
||
Reviewers int
|
||
}
|
||
if err := json.Unmarshal([]byte(args), &a); err != nil {
|
||
return "评审参数错误: " + err.Error()
|
||
}
|
||
sp, ok := st.rc.(interface {
|
||
SpawnAgent(agentID, name, task, hint string) (core.AgentResultPayload, error)
|
||
})
|
||
if !ok {
|
||
return "当前运行不能派发评审子代理"
|
||
}
|
||
return runReview(sp.SpawnAgent, a.Target, a.Focus, a.Reviewers)
|
||
}
|
||
|
||
func runReview(spawn func(agentID, name, task, hint string) (core.AgentResultPayload, error), target, focus string, reviewers int) string {
|
||
if reviewers < 1 {
|
||
reviewers = 1
|
||
}
|
||
if reviewers > 3 {
|
||
reviewers = 3
|
||
}
|
||
found := make([][]reviewFinding, reviewers)
|
||
parallel(reviewers, func(i int) {
|
||
task := fmt.Sprintf("评审以下材料,重点是%s。只输出 JSON 数组,元素含 location、severity、issue、evidence。\n\n%s", orDefault(focus, "正确性与遗漏"), target)
|
||
res, err := spawn(reviewProfileID, fmt.Sprintf("审查%d", i+1), task, focus)
|
||
if err != nil {
|
||
return
|
||
}
|
||
found[i] = parseFindings(res.Text + "\n" + res.Summary)
|
||
})
|
||
unique := dedupeFindings(found, 10)
|
||
kept := make([]reviewFinding, len(unique))
|
||
pass := make([]bool, len(unique))
|
||
parallel(len(unique), func(i int) {
|
||
f := unique[i]
|
||
body, _ := json.Marshal(f)
|
||
task := fmt.Sprintf("独立核实这条问题是否成立。材料如下:\n%s\n\n问题:%s\n只在结尾给出一行 VERDICT: PASS、FAIL 或 PARTIAL。", target, body)
|
||
res, err := spawn(verifyProfileID, "核实", task, f.Location)
|
||
if err != nil {
|
||
return
|
||
}
|
||
if reviewVerdict(res.Text+"\n"+res.Summary) == "PASS" {
|
||
kept[i] = f
|
||
pass[i] = true
|
||
}
|
||
})
|
||
var verified []reviewFinding
|
||
for i, ok := range pass {
|
||
if ok {
|
||
verified = append(verified, kept[i])
|
||
}
|
||
}
|
||
sort.SliceStable(verified, func(i, j int) bool {
|
||
return severityRank(verified[i].Severity) < severityRank(verified[j].Severity)
|
||
})
|
||
dropped := len(unique) - len(verified)
|
||
var b strings.Builder
|
||
if len(verified) == 0 {
|
||
b.WriteString("没有验证通过的问题。\n")
|
||
}
|
||
for _, f := range verified {
|
||
fmt.Fprintf(&b, "- [%s] %s:%s\n 证据:%s\n", orDefault(f.Severity, "medium"), orDefault(f.Location, "未定位"), f.Issue, f.Evidence)
|
||
}
|
||
fmt.Fprintf(&b, "已丢弃 %d 条未验证\n", dropped)
|
||
return b.String()
|
||
}
|
||
|
||
func parallel(n int, fn func(i int)) {
|
||
if n <= 0 {
|
||
return
|
||
}
|
||
sem := make(chan struct{}, 3)
|
||
var wg sync.WaitGroup
|
||
for i := 0; i < n; i++ {
|
||
wg.Add(1)
|
||
go func(i int) {
|
||
defer wg.Done()
|
||
sem <- struct{}{}
|
||
defer func() { <-sem }()
|
||
fn(i)
|
||
}(i)
|
||
}
|
||
wg.Wait()
|
||
}
|
||
|
||
func parseFindings(text string) []reviewFinding {
|
||
start := strings.Index(text, "[")
|
||
end := strings.LastIndex(text, "]")
|
||
if start < 0 || end <= start {
|
||
return nil
|
||
}
|
||
var out []reviewFinding
|
||
if err := json.Unmarshal([]byte(text[start:end+1]), &out); err != nil {
|
||
return nil
|
||
}
|
||
var kept []reviewFinding
|
||
for _, f := range out {
|
||
if strings.TrimSpace(f.Issue) == "" {
|
||
continue
|
||
}
|
||
kept = append(kept, f)
|
||
}
|
||
return kept
|
||
}
|
||
|
||
func dedupeFindings(groups [][]reviewFinding, limit int) []reviewFinding {
|
||
seen := map[string]bool{}
|
||
var out []reviewFinding
|
||
for _, group := range groups {
|
||
for _, f := range group {
|
||
key := strings.TrimSpace(f.Location) + "\n" + strings.TrimSpace(f.Issue)
|
||
if seen[key] {
|
||
continue
|
||
}
|
||
seen[key] = true
|
||
out = append(out, f)
|
||
if len(out) >= limit {
|
||
return out
|
||
}
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func reviewVerdict(text string) string {
|
||
lines := strings.Split(text, "\n")
|
||
for i := len(lines) - 1; i >= 0; i-- {
|
||
line := strings.TrimSpace(lines[i])
|
||
if line == "" || !strings.HasPrefix(strings.ToUpper(line), "VERDICT:") {
|
||
continue
|
||
}
|
||
v := strings.TrimSpace(line[len("VERDICT:"):])
|
||
v = strings.ToUpper(v)
|
||
switch {
|
||
case strings.HasPrefix(v, "PASS"):
|
||
return "PASS"
|
||
case strings.HasPrefix(v, "FAIL"):
|
||
return "FAIL"
|
||
case strings.HasPrefix(v, "PARTIAL"):
|
||
return "PARTIAL"
|
||
default:
|
||
return ""
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func severityRank(s string) int {
|
||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||
case "critical":
|
||
return 0
|
||
case "high":
|
||
return 1
|
||
case "medium":
|
||
return 2
|
||
case "low":
|
||
return 3
|
||
default:
|
||
return 4
|
||
}
|
||
}
|