Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
884 changes: 884 additions & 0 deletions internal/subagent/context_slice.go

Large diffs are not rendered by default.

599 changes: 599 additions & 0 deletions internal/subagent/context_slice_test.go

Large diffs are not rendered by default.

12 changes: 6 additions & 6 deletions internal/subagent/output_contract.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,22 +11,22 @@ var supportedOutputSections = map[string]struct{}{
"artifacts": {},
}

// validateOutputContract 校验输出结构是否满足角色策略要求
// validateOutputContract 校验子代理输出是否满足统一结构化契约
func validateOutputContract(policy RolePolicy, output Output) error {
out := output.normalize()
requiredSections, err := normalizeRequiredSections(policy.RequiredSections)
if err != nil {
return err
}
for _, key := range requiredSections {
if !hasOutputSectionContent(out, key) {
return errorsf("output section %q is required", key)
out := output.normalize()
for _, section := range requiredSections {
if !hasOutputSectionContent(out, section) {
return errorsf("output section %q is required", section)
}
}
return nil
}

// normalizeRequiredSections 归一化并校验 required section 名称集合。
// normalizeRequiredSections 规整并校验 required section 名称集合。
func normalizeRequiredSections(sections []string) ([]string, error) {
items := dedupeAndTrim(sections)
keys := make([]string, 0, len(items))
Expand Down
14 changes: 14 additions & 0 deletions internal/subagent/output_contract_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,17 @@ func TestValidateOutputContractUnsupportedSection(t *testing.T) {
t.Fatalf("expected unsupported section error")
}
}

func TestValidateOutputContractOnlyRequiresConfiguredSections(t *testing.T) {
t.Parallel()

policy := RolePolicy{
Role: RoleCoder,
SystemPrompt: "prompt",
AllowedTools: []string{"bash"},
RequiredSections: []string{"summary"},
}
if err := validateOutputContract(policy, Output{Summary: "ok"}); err != nil {
t.Fatalf("validateOutputContract() error = %v", err)
}
}
21 changes: 19 additions & 2 deletions internal/subagent/scheduler.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ func (s *Scheduler) Run(ctx context.Context) (ScheduleResult, error) {
s.pruneReadySince(state, ready)
s.sortReadyTasks(ready, state.readySince)

started, err := s.startReadyTasks(ctx, ready, state)
started, err := s.startReadyTasks(ctx, ready, snapshot, state)
if err != nil {
s.cancelRunningTodos(state, err)
return finalize(result), err
Expand Down Expand Up @@ -245,7 +245,12 @@ func (s *Scheduler) ensureReadyStatus(item agentsession.TodoItem) (agentsession.
}

// startReadyTasks 在并发上限内领取并启动可执行任务。
func (s *Scheduler) startReadyTasks(ctx context.Context, ready []agentsession.TodoItem, state *schedulerState) (int, error) {
func (s *Scheduler) startReadyTasks(
ctx context.Context,
ready []agentsession.TodoItem,
snapshot map[string]agentsession.TodoItem,
state *schedulerState,
) (int, error) {
if len(ready) == 0 {
return 0, nil
}
Expand All @@ -269,10 +274,22 @@ func (s *Scheduler) startReadyTasks(ctx context.Context, ready []agentsession.To
role := s.cfg.RoleSelector(item)
budget := s.cfg.BudgetSelector(item, s.cfg.DefaultBudget).normalize(s.cfg.DefaultBudget)
capability := s.cfg.Capabilities(item).normalize()
contextSlice := s.cfg.ContextBuilder(TaskContextSliceInput{
Comment thread
fennoai[bot] marked this conversation as resolved.
Task: item,
Todos: snapshot,
ReadOnlyTodos: true,
ActivatedSkills: s.cfg.ContextSkills(item, snapshot),
RelatedFiles: s.cfg.ContextFiles(item, snapshot),
MaxChars: s.cfg.ContextMaxChars,
MaxTodoFragments: s.cfg.ContextMaxTodoFragments,
MaxDependencyArtifacts: s.cfg.ContextMaxDependencyArtifacts,
MaxRelatedFiles: s.cfg.ContextMaxRelatedFiles,
})
task := Task{
ID: item.ID,
Goal: strings.TrimSpace(item.Content),
ExpectedOutput: strings.Join(item.Acceptance, "\n"),
ContextSlice: contextSlice,
}

state.running[item.ID] = runningTask{id: item.ID, attempt: attempt}
Expand Down
144 changes: 144 additions & 0 deletions internal/subagent/scheduler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"slices"
"strings"
"sync"
"sync/atomic"
Expand Down Expand Up @@ -162,6 +163,13 @@ func TestSchedulerConfigNormalize(t *testing.T) {
if cfg.Clock == nil || cfg.RoleSelector == nil || cfg.BudgetSelector == nil || cfg.Backoff == nil || cfg.Observer == nil {
t.Fatalf("normalize() should set defaults")
}
if cfg.ContextBuilder == nil || cfg.ContextSkills == nil || cfg.ContextFiles == nil {
t.Fatalf("normalize() should set context defaults")
}
if cfg.ContextMaxChars <= 0 || cfg.ContextMaxTodoFragments <= 0 ||
cfg.ContextMaxDependencyArtifacts <= 0 || cfg.ContextMaxRelatedFiles <= 0 {
t.Fatalf("context budget defaults not normalized: %+v", cfg)
}

role := cfg.RoleSelector(agentsession.TodoItem{OwnerID: string(RoleReviewer)})
if role != cfg.DefaultRole {
Expand All @@ -177,6 +185,22 @@ func TestSchedulerConfigNormalize(t *testing.T) {
if got := cfg.BudgetSelector(agentsession.TodoItem{}, cfg.DefaultBudget); got != cfg.DefaultBudget {
t.Fatalf("BudgetSelector() = %+v, want %+v", got, cfg.DefaultBudget)
}
slice := cfg.ContextBuilder(TaskContextSliceInput{
Task: agentsession.TodoItem{ID: "t1", Content: "goal"},
Todos: map[string]agentsession.TodoItem{
"t1": {ID: "t1", Content: "goal"},
},
MaxChars: cfg.ContextMaxChars,
})
if slice.TaskID != "t1" {
t.Fatalf("ContextBuilder() task id = %q, want t1", slice.TaskID)
}
if skills := cfg.ContextSkills(agentsession.TodoItem{}, map[string]agentsession.TodoItem{}); skills != nil {
t.Fatalf("ContextSkills default should return nil, got %v", skills)
}
if files := cfg.ContextFiles(agentsession.TodoItem{}, map[string]agentsession.TodoItem{}); files != nil {
t.Fatalf("ContextFiles default should return nil, got %v", files)
}
}

func TestNewSchedulerValidationErrors(t *testing.T) {
Expand Down Expand Up @@ -353,6 +377,126 @@ func TestSchedulerRunDependencyUnlock(t *testing.T) {
}
}

func TestSchedulerBuildsTaskContextSlice(t *testing.T) {
t.Parallel()

store := newSchedulerStore(t, []agentsession.TodoItem{
{
ID: "dep",
Content: "完成依赖",
Status: agentsession.TodoStatusCompleted,
Artifacts: []string{"artifacts/dep-result.txt"},
Dependencies: nil,
},
{
ID: "main",
Content: "执行主任务",
Status: agentsession.TodoStatusPending,
Dependencies: []string{"dep"},
Acceptance: []string{"通过测试"},
},
})

inputCh := make(chan StepInput, 2)
factory := newScriptedFactory(func(ctx context.Context, taskID string, attempt int, input StepInput) (StepOutput, error) {
_ = ctx
_ = attempt
select {
case inputCh <- input:
default:
}
return successStep(taskID), nil
})

scheduler, err := NewScheduler(store, factory, SchedulerConfig{
MaxConcurrency: 1,
PollInterval: 2 * time.Millisecond,
ContextSkills: func(todo agentsession.TodoItem, snapshot map[string]agentsession.TodoItem) []string {
_ = todo
_ = snapshot
return []string{"skill-a", "skill-b"}
},
ContextFiles: func(todo agentsession.TodoItem, snapshot map[string]agentsession.TodoItem) []TaskContextFileSummary {
_ = snapshot
return []TaskContextFileSummary{
{Path: "internal/subagent/scheduler.go", Summary: "调度器实现"},
{Path: "internal/subagent/scheduler_types.go", Summary: "配置定义"},
}
},
ContextMaxChars: 4000,
})
if err != nil {
t.Fatalf("NewScheduler() error = %v", err)
}

ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_, err = scheduler.Run(ctx)
if err != nil {
t.Fatalf("Run() error = %v", err)
}

var captured StepInput
select {
case captured = <-inputCh:
case <-time.After(time.Second):
t.Fatalf("timeout waiting captured step input")
}

if captured.Task.ID != "main" {
t.Fatalf("captured task id = %q, want main", captured.Task.ID)
}
if captured.Task.ContextSlice.TaskID != "main" {
t.Fatalf("context task id = %q, want main", captured.Task.ContextSlice.TaskID)
}
if len(captured.Task.ContextSlice.DependencyArtifacts) == 0 {
t.Fatalf("expected dependency artifacts in context slice")
}
if !slices.Equal(captured.Task.ContextSlice.ActivatedSkills, []string{"skill-a", "skill-b"}) {
t.Fatalf("ActivatedSkills = %v, want [skill-a skill-b]", captured.Task.ContextSlice.ActivatedSkills)
}
if len(captured.Task.ContextSlice.RelatedFiles) == 0 {
t.Fatalf("expected related files in context slice")
}
}

func TestSchedulerContextBuilderUsesReadOnlySnapshot(t *testing.T) {
t.Parallel()

store := newSchedulerStore(t, []agentsession.TodoItem{
{ID: "task", Content: "task"},
})
factory := newScriptedFactory(func(ctx context.Context, taskID string, attempt int, input StepInput) (StepOutput, error) {
_ = ctx
_ = attempt
_ = input
return successStep(taskID), nil
})

var seenReadOnly bool
builder := func(input TaskContextSliceInput) TaskContextSlice {
seenReadOnly = input.ReadOnlyTodos
return BuildTaskContextSlice(input)
}
scheduler, err := NewScheduler(store, factory, SchedulerConfig{
MaxConcurrency: 1,
PollInterval: 2 * time.Millisecond,
ContextBuilder: builder,
})
if err != nil {
t.Fatalf("NewScheduler() error = %v", err)
}

ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if _, err := scheduler.Run(ctx); err != nil {
t.Fatalf("Run() error = %v", err)
}
if !seenReadOnly {
t.Fatalf("ContextBuilder input ReadOnlyTodos = false, want true")
}
}

func TestSchedulerRunConcurrencyLimit(t *testing.T) {
t.Parallel()

Expand Down
Loading
Loading