Files
weichen 80faf3edbd Resolve squad routes from the run owner and project
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
2026-10-21 16:15:03 +00:00

350 lines
14 KiB
Go
Raw Permalink 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-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 ""
}