Details (original message): Add catalog-driven orchestration: intent catalog injection, deterministic plan validation, and system-function steps.
188 lines
6.1 KiB
Go
188 lines
6.1 KiB
Go
package squad
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"agent-native/exec"
|
|
)
|
|
|
|
// recordingBackend 记录每次收到的 prompt(按目录),产出按 script。
|
|
type recordingBackend struct {
|
|
mu sync.Mutex
|
|
prompts map[string][]string
|
|
script map[string]map[string]string
|
|
tokens bool
|
|
}
|
|
|
|
func (b *recordingBackend) Name() string { return "rec" }
|
|
|
|
func (b *recordingBackend) Execute(ctx context.Context, req exec.Request) (*exec.Result, error) {
|
|
key := filepath.Base(filepath.Clean(req.WorkDir))
|
|
b.mu.Lock()
|
|
b.prompts[key] = append(b.prompts[key], req.Prompt)
|
|
b.mu.Unlock()
|
|
files := b.script[key]
|
|
patches := map[string][]byte{}
|
|
for _, t := range req.Targets {
|
|
content := files[t]
|
|
if content == "" {
|
|
content = "\n"
|
|
}
|
|
out := filepath.Join(req.WorkDir, t)
|
|
_ = os.MkdirAll(filepath.Dir(out), 0o755)
|
|
_ = os.WriteFile(out, []byte(content), 0o644)
|
|
patches[t] = []byte(content)
|
|
}
|
|
pt, ct := 0, 0
|
|
if b.tokens {
|
|
pt, ct = 120, 80
|
|
}
|
|
return &exec.Result{Success: true, Patches: patches, PromptTokens: pt, CompletionTokens: ct}, nil
|
|
}
|
|
|
|
func TestSteeringInjectedAtNextStep(t *testing.T) {
|
|
// 步骤1 产生非阻塞挂起点:用 gap 挂起,steer 后 respond → 步骤2 收到注入。
|
|
rec := &recordingBackend{prompts: map[string][]string{}, script: map[string]map[string]string{
|
|
"01_researcher": {"notes.md": "n", "fields.json": `{}`,
|
|
"gaps.json": `[{"item":"缺信息","blocking":true}]`},
|
|
"02_writer": {"output.md": goodReport},
|
|
"03_reviewer": {"output.md": goodReport},
|
|
}}
|
|
dir := t.TempDir()
|
|
reg := NewRoleRegistry()
|
|
for _, r := range []string{"researcher", "writer", "reviewer"} {
|
|
_ = reg.Register(RoleSpec{Role: r, Backend: "rec"})
|
|
}
|
|
backends := NewBackendResolver()
|
|
backends.RegisterInstance("rec", rec)
|
|
engine, _ := NewEngine(Options{DataDir: dir, Registry: reg,
|
|
Planner: mockScriptPlanner(gapSteps()...), Backends: backends, Sink: NopSink{}})
|
|
|
|
res, _ := engine.Run(context.Background(), Goal{Text: "g"}, RunOptions{})
|
|
if res.Status != RunWaitingHuman {
|
|
t.Fatalf("want held for steering window, got %s", res.Status)
|
|
}
|
|
if err := engine.Steer(res.RunID, "补充:结论部分要加一节成本分析"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// 终态运行拒绝 steer。
|
|
if err := engine.Steer("run_nope", "x"); err == nil {
|
|
t.Fatal("steer unknown run must fail")
|
|
}
|
|
res2, err := engine.Respond(context.Background(), res.RunID, RespondDecision{RequestID: res.Pending.ID, Choice: "resolved", Answer: "ok"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if res2.Status != RunCompleted {
|
|
t.Fatalf("want completed, got %s err=%s", res2.Status, res2.Error)
|
|
}
|
|
rec.mu.Lock()
|
|
writerPrompts := rec.prompts["02_writer"]
|
|
reviewerPrompts := rec.prompts["03_reviewer"]
|
|
rec.mu.Unlock()
|
|
if len(writerPrompts) == 0 || !strings.Contains(writerPrompts[0], "成本分析") {
|
|
t.Fatalf("steering not injected into next step: %q", truncate(writerPrompts[0], 100))
|
|
}
|
|
if len(reviewerPrompts) > 0 && strings.Contains(reviewerPrompts[0], "成本分析") {
|
|
t.Fatalf("steering must be consumed once, leaked to later step")
|
|
}
|
|
}
|
|
|
|
func TestUsageAggregatedPerRole(t *testing.T) {
|
|
rec := &recordingBackend{prompts: map[string][]string{}, tokens: true,
|
|
script: map[string]map[string]string{
|
|
"01_researcher": {"notes.md": "n", "fields.json": `{}`},
|
|
"02_writer": {"output.md": goodReport},
|
|
"03_reviewer": {"output.md": goodReport},
|
|
}}
|
|
dir := t.TempDir()
|
|
reg := NewRoleRegistry()
|
|
for _, r := range []string{"researcher", "writer", "reviewer"} {
|
|
_ = reg.Register(RoleSpec{Role: r, Backend: "rec"})
|
|
}
|
|
backends := NewBackendResolver()
|
|
backends.RegisterInstance("rec", rec)
|
|
engine, _ := NewEngine(Options{DataDir: dir, Registry: reg,
|
|
Planner: mockScriptPlanner(baseSteps()...), Backends: backends, Sink: NopSink{}})
|
|
res, err := engine.Run(context.Background(), Goal{Text: "g"}, RunOptions{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if res.Status != RunCompleted {
|
|
t.Fatalf("want completed, got %s", res.Status)
|
|
}
|
|
if res.Usage == nil || res.Usage["writer"].PromptTokens != 120 || res.Usage["writer"].Steps != 1 {
|
|
t.Fatalf("usage wrong: %+v", res.Usage)
|
|
}
|
|
// usage 持久化可恢复。
|
|
data, err := os.ReadFile(filepath.Join(res.WorkDir, "usage.json"))
|
|
if err != nil || !strings.Contains(string(data), "researcher") {
|
|
t.Fatalf("usage.json missing: %v", err)
|
|
}
|
|
}
|
|
|
|
// slowCountBackend 统计并发峰值(验证角色级并发上限)。
|
|
type slowCountBackend struct {
|
|
mu sync.Mutex
|
|
cur int
|
|
peak int
|
|
delay time.Duration
|
|
}
|
|
|
|
func (b *slowCountBackend) Name() string { return "slow" }
|
|
|
|
func (b *slowCountBackend) Execute(ctx context.Context, req exec.Request) (*exec.Result, error) {
|
|
b.mu.Lock()
|
|
b.cur++
|
|
if b.cur > b.peak {
|
|
b.peak = b.cur
|
|
}
|
|
b.mu.Unlock()
|
|
time.Sleep(b.delay)
|
|
b.mu.Lock()
|
|
b.cur--
|
|
b.mu.Unlock()
|
|
for _, t := range req.Targets {
|
|
out := filepath.Join(req.WorkDir, t)
|
|
_ = os.MkdirAll(filepath.Dir(out), 0o755)
|
|
_ = os.WriteFile(out, []byte(goodReport), 0o644)
|
|
}
|
|
return &exec.Result{Success: true, Patches: map[string][]byte{}}, nil
|
|
}
|
|
|
|
func TestRoleConcurrencyLimit(t *testing.T) {
|
|
// 两个并行步骤同角色,MaxConcurrentTasks=1 → 串行(峰值 1)。
|
|
slow := &slowCountBackend{delay: 80 * time.Millisecond}
|
|
dir := t.TempDir()
|
|
reg := NewRoleRegistry()
|
|
_ = reg.Register(RoleSpec{Role: "writer", Backend: "slow", MaxConcurrentTasks: 1})
|
|
backends := NewBackendResolver()
|
|
backends.RegisterInstance("slow", slow)
|
|
engine, _ := NewEngine(Options{DataDir: dir, Registry: reg,
|
|
Planner: mockScriptPlanner([]StepSpec{
|
|
{ID: "s1", Role: "writer", Title: "A", Parallel: true, Targets: []string{"a.md"}},
|
|
{ID: "s2", Role: "writer", Title: "B", Parallel: true, Targets: []string{"b.md"}},
|
|
}...), Backends: backends, Sink: ProgressFunc(func(ev Event) {
|
|
t.Logf("EVENT %s step=%s text=%q", ev.Type, ev.StepID, ev.Text)
|
|
})})
|
|
res, err := engine.Run(context.Background(), Goal{Text: "g"}, RunOptions{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if res.Status != RunCompleted {
|
|
t.Fatalf("want completed, got %s err=%s", res.Status, res.Error)
|
|
}
|
|
slow.mu.Lock()
|
|
peak := slow.peak
|
|
slow.mu.Unlock()
|
|
if peak != 1 {
|
|
t.Fatalf("role concurrency peak want 1, got %d", peak)
|
|
}
|
|
}
|