Files
Agent-System-Backend/internal/squadadapter/adapter.go
T
weichenandCursor 46ce1b2c8e Let subagents run in the background and be reviewed
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>
2026-10-01 15:29:01 +00:00

391 lines
15 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 squadadapter 把 Agent-Squad 引擎接入统一 Run/事件模型(设计 P3:
// runtime 接 Agent-Squad——spawn/黑板/HITL 由 runmgr 映射为统一 Run + 事件)。
//
// 装配映射:
// - RoleRegistry → 内置小队角色(规则规划器自主编排四阶段骨架)
// - Planner → squad RulePlanner(确定性;LLM 规划器为后续档)
// - BackendResolver→ 自定义 "agent-system" 后端工厂:每步执行 = rc.SpawnSubagent
// (子 Run 进任务树,走既有 mock/LLM 子代理执行器与事件)
// - ProgressSink → 步骤事件映射 plan.update / agent.progress
// - HITL(挂起) → 引擎 Pending HumanRequest ↔ rc.AskHITL 卡片;RespondDecision 回填
package squadadapter
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"agent-native/exec"
"agent-native/runtime/subagent"
"agent-squad/squad"
"agent-system-backend/internal/quality"
core "agent-system-core"
)
// Ctx 是适配器所需的窄端口(结构性接口;runmgr 的 runCtx 天然满足,
// 避免适配器反向依赖 runtime 包形成环)。
type Ctx interface {
Context() context.Context
Run() core.Run
Settings() core.EffectiveSettings
Emit(typ core.StreamEventType, payload any) error
AskHITL(card core.HITLCard) (core.HITLResponse, error)
SpawnSubagent(name, task string, hint string) (core.AgentResultPayload, error)
UpdatePlan(p core.PlanSnapshot) error
WriteArtifact(name, kind, mime string, content []byte) (core.Artifact, error)
}
// CardSink 是可选的产卡能力(提案 T2 编排卡):runCtx 实现之;测试桩无需实现。
type CardSink interface {
CreateCard(card core.ActivityCard) (core.ActivityCard, error)
SetCardStatus(id string, status core.CardStatus, tone, note string) error
}
// Orchestrate 在一次 conversation run 内驱动 squad 引擎完成目标。
// 返回摘要文本(由 runner 落为消息部件);error = 编排失败。
func Orchestrate(rc Ctx, dataDir, goalText string) (string, error) {
if strings.TrimSpace(goalText) == "" {
return "", fmt.Errorf("squad: goal text is empty")
}
// 编排卡(可选能力):开始 → 执行中 → 结果/失败显式终态;无 CardSink 时静默跳过。
var cardID string
if cs, ok := rc.(CardSink); ok {
title := goalText
if len(title) > 48 {
title = title[:48] + "…"
}
if card, err := cs.CreateCard(core.ActivityCard{Kind: core.CardKindProgress, Title: "小队编排 · " + title, Status: core.CardActive, Tone: "info"}); err == nil {
cardID = card.ID
}
}
runDir := filepath.Join(dataDir, "squad", rc.Run().ID)
registry := squad.NewRoleRegistry()
for _, role := range builtinRoles() {
if err := registry.Register(role); err != nil {
return "", fmt.Errorf("squad: register role %s: %w", role.Role, err)
}
}
backends := buildBackends(rc)
engine, err := squad.NewEngine(squad.Options{
DataDir: runDir,
Registry: registry,
Planner: squad.NewRulePlanner(),
Backends: backends,
DefaultBackend: "agent-system",
StepTimeout: 5 * time.Minute,
HitlPolicy: squad.HitlPolicy{Mode: squad.HitlInteractive},
Checker: quality.Checker{},
RepairBudget: 2,
Sink: squad.ProgressFunc(func(ev squad.Event) { onSquadEvent(rc, ev) }),
})
if err != nil {
return "", fmt.Errorf("squad: engine: %w", err)
}
// 驱动循环:引擎为步进语义(每次调用推进一个步骤波次)——
// 挂起态 → HITL 卡片 → Respond;执行态 → Resume 推进;直至终态。
res, err := engine.Run(rc.Context(), squad.Goal{Text: goalText}, squad.RunOptions{})
for err == nil && !squad.RunStatus(res.Status).Terminal() {
switch {
case isHoldStatus(string(res.Status)):
req := res.Pending
if req == nil {
if req, err = engine.Pending(res.RunID); err != nil || req == nil {
break
}
}
decision, aerr := askHuman(rc, req)
if aerr != nil {
return "", aerr
}
res, err = engine.Respond(rc.Context(), res.RunID, decision)
default:
res, err = engine.Resume(rc.Context(), res.RunID)
}
if err != nil {
break
}
// 步进间映射最新计划到右栏。
if res.Plan != nil {
_ = rc.UpdatePlan(mapPlan(res.Plan))
}
}
if err != nil {
return "", fmt.Errorf("squad: run: %w", err)
}
// 终态计划映射 + 交付物落工件。
if res.Plan != nil {
_ = rc.UpdatePlan(mapPlan(res.Plan))
}
summary := firstNonEmpty(res.Summary, res.Deliverable, "编排完成")
if res.Deliverable != "" {
if _, werr := rc.WriteArtifact("squad-"+res.RunID+".md", "document", "text/markdown", []byte(res.Deliverable)); werr != nil {
summary += "(交付物写入失败:" + werr.Error() + ")"
}
}
switch res.Status {
case squad.RunCompleted:
if cardID != "" {
if cs, ok := rc.(CardSink); ok {
_ = cs.SetCardStatus(cardID, core.CardDone, "ok", summary)
}
}
return summary, nil
default:
if cardID != "" {
if cs, ok := rc.(CardSink); ok {
_ = cs.SetCardStatus(cardID, core.CardFailed, "danger", res.Error)
}
}
return summary, fmt.Errorf("squad: run %s status %s: %s", res.RunID, res.Status, res.Error)
}
}
// stepBackend:小队步骤 → 统一子 Run(任务树可见;执行走 runmgr 的子代理执行器)。
type stepBackend struct {
rc Ctx
role squad.RoleSpec
}
func (b *stepBackend) Name() string { return "agent-system" }
// profileForRole 把小队角色键映射到子代理 profile。未列出的角色走 general。
func profileForRole(role string) string {
switch role {
case "planner", "organizer":
return "agent.sub.plan"
case "researcher", "analyst":
return "agent.sub.research"
case "writer":
return "agent.sub.writer"
case "reviewer", "leader":
return "agent.sub.verify"
default:
return "agent.sub.general"
}
}
func (b *stepBackend) Execute(ctx context.Context, req exec.Request) (*exec.Result, error) {
task := strings.TrimSpace(req.Prompt)
if task == "" {
task = req.Instructions
}
hint := strings.TrimSpace(req.Instructions)
name := firstNonEmpty(b.role.Name, b.role.Role)
var out core.AgentResultPayload
var err error
if sp, ok := b.rc.(interface {
SpawnAgent(agentID, name, task, hint string) (core.AgentResultPayload, error)
}); ok {
out, err = sp.SpawnAgent(profileForRole(b.role.Role), name, task, hint)
} else {
out, err = b.rc.SpawnSubagent(name, task, hint)
}
res := &exec.Result{Success: err == nil, Logs: firstNonEmpty(out.Summary, "子代理完成")}
if err != nil {
res.ErrorReason = err.Error()
return res, nil
}
// 交付物:子代理全文写入步骤子工作区的目标文件(引擎契约:后端自行落盘,
// 检查器按 allTargetsPresent 校验;Patches 同时回传供引擎重试路径复用)。
content := firstNonEmpty(out.Text, out.Summary, "(子代理无文本产出)")
targets := req.Targets
if len(targets) == 0 {
targets = []string{"output.md"}
}
res.Patches = make(map[string][]byte, len(targets))
for _, t := range targets {
body := []byte(content + "\n")
res.Patches[t] = body
dest := filepath.Join(req.WorkDir, filepath.FromSlash(t))
if merr := os.MkdirAll(filepath.Dir(dest), 0o755); merr == nil {
_ = os.WriteFile(dest, body, 0o644)
}
}
return res, nil
}
// askHuman:HumanRequest → HITL 卡片 → RespondDecision(选项直达 / 自由文本作答)。
func askHuman(rc Ctx, req *squad.HumanRequest) (squad.RespondDecision, error) {
choices := make([]core.HITLChoice, 0, len(req.Options))
for _, o := range req.Options {
choices = append(choices, core.HITLChoice{
ID: o.Value, Label: o.Label,
Description: firstNonEmpty(req.Context, "小队编排挂起"),
Impact: fmt.Sprintf("决策类型 %s:%s 将继续/终止本次编排", req.Kind, o.Value),
})
}
if len(choices) == 0 {
choices = append(choices,
core.HITLChoice{ID: "approve", Label: "继续", Description: "按当前计划继续执行", Impact: "小队继续执行后续步骤", Recommended: true},
core.HITLChoice{ID: "abort", Label: "中止", Description: "终止本次编排", Impact: "编排取消,已产出保留"},
)
}
resp, err := rc.AskHITL(core.HITLCard{
Question: req.Question,
Kind: core.HITLKindChoice,
Choices: choices,
AllowFreeInput: true,
FreeInputHint: "可直接输入回答或修改指令",
})
if err != nil {
return squad.RespondDecision{RequestID: req.ID, Choice: "abort"}, err
}
d := squad.RespondDecision{RequestID: req.ID}
if resp.ChoiceID != "" {
d.Choice = resp.ChoiceID
} else {
d.Answer = resp.FreeText
}
return d, nil
}
// onSquadEvent:引擎事件 → 统一事件(计划步态 + 过程注记)。
func onSquadEvent(rc Ctx, ev squad.Event) {
switch ev.Type {
case squad.EvStepStarted, squad.EvStepCompleted, squad.EvStepFailed, squad.EvStepSkipped:
note := fmt.Sprintf("小队步骤 %s(%s)%s", ev.StepID, ev.Role, strings.TrimPrefix(ev.Type, "step_"))
if ev.Text != "" {
note += ":" + ev.Text
}
_ = rc.Emit(core.EvAgentProgress, core.AgentProgressPayload{AgentRun: rc.Run().ID, Note: note, Phase: core.PhaseActing})
case squad.EvHumanInputRequired, squad.EvHumanResponded:
// 挂起/回填经 AskHITL 卡片主路径呈现;此处仅过程注记。
_ = rc.Emit(core.EvAgentProgress, core.AgentProgressPayload{AgentRun: rc.Run().ID, Note: ev.Type + ": " + ev.Text})
}
}
// mapPlan:WorkflowPlan → 右栏计划快照。
func mapPlan(p *squad.WorkflowPlan) core.PlanSnapshot {
snap := core.PlanSnapshot{Title: firstNonEmpty(p.Title, "小队编排计划"), Steps: make([]core.PlanStep, 0, len(p.Steps))}
for _, s := range p.Steps {
snap.Steps = append(snap.Steps, core.PlanStep{ID: s.ID, Text: firstNonEmpty(s.Title, s.ID), Status: mapStepStatus(s.Status)})
}
return snap
}
func mapStepStatus(s string) string {
switch s {
case "running":
return "in_progress"
case "completed":
return "done"
case "skipped":
return "skipped"
default:
return "pending"
}
}
func isHoldStatus(s string) bool {
return s == "waiting_human" || s == "awaiting_confirmation" || s == "awaiting_clarification" || s == "quality_review"
}
// AgentNativeOptions 可选组合根注入:返回配置且启用时,为每次小队运行注册
// "agent-native" 内核后端工厂(模型/搜索/执行/MCP/技能根由组合根从 settings
// 解析;Subagent 工厂经 rc 桥接产品 SpawnSubagent)。
var AgentNativeOptions func(rc Ctx) (squad.AgentNativeConfig, bool)
// SubagentFactory 把内核委派端口(delegate_task)桥接到产品 SpawnSubagent:
// 委派信息集(目标/输入/写范围/非目标/完成判据/交付物)拼装为任务书,
// 子运行进入任务树(用量与事件统一记账);深度天然为 1——子运行是小队外
// 的普通代理执行,不再嵌套小队。
func SubagentFactory(rc Ctx) subagent.Factory {
return func(ctx context.Context, req subagent.Request) (subagent.Result, error) {
var b strings.Builder
b.WriteString("委派子任务\n目标:" + req.Goal)
if req.Inputs != "" {
b.WriteString("\n输入:" + req.Inputs)
}
if req.WriteScope != "" {
b.WriteString("\n可写范围(不得越界):" + req.WriteScope)
}
if req.NonGoals != "" {
b.WriteString("\n非目标:" + req.NonGoals)
}
b.WriteString("\n完成判据:" + req.DoneCriteria)
if len(req.Deliverables) > 0 {
b.WriteString("\n交付物:" + strings.Join(req.Deliverables, ", "))
}
payload, err := rc.SpawnSubagent("delegatee", b.String(), "内核 delegate_task 委派")
if err != nil {
return subagent.Result{}, err
}
res := subagent.Result{Text: firstNonEmpty(payload.Text, payload.Summary)}
switch payload.Status {
case core.RunCompleted:
res.Success = true
default:
res.Err = "子代理结束状态:" + string(payload.Status)
}
return res, nil
}
}
// buildBackends 组装小队后端解析器:"agent-system"(SpawnSubagent 路径)恒注册;
// 组合根注入 AgentNativeOptions 时额外注册 "agent-native" 内核工厂——角色显式
// 声明 Backend:"agent-native" 才会走内核执行;默认后端仍是 agent-system,
// 不因工厂注册而切换(对齐 parity 渐进启用条款)。
func buildBackends(rc Ctx) *squad.BackendResolver {
backends := squad.NewBackendResolver()
backends.Register("agent-system", func(role squad.RoleSpec) (exec.Backend, error) {
return &stepBackend{rc: rc, role: role}, nil
})
if AgentNativeOptions != nil {
if cfg, ok := AgentNativeOptions(rc); ok {
backends.RegisterAgentNativeFull(cfg)
}
}
return backends
}
// RoleInstructions 小队角色指令来源:app 装配时注入提示词库查找
// (agent.squad.<kind>);nil 或未命中 = 使用内联回退文案(测试与骨架兼容)。
var RoleInstructions func(kind string) (string, bool)
func builtinRoles() []squad.RoleSpec {
instr := func(kind, fallback string) string {
if RoleInstructions != nil {
if body, ok := RoleInstructions(kind); ok && strings.TrimSpace(body) != "" {
return body
}
}
return fallback
}
// 执行配置显式化(agent-native 后端消费;agent-system 路径忽略不生效但保持声明一致):
// 规划/调研/审校一律 BashPermission=deny(无命令执行需求);
// 撰稿员需要 run_python 生成文档——ask 档(每次运行首次使用前人工放行)。
// 工具白名单按角色最小化;撰稿员技能指向启动种子的三套交付配方。
return []squad.RoleSpec{
{Role: "planner", Name: "规划员", Backend: "agent-system", BashPermission: squad.BashPermissionDeny,
Tools: []string{"read_file", "list_dir", "grep"},
Keywords: []string{"梳理", "规划", "分析"}, Instructions: instr("planner", "梳理目标与材料,产出结构化要点与执行建议。")},
{Role: "researcher", Name: "调研员", Backend: "agent-system", BashPermission: squad.BashPermissionDeny,
Tools: []string{"web_search", "tavily_search", "fetch_url", "read_file", "write_file"},
Keywords: []string{"调研", "检索", "资料"}, Instructions: instr("researcher", "围绕目标调研关键问题,输出简明结论与依据。")},
{Role: "writer", Name: "撰稿员", Backend: "agent-system", BashPermission: squad.BashPermissionAsk,
Tools: []string{"read_file", "write_file", "edit_file", "multi_edit", "list_dir", "grep", "glob", "load_skill", "run_python", "run_shell"},
Skills: []string{"docx-author", "xlsx-author", "pptx-author"},
Keywords: []string{"撰写", "成文", "报告"}, Instructions: instr("writer", "把前序要点与调研结论整理为清晰成文的交付文档。")},
{Role: "reviewer", Name: "审校员", Backend: "agent-system", BashPermission: squad.BashPermissionDeny,
Tools: []string{"read_file", "grep", "glob", "list_dir"},
Keywords: []string{"审校", "把关", "终审"}, Instructions: instr("reviewer", "终审交付文档:一致性、完整性与风险提示,给出通过或修改意见。")},
}
}
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}