Why: F13 - the squad factory read the default account with an empty project, pairing the run user's model name with another account's endpoint and credentials; direct and squad paths disagreed on reasoning split and route contract. Evidence: app/runtime/squadadapter tests pass (verify-u10b.json); run-scoped snapshot, project override, profile override and fail-closed cases added with synthetic keys. Impact: squad execution now uses the same effective-config rules as the direct path; default setups unchanged. Refs: feature/agent-stack-audit-remediation-b1 U10
350 lines
14 KiB
Go
350 lines
14 KiB
Go
// Package squadadapter 把 Agent-Squad 引擎接入统一 Run/事件模型(设计 P3:
|
||
// runtime 接 Agent-Squad——spawn/黑板/HITL 由 runmgr 映射为统一 Run + 事件)。
|
||
//
|
||
// 装配映射:
|
||
// - RoleRegistry → 内置小队角色(规则规划器自主编排四阶段骨架)
|
||
// - Planner → squad RulePlanner(确定性;LLM 规划器为后续档)
|
||
// - BackendResolver→ "agent-native" 内核后端工厂(Q3 WP2:SpawnSubagent
|
||
// 压缩路径已退役;每步执行 = agent-native 内核循环)
|
||
// - ProgressSink → 步骤事件映射 plan.update / agent.progress
|
||
// - HITL(挂起) → 引擎 Pending HumanRequest ↔ rc.AskHITL 卡片;RespondDecision 回填
|
||
package squadadapter
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"path/filepath"
|
||
"strings"
|
||
|
||
"agent-native/runtime/subagent"
|
||
nativeadp "agent-squad/adapters/native"
|
||
"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-native",
|
||
StepTimeout: -1, // 负数 = 不设单步截止(引擎只在 0 时回落到 10 分钟)
|
||
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)
|
||
}
|
||
}
|
||
|
||
// 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)。
|
||
//
|
||
// 组合根必须按「本次运行」解析配置:模型、端点与密钥整条来自 rc.Settings()
|
||
// 已合并的当前用户/项目快照(叠加 rc 的画像模型覆盖),per-route 规则与直连
|
||
// 共用同一构造器。按默认账号重新解析会让一个用户的模型名配上另一个账号的
|
||
// 端点/密钥。
|
||
var AgentNativeOptions func(rc Ctx) (nativeadp.Config, 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, ", "))
|
||
}
|
||
// Q3 WP3:任务书四要素补全——目标、输出格式、工具和边界。空声明
|
||
// 不落节(沿用子代理画像默认)。
|
||
if strings.TrimSpace(req.OutputFormat) != "" {
|
||
b.WriteString("\n输出格式:" + req.OutputFormat)
|
||
}
|
||
if len(req.Tools) > 0 {
|
||
b.WriteString("\n工具面(可用工具,不得越界):" + strings.Join(req.Tools, ", "))
|
||
}
|
||
// Background(N2 WP5):声明驱动后台派生,语义同产品 delegate 的
|
||
// background 参数;完成结果经通知队列回投,本次调用立即返回 run ID。
|
||
// rc 无后台能力(测试桩/旧宿主)时回落同步派生,不 fail-closed。
|
||
if req.Background {
|
||
if bg, ok := rc.(interface {
|
||
SpawnBackground(agentID, name, task, hint string) (core.ID, error)
|
||
}); ok {
|
||
id, err := bg.SpawnBackground("delegatee", "delegatee", b.String(), "内核 delegate_task 委派(后台)")
|
||
if err != nil {
|
||
return subagent.Result{}, err
|
||
}
|
||
return subagent.Result{Success: true, Text: "已在后台启动 " + id + ",完成后会自动通知"}, nil
|
||
}
|
||
}
|
||
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 组装小队后端解析器(Q3 WP2):只注册 "agent-native" 内核
|
||
// 工厂(组合根注入 AgentNativeOptions 且模型已配置时)。legacy 的
|
||
// "agent-system"(SpawnSubagent 压缩路径)已退役删除;工厂缺席时解析
|
||
// fail-closed——步骤失败可见,不静默降级到别的执行面。
|
||
func BuildBackends(rc Ctx) *squad.BackendResolver {
|
||
backends := squad.NewBackendResolver()
|
||
if AgentNativeOptions != nil {
|
||
if cfg, ok := AgentNativeOptions(rc); ok {
|
||
nativeadp.RegisterFull(backends, 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 后端消费):
|
||
// 规划/调研/审校一律 BashPermission=deny(无命令执行需求);
|
||
// 撰稿员需要 run_python/run_command 生成文档——ask 档(每次运行首次使用前人工放行)。
|
||
// 工具白名单按角色最小化(Q3 WP2:agent-native 下步骤须自己落盘交付物,
|
||
// 全员补 write_file);撰稿员技能指向启动种子的三套交付配方。
|
||
return []squad.RoleSpec{
|
||
{Role: "planner", Name: "规划员", Backend: "agent-native", BashPermission: squad.BashPermissionDeny,
|
||
Tools: []string{"read_file", "list_dir", "grep", "write_file"},
|
||
Keywords: []string{"梳理", "规划", "分析"}, Instructions: instr("planner", "梳理目标与材料,产出结构化要点与执行建议。")},
|
||
{Role: "researcher", Name: "调研员", Backend: "agent-native", 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-native", BashPermission: squad.BashPermissionAsk,
|
||
Tools: []string{"read_file", "write_file", "edit_file", "multi_edit", "list_dir", "grep", "glob", "load_skill", "run_python", "run_command"},
|
||
Skills: []string{"docx-author", "xlsx-author", "pptx-author"},
|
||
Keywords: []string{"撰写", "成文", "报告"}, Instructions: instr("writer", "把前序要点与调研结论整理为清晰成文的交付文档。")},
|
||
{Role: "reviewer", Name: "审校员", Backend: "agent-native", BashPermission: squad.BashPermissionDeny,
|
||
Tools: []string{"read_file", "grep", "glob", "list_dir", "write_file"},
|
||
Keywords: []string{"审校", "把关", "终审"}, Instructions: instr("reviewer", "终审交付文档:一致性、完整性与风险提示,给出通过或修改意见。")},
|
||
}
|
||
}
|
||
|
||
func firstNonEmpty(vals ...string) string {
|
||
for _, v := range vals {
|
||
if strings.TrimSpace(v) != "" {
|
||
return v
|
||
}
|
||
}
|
||
return ""
|
||
}
|