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>
391 lines
15 KiB
Go
391 lines
15 KiB
Go
// 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 ""
|
||
}
|