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

238 lines
8.0 KiB
Go

package squadadapter
import (
"fmt"
"math"
"os"
"path/filepath"
"strings"
agentllm "agent-native/runtime/llm"
"agent-native/runtime/tools"
nativeadp "agent-squad/adapters/native"
"agent-squad/squad"
"agent-system-backend/internal/quality"
)
// EntryOptions 是小队作为对话入口时的一次运行参数。
type EntryOptions struct {
DataDir string
Goal string
HostTools []tools.Tool
OnEvent func(ev squad.ProcessEvent)
Stream agentllm.StreamFunc
Router squad.Router
Checker squad.Checker
Hitl squad.HitlPolicy
MaxTurns int
MaxToolCalls int
RepairBudget int
// Model 是本次运行的主模型(与 AgentNativeOptions 解析出的路由同源:
// 同一份运行快照 + 画像覆盖),不得与工厂配的端点/密钥分属不同属主。
Model string
}
// EntryResult 是入口跑完后的文本。工件登记由调用方在虚假交付检查之后做。
type EntryResult struct {
Summary string
Body string
Name string
}
// RunEntry 用小队引擎跑完一条用户目标。简单任务是单步 assistant,复杂任务进 leader。
// 每一步都走 agent-native 内核。
func RunEntry(rc Ctx, opt EntryOptions) (EntryResult, error) {
if strings.TrimSpace(opt.Goal) == "" {
return EntryResult{}, fmt.Errorf("squad entry: goal text is empty")
}
cfg, enabled := nativeadp.Config{}, false
if AgentNativeOptions != nil {
cfg, enabled = AgentNativeOptions(rc)
}
if opt.Model != "" {
cfg.DefaultModel = opt.Model
}
if opt.Stream != nil {
cfg.Stream = opt.Stream
if cfg.DefaultModel == "" {
cfg.DefaultModel = "test"
}
enabled = true
}
if !enabled || cfg.DefaultModel == "" {
return EntryResult{}, fmt.Errorf("squad entry: 主模型未配置")
}
cfg.HostTools = opt.HostTools
cfg.OnEvent = opt.OnEvent
if opt.MaxTurns > 0 {
cfg.MaxTurns = opt.MaxTurns
}
if cfg.MaxTurns <= 0 {
cfg.MaxTurns = math.MaxInt32
}
cfg.MaxToolCalls = opt.MaxToolCalls
hostNames := make([]string, 0, len(opt.HostTools))
for _, tool := range opt.HostTools {
hostNames = append(hostNames, tool.Name())
}
registry := squad.NewRoleRegistry()
projectRun := rc.Run().ProjectID != ""
for _, role := range kernelRoles(roleCaps{
host: hostNames, exec: cfg.Exec != nil, skills: len(cfg.SkillsRoots) > 0,
search: cfg.Search != nil, fetch: cfg.Fetch != nil, workspace: !projectRun,
}) {
if err := registry.Register(role); err != nil {
return EntryResult{}, fmt.Errorf("squad entry: register %s: %w", role.Role, err)
}
}
backends := BuildBackends(rc)
nativeadp.RegisterFull(backends, cfg)
checker := opt.Checker
if checker == nil {
checker = quality.Checker{}
}
repair := opt.RepairBudget
if repair <= 0 {
repair = 2
}
runDir := filepath.Join(opt.DataDir, "squad-entry", rc.Run().ID)
engine, err := squad.NewEngine(squad.Options{
DataDir: runDir,
Registry: registry,
Planner: squad.NewRulePlanner(),
Backends: backends,
DefaultBackend: "agent-native",
StepTimeout: -1, // 负数 = 不设单步截止(引擎只在 0 时回落到 10 分钟)
HitlPolicy: opt.Hitl,
Checker: checker,
RepairBudget: repair,
Router: opt.Router,
Sink: squad.ProgressFunc(func(ev squad.Event) { onSquadEvent(rc, ev) }),
})
if err != nil {
return EntryResult{}, fmt.Errorf("squad entry: engine: %w", err)
}
res, err := engine.Run(rc.Context(), squad.Goal{Text: opt.Goal}, squad.RunOptions{})
for err == nil && res != 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 EntryResult{}, 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 EntryResult{}, fmt.Errorf("squad entry: %w", err)
}
if res == nil {
return EntryResult{}, fmt.Errorf("squad entry: empty result")
}
if res.Plan != nil {
_ = rc.UpdatePlan(mapPlan(res.Plan))
}
if res.Status != squad.RunCompleted && res.Status != squad.RunDegraded {
return EntryResult{Summary: res.Summary}, fmt.Errorf("squad entry: status %s: %s", res.Status, res.Error)
}
name := "squad-entry.md"
if res.Deliverable != "" && !strings.Contains(res.Deliverable, "..") {
name = filepath.Base(res.Deliverable)
}
return EntryResult{Summary: res.Summary, Body: readDeliverable(res), Name: name}, nil
}
func readDeliverable(res *squad.RunResult) string {
if res.WorkDir != "" && res.Deliverable != "" && !strings.Contains(res.Deliverable, "..") {
path := filepath.Join(res.WorkDir, filepath.FromSlash(res.Deliverable))
if b, err := os.ReadFile(path); err == nil {
return string(b)
}
}
return res.Deliverable
}
type roleCaps struct {
host []string
exec bool
skills bool
search bool
fetch bool
workspace bool
}
func kernelRoles(caps roleCaps) []squad.RoleSpec {
base := []string{}
if caps.workspace {
base = []string{"read_file", "write_file", "list_dir", "grep"}
}
writerTools := append([]string{}, base...)
writerTools = append(writerTools, "edit_file", "multi_edit", "glob")
if caps.exec {
writerTools = append(writerTools, "run_python", "run_command")
}
if caps.skills {
writerTools = append(writerTools, "load_skill")
}
writerTools = append(writerTools, caps.host...)
research := append([]string{}, base...)
if caps.search {
research = append(research, "web_search")
}
if caps.fetch {
research = append(research, "fetch_url")
}
instr := func(kind, fallback string) string {
if RoleInstructions != nil {
if body, ok := RoleInstructions(kind); ok && strings.TrimSpace(body) != "" {
return body
}
}
return fallback
}
writerBash := squad.BashPermissionDeny
if caps.exec {
writerBash = squad.BashPermissionAsk
}
writerSkills := []string(nil)
if caps.skills {
writerSkills = []string{"docx-author", "xlsx-author", "pptx-author"}
}
return []squad.RoleSpec{
{Role: "leader", Name: "组长", Backend: "agent-native", BashPermission: squad.BashPermissionDeny,
Tools: []string{"read_file", "write_file"}, Instructions: instr("leader", "先判断任务该直通还是拆开。复杂任务每次只委派一个成员,并写明技能与最低分。")},
{Role: "assistant", Name: "助手", Backend: "agent-native", BashPermission: writerBash,
Tools: writerTools, Skills: writerSkills,
Instructions: instr("assistant", "直接完成用户的这一件事。需要成文时按交付物工作流生成并登记,不要只在回复里声称文件已生成。")},
{Role: "planner", Name: "规划员", Backend: "agent-native", BashPermission: squad.BashPermissionDeny,
Tools: base, Instructions: instr("planner", "梳理目标与材料,产出结构化要点与执行建议。")},
{Role: "researcher", Name: "调研员", Backend: "agent-native", BashPermission: squad.BashPermissionDeny,
Tools: research, Instructions: instr("researcher", "围绕目标调研关键问题,输出简明结论与依据。")},
{Role: "writer", Name: "撰稿员", Backend: "agent-native", BashPermission: writerBash,
Tools: writerTools, Skills: writerSkills, Instructions: instr("writer", "把前序要点与调研结论整理为清晰成文的交付文档。")},
{Role: "analyst", Name: "分析员", Backend: "agent-native", BashPermission: squad.BashPermissionDeny,
Tools: append(append([]string{}, base...), caps.host...),
Instructions: instr("analyst", "按现象、影响、隐性顾虑、建议四层写洞察。开头给一句话洞察,结尾给下一步行动。")},
{Role: "reviewer", Name: "审校员", Backend: "agent-native", BashPermission: squad.BashPermissionDeny,
Tools: append(append([]string{}, base...), caps.host...),
Instructions: instr("reviewer", "终审交付文档:一致性、完整性与风险提示,给出通过或修改意见。")},
}
}