Why: RoleSpec.Tools/Skills were dead fields, BashPermission only gated an HITL prompt with nothing to enforce, leader turns dropped process events and tool profile, and the HTTP API could not start leader mode. Evidence: go vet ./... clean; go test ./... 6 packages ok. New cases: step requests carry ToolAllowlist/Skills/SessionScope/ExecutionID and a step-wide shared deadline; buildAgentNativeOptions bash gating (deny/unset get no exec tools even when configured, ask/allow do, missing model fails closed); Derive inheriting tool_profile/tools/ skills/bash_permission with request overrides; role-level HitlPolicy making a bash=ask step fail under auto instead of holding; leader requests fully assembled with detail events bridged to the sink; HTTP createRun accepting mode/leader_role/max_leader_turns (engine-driven repro of the plan-validation precondition included). Impact: RegisterAgentNative keeps its signature and delegates to a new RegisterAgentNativeFull(AgentNativeConfig); nested agent-native gitlink pinned to f9ab2ef (full-agent batch); llm planner derive schema gains tool_profile/tools/skills/bash_permission. Refs: Dev task feature/full-agent-r1 batch B9; agent-native PR #3.
200 lines
6.8 KiB
Go
200 lines
6.8 KiB
Go
package squad
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"agent-native/exec"
|
|
"agent-native/runtime/llm"
|
|
"agent-native/runtime/tools"
|
|
)
|
|
|
|
// captureBackend 记录收到的请求(v2 字段贯通断言用)。
|
|
type captureBackend struct {
|
|
mu sync.Mutex
|
|
reqs []exec.Request
|
|
script map[string]string // 子目录名 → 单一目标内容
|
|
}
|
|
|
|
func (b *captureBackend) Name() string { return "capture" }
|
|
|
|
func (b *captureBackend) Execute(ctx context.Context, req exec.Request) (*exec.Result, error) {
|
|
b.mu.Lock()
|
|
b.reqs = append(b.reqs, req)
|
|
b.mu.Unlock()
|
|
key := filepath.Base(filepath.Clean(req.WorkDir))
|
|
content := b.script[key]
|
|
if content == "" {
|
|
content = "# 默认交付物\n\n脚本后端生成的合格内容,长度满足最小质检要求。\n"
|
|
}
|
|
patches := map[string][]byte{}
|
|
for _, t := range req.Targets {
|
|
out := filepath.Join(req.WorkDir, t)
|
|
_ = os.MkdirAll(filepath.Dir(out), 0o755)
|
|
_ = os.WriteFile(out, []byte(content), 0o644)
|
|
patches[t] = []byte(content)
|
|
}
|
|
return &exec.Result{Success: true, Patches: patches}, nil
|
|
}
|
|
|
|
func TestStepRequestCarriesRoleSpecV2Fields(t *testing.T) {
|
|
dir := t.TempDir()
|
|
reg := NewRoleRegistry()
|
|
role := RoleSpec{
|
|
Role: "researcher", Name: "调研", Backend: "capture",
|
|
Tools: []string{"read_file", "grep"}, Skills: []string{"docx-author"},
|
|
}
|
|
if err := reg.Register(role); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
capture := &captureBackend{}
|
|
backends := NewBackendResolver()
|
|
backends.RegisterInstance("capture", capture)
|
|
engine, err := NewEngine(Options{
|
|
DataDir: dir, Registry: reg,
|
|
Planner: mockScriptPlanner(StepSpec{ID: "s1", Role: "researcher", Title: "调研", Targets: []string{"output.md"}}),
|
|
Backends: backends, Sink: NopSink{},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
res, err := engine.Run(context.Background(), Goal{Text: "生成测试报告"}, RunOptions{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if res.Status != RunCompleted {
|
|
t.Fatalf("want completed, got %s (err=%s)", res.Status, res.Error)
|
|
}
|
|
if len(capture.reqs) == 0 {
|
|
t.Fatal("capture backend never invoked")
|
|
}
|
|
req := capture.reqs[0]
|
|
if len(req.ToolAllowlist) != 2 || req.ToolAllowlist[0] != "read_file" {
|
|
t.Fatalf("role.Tools must flow into ToolAllowlist, got %v", req.ToolAllowlist)
|
|
}
|
|
if len(req.Skills) != 1 || req.Skills[0] != "docx-author" {
|
|
t.Fatalf("role.Skills must flow into request, got %v", req.Skills)
|
|
}
|
|
if !strings.Contains(req.SessionScope, "s1") || !strings.Contains(req.ExecutionID, "s1") {
|
|
t.Fatalf("identifiers missing: scope=%q exec=%q", req.SessionScope, req.ExecutionID)
|
|
}
|
|
if req.Budget == nil || time.Until(req.Budget.Deadline) <= 0 {
|
|
t.Fatal("step budget deadline must be set and in the future")
|
|
}
|
|
}
|
|
|
|
func TestBuildAgentNativeOptionsBashPermissionGating(t *testing.T) {
|
|
base := AgentNativeConfig{
|
|
Client: &llm.Client{BaseURL: "http://x", APIKey: "k"},
|
|
DefaultModel: "m1", MaxTurns: 5, Timeout: time.Minute,
|
|
Exec: &tools.ExecConfig{Bash: "/bin/bash"},
|
|
SkillsRoots: []string{"/skills"},
|
|
}
|
|
deny, err := buildAgentNativeOptions(base, RoleSpec{Role: "r", BashPermission: BashPermissionDeny})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if deny.Exec != nil {
|
|
t.Fatal("deny role must not get exec tools even when config provides them")
|
|
}
|
|
ask, _ := buildAgentNativeOptions(base, RoleSpec{Role: "r", BashPermission: BashPermissionAsk})
|
|
if ask.Exec == nil {
|
|
t.Fatal("ask role gets exec tools (the HITL gate runs before the step)")
|
|
}
|
|
allow, _ := buildAgentNativeOptions(base, RoleSpec{Role: "r", BashPermission: BashPermissionAllow})
|
|
if allow.Exec == nil {
|
|
t.Fatal("allow role gets exec tools")
|
|
}
|
|
// 空白名 = 注册时默认 deny,同样不装配。
|
|
empty, _ := buildAgentNativeOptions(base, RoleSpec{Role: "r"})
|
|
if empty.Exec != nil {
|
|
t.Fatal("unset bash_permission defaults to deny at registration; no exec tools")
|
|
}
|
|
// 无 Exec 配置时任何档位都不装配。
|
|
none, _ := buildAgentNativeOptions(AgentNativeConfig{Client: base.Client, DefaultModel: "m"}, RoleSpec{Role: "r", BashPermission: BashPermissionAllow})
|
|
if none.Exec != nil {
|
|
t.Fatal("no Exec config = no exec tools regardless of permission")
|
|
}
|
|
// 模型解析:角色级覆盖默认。
|
|
roleModel, _ := buildAgentNativeOptions(base, RoleSpec{Role: "r", Model: "big"})
|
|
if roleModel.Model != "big" {
|
|
t.Fatalf("role model must win, got %s", roleModel.Model)
|
|
}
|
|
if _, err := buildAgentNativeOptions(AgentNativeConfig{Client: base.Client}, RoleSpec{Role: "r"}); err == nil {
|
|
t.Fatal("missing model must fail-closed")
|
|
}
|
|
}
|
|
|
|
func TestDeriveInheritsExecutionConfig(t *testing.T) {
|
|
reg := NewRoleRegistry()
|
|
base := RoleSpec{
|
|
Role: "worker", Backend: "agent-native", Model: "m", ToolProfile: "workspace",
|
|
Tools: []string{"read_file"}, Skills: []string{"s1"}, BashPermission: "ask",
|
|
}
|
|
if err := reg.Register(base); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
derived, err := reg.Derive(DeriveRequest{
|
|
RoleKey: "py-worker", BaseRole: "worker",
|
|
Tools: []string{"read_file", "run_python"},
|
|
Skills: []string{"s1", "xlsx-author"},
|
|
ToolProfile: "workspace",
|
|
BashPermission: "allow",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if derived.ToolProfile != "workspace" || derived.BashPermission != "allow" {
|
|
t.Fatalf("derive must honor explicit exec config: %+v", derived)
|
|
}
|
|
if len(derived.Tools) != 2 || len(derived.Skills) != 2 {
|
|
t.Fatalf("derive must take request slices over base: tools=%v skills=%v", derived.Tools, derived.Skills)
|
|
}
|
|
// 未显式给定时继承基线。
|
|
inherits, err := reg.Derive(DeriveRequest{RoleKey: "plain", BaseRole: "worker"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if inherits.BashPermission != "ask" || len(inherits.Tools) != 1 {
|
|
t.Fatalf("derive must inherit base exec config: %+v", inherits)
|
|
}
|
|
}
|
|
|
|
func TestRoleHitlPolicyOverridesGlobal(t *testing.T) {
|
|
dir := t.TempDir()
|
|
reg := NewRoleRegistry()
|
|
// bash=ask + 角色级 auto:不再挂起等审批,而是直接失败(auto 无值守语义)。
|
|
if err := reg.Register(RoleSpec{
|
|
Role: "runner", Name: "执行", Backend: "capture", BashPermission: BashPermissionAsk,
|
|
HitlPolicy: &HitlPolicy{Mode: HitlAuto},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
capture := &captureBackend{}
|
|
backends := NewBackendResolver()
|
|
backends.RegisterInstance("capture", capture)
|
|
engine, err := NewEngine(Options{
|
|
DataDir: dir, Registry: reg,
|
|
Planner: mockScriptPlanner(StepSpec{ID: "s1", Role: "runner", Title: "跑", Targets: []string{"output.md"}}),
|
|
Backends: backends, Sink: NopSink{},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
res, err := engine.Run(context.Background(), Goal{Text: "生成测试报告"}, RunOptions{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if res.Status != RunFailed || !strings.Contains(res.Error, "permission denied") {
|
|
t.Fatalf("role auto policy must fail the bash=ask step instead of holding, got %s err=%s", res.Status, res.Error)
|
|
}
|
|
if len(capture.reqs) != 0 {
|
|
t.Fatal("unapproved bash step must never execute")
|
|
}
|
|
}
|