From f35e9223f4f5d8759dee78d6ffe9f8bd6382190a Mon Sep 17 00:00:00 2001 From: Cai_Tang <106404101+Cai-Tang-www@users.noreply.github.com> Date: Fri, 17 Apr 2026 15:25:14 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(subagent):=20=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E4=B8=8A=E4=B8=8B=E6=96=87=E5=88=87=E7=89=87?= =?UTF-8?q?=E4=B8=8E=E7=BB=93=E6=9E=84=E5=8C=96=E7=BB=93=E6=9E=9C=E5=A5=91?= =?UTF-8?q?=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/subagent/context_slice.go | 705 ++++++++++++++++++++++ internal/subagent/context_slice_test.go | 279 +++++++++ internal/subagent/output_contract.go | 24 +- internal/subagent/output_contract_test.go | 24 + internal/subagent/scheduler.go | 20 +- internal/subagent/scheduler_test.go | 107 ++++ internal/subagent/scheduler_types.go | 88 ++- internal/subagent/types.go | 1 + 8 files changed, 1218 insertions(+), 30 deletions(-) create mode 100644 internal/subagent/context_slice.go create mode 100644 internal/subagent/context_slice_test.go diff --git a/internal/subagent/context_slice.go b/internal/subagent/context_slice.go new file mode 100644 index 00000000..661ee255 --- /dev/null +++ b/internal/subagent/context_slice.go @@ -0,0 +1,705 @@ +package subagent + +import ( + "fmt" + "sort" + "strings" + + agentsession "neo-code/internal/session" +) + +const ( + defaultTaskContextMaxChars = 3200 + defaultTaskContextMaxTodoFragments = 6 + defaultTaskContextMaxDependencyArtifacts = 8 + defaultTaskContextMaxRelatedFiles = 6 +) + +// TaskContextFileSummary 描述任务相关文件的稳定摘要信息。 +type TaskContextFileSummary struct { + Path string + Summary string +} + +// TaskTodoFragment 描述注入子代理的 Todo 片段。 +type TaskTodoFragment struct { + ID string + Status agentsession.TodoStatus + Content string + Priority int +} + +// TaskContextDescriptor 描述可重建 Task Context Slice 的最小引用集合。 +type TaskContextDescriptor struct { + TaskID string + DependencyTaskIDs []string + TodoFragmentIDs []string + ArtifactRefs []string + RelatedFilePaths []string + SkillIDs []string +} + +// normalize 规整 descriptor 字段,保证 compact 后重建输入稳定。 +func (d TaskContextDescriptor) normalize() TaskContextDescriptor { + d.TaskID = strings.TrimSpace(d.TaskID) + d.DependencyTaskIDs = dedupeAndTrim(d.DependencyTaskIDs) + d.TodoFragmentIDs = dedupeAndTrim(d.TodoFragmentIDs) + d.ArtifactRefs = dedupeAndTrim(d.ArtifactRefs) + d.RelatedFilePaths = dedupeAndTrim(d.RelatedFilePaths) + d.SkillIDs = dedupeAndTrim(d.SkillIDs) + return d +} + +// TaskContextSlice 描述子代理任务可消费的最小必要上下文。 +type TaskContextSlice struct { + TaskID string + Goal string + Acceptance []string + DependencyArtifacts []string + RelatedFiles []TaskContextFileSummary + ActivatedSkills []string + TodoFragment []TaskTodoFragment + Descriptor TaskContextDescriptor + BudgetChars int + Truncated bool +} + +// Render 以稳定顺序渲染切片,供执行引擎直接消费。 +func (s TaskContextSlice) Render() string { + var b strings.Builder + if strings.TrimSpace(s.TaskID) != "" { + b.WriteString("task_id: ") + b.WriteString(strings.TrimSpace(s.TaskID)) + b.WriteString("\n") + } + if strings.TrimSpace(s.Goal) != "" { + b.WriteString("goal: ") + b.WriteString(strings.TrimSpace(s.Goal)) + b.WriteString("\n") + } + writeBulletSection(&b, "acceptance", s.Acceptance) + writeBulletSection(&b, "dependency_artifacts", s.DependencyArtifacts) + writeFileSummarySection(&b, "related_files", s.RelatedFiles) + writeBulletSection(&b, "activated_skills", s.ActivatedSkills) + writeTodoFragmentSection(&b, "todo_fragment", s.TodoFragment) + return strings.TrimSpace(b.String()) +} + +// TaskContextSliceInput 描述构建任务上下文切片所需的输入。 +type TaskContextSliceInput struct { + Task agentsession.TodoItem + Todos map[string]agentsession.TodoItem + ActivatedSkills []string + RelatedFiles []TaskContextFileSummary + MaxChars int + MaxTodoFragments int + MaxDependencyArtifacts int + MaxRelatedFiles int +} + +// TaskContextRebuildInput 描述通过 descriptor 重建切片所需输入。 +type TaskContextRebuildInput struct { + Descriptor TaskContextDescriptor + Todos map[string]agentsession.TodoItem + ActivatedSkills []string + RelatedFiles []TaskContextFileSummary + MaxChars int + MaxTodoFragments int + MaxDependencyArtifacts int + MaxRelatedFiles int +} + +// BuildTaskContextSlice 基于 Todo DAG 快照构建子代理最小上下文切片。 +func BuildTaskContextSlice(input TaskContextSliceInput) TaskContextSlice { + in := normalizeTaskContextSliceInput(input) + taskID := strings.TrimSpace(in.Task.ID) + dependencyIDs := dedupeAndTrim(in.Task.Dependencies) + slice := TaskContextSlice{ + TaskID: taskID, + Goal: strings.TrimSpace(in.Task.Content), + Acceptance: dedupeAndTrim(in.Task.Acceptance), + BudgetChars: in.MaxChars, + } + + primaryFragments, additionalFragments := collectTodoFragments( + in.Task, + in.Todos, + dependencyIDs, + in.MaxTodoFragments, + ) + slice.TodoFragment = append(slice.TodoFragment, primaryFragments...) + slice.DependencyArtifacts = collectDependencyArtifacts(in.Todos, dependencyIDs, in.MaxDependencyArtifacts) + slice.RelatedFiles = mergeRelatedFiles(in.RelatedFiles, slice.DependencyArtifacts, in.MaxRelatedFiles) + slice.ActivatedSkills = append(slice.ActivatedSkills, in.ActivatedSkills...) + slice.TodoFragment = append(slice.TodoFragment, additionalFragments...) + + slice.Truncated = enforceTaskContextBudget(&slice, in.MaxChars) + slice.Descriptor = buildTaskContextDescriptor(slice, dependencyIDs) + return slice +} + +// RebuildTaskContextSlice 仅基于 descriptor 与当前快照重建上下文切片。 +func RebuildTaskContextSlice(input TaskContextRebuildInput) (TaskContextSlice, error) { + descriptor := input.Descriptor.normalize() + if descriptor.TaskID == "" { + return TaskContextSlice{}, errorsf("task context descriptor task id is required") + } + task, ok := input.Todos[descriptor.TaskID] + if !ok { + return TaskContextSlice{}, fmt.Errorf("subagent: task context descriptor task %q not found", descriptor.TaskID) + } + task.Dependencies = filterByAllowlist(task.Dependencies, descriptor.DependencyTaskIDs) + relatedFiles := filterRelatedFilesByPath(input.RelatedFiles, descriptor.RelatedFilePaths) + activatedSkills := filterByAllowlist(input.ActivatedSkills, descriptor.SkillIDs) + + slice := BuildTaskContextSlice(TaskContextSliceInput{ + Task: task, + Todos: input.Todos, + ActivatedSkills: activatedSkills, + RelatedFiles: relatedFiles, + MaxChars: input.MaxChars, + MaxTodoFragments: input.MaxTodoFragments, + MaxDependencyArtifacts: input.MaxDependencyArtifacts, + MaxRelatedFiles: input.MaxRelatedFiles, + }) + + allowedTodoIDs := dedupeAndTrim(descriptor.TodoFragmentIDs) + if len(allowedTodoIDs) > 0 { + slice.TodoFragment = filterTodoFragmentsByID(slice.TodoFragment, allowedTodoIDs) + slice.Truncated = enforceTaskContextBudget(&slice, slice.BudgetChars) || slice.Truncated + slice.Descriptor = buildTaskContextDescriptor(slice, descriptor.DependencyTaskIDs) + } + return slice, nil +} + +// normalizeTaskContextSliceInput 规整调用参数并注入默认预算。 +func normalizeTaskContextSliceInput(input TaskContextSliceInput) TaskContextSliceInput { + out := input + if out.MaxChars <= 0 { + out.MaxChars = defaultTaskContextMaxChars + } + if out.MaxTodoFragments <= 0 { + out.MaxTodoFragments = defaultTaskContextMaxTodoFragments + } + if out.MaxDependencyArtifacts <= 0 { + out.MaxDependencyArtifacts = defaultTaskContextMaxDependencyArtifacts + } + if out.MaxRelatedFiles <= 0 { + out.MaxRelatedFiles = defaultTaskContextMaxRelatedFiles + } + out.Todos = cloneTodoMap(out.Todos) + if strings.TrimSpace(out.Task.ID) != "" { + out.Todos[strings.TrimSpace(out.Task.ID)] = out.Task.Clone() + } + out.ActivatedSkills = dedupeAndTrim(out.ActivatedSkills) + out.RelatedFiles = normalizeContextFileSummaries(out.RelatedFiles) + return out +} + +// collectTodoFragments 提取当前任务、关键依赖和额外运行中 Todo 片段。 +func collectTodoFragments( + task agentsession.TodoItem, + todos map[string]agentsession.TodoItem, + dependencyIDs []string, + maxFragments int, +) ([]TaskTodoFragment, []TaskTodoFragment) { + if maxFragments <= 0 { + return nil, nil + } + primary := make([]TaskTodoFragment, 0, maxFragments) + seen := make(map[string]struct{}, maxFragments) + pushPrimary := func(item agentsession.TodoItem) { + if len(primary) >= maxFragments { + return + } + id := strings.TrimSpace(item.ID) + if id == "" { + return + } + if _, exists := seen[id]; exists { + return + } + seen[id] = struct{}{} + primary = append(primary, toTaskTodoFragment(item)) + } + + pushPrimary(task) + for _, depID := range dependencyIDs { + if dep, ok := todos[depID]; ok { + pushPrimary(dep) + } + } + + extras := collectAdditionalTodoFragments(task.ID, todos, seen, maxFragments-len(primary)) + return primary, extras +} + +// collectAdditionalTodoFragments 追加高优先级的进行中/阻塞任务片段,保证顺序稳定。 +func collectAdditionalTodoFragments( + currentTaskID string, + todos map[string]agentsession.TodoItem, + existing map[string]struct{}, + limit int, +) []TaskTodoFragment { + if limit <= 0 { + return nil + } + candidates := make([]agentsession.TodoItem, 0, len(todos)) + for id, item := range todos { + if strings.EqualFold(strings.TrimSpace(id), strings.TrimSpace(currentTaskID)) { + continue + } + if _, ok := existing[strings.TrimSpace(id)]; ok { + continue + } + if item.Status.IsTerminal() { + continue + } + candidates = append(candidates, item.Clone()) + } + + sort.SliceStable(candidates, func(i, j int) bool { + left := candidates[i] + right := candidates[j] + lp := todoStatusRank(left.Status) + rp := todoStatusRank(right.Status) + if lp != rp { + return lp < rp + } + if left.Priority != right.Priority { + return left.Priority > right.Priority + } + if !left.CreatedAt.Equal(right.CreatedAt) { + return left.CreatedAt.Before(right.CreatedAt) + } + return left.ID < right.ID + }) + + result := make([]TaskTodoFragment, 0, limit) + for _, item := range candidates { + if len(result) >= limit { + break + } + result = append(result, toTaskTodoFragment(item)) + } + return result +} + +// collectDependencyArtifacts 读取依赖任务产物引用,并按预算裁剪。 +func collectDependencyArtifacts( + todos map[string]agentsession.TodoItem, + dependencyIDs []string, + limit int, +) []string { + if limit <= 0 { + return nil + } + artifacts := make([]string, 0, limit) + seen := make(map[string]struct{}, limit) + for _, depID := range dependencyIDs { + dependency, ok := todos[depID] + if !ok { + continue + } + for _, raw := range dependency.Artifacts { + item := strings.TrimSpace(raw) + if item == "" { + continue + } + key := strings.ToLower(item) + if _, exists := seen[key]; exists { + continue + } + seen[key] = struct{}{} + artifacts = append(artifacts, item) + if len(artifacts) >= limit { + return artifacts + } + } + } + return artifacts +} + +// mergeRelatedFiles 合并输入文件摘要和依赖产物路径,生成稳定文件摘要集合。 +func mergeRelatedFiles( + related []TaskContextFileSummary, + dependencyArtifacts []string, + limit int, +) []TaskContextFileSummary { + if limit <= 0 { + return nil + } + normalized := normalizeContextFileSummaries(related) + fileByPath := make(map[string]TaskContextFileSummary, len(normalized)) + result := make([]TaskContextFileSummary, 0, limit) + for _, item := range normalized { + pathKey := strings.ToLower(strings.TrimSpace(item.Path)) + if pathKey == "" { + continue + } + fileByPath[pathKey] = item + result = append(result, item) + if len(result) >= limit { + return result + } + } + + for _, artifact := range dependencyArtifacts { + if len(result) >= limit { + break + } + path := strings.TrimSpace(artifact) + if path == "" { + continue + } + pathKey := strings.ToLower(path) + if _, exists := fileByPath[pathKey]; exists { + continue + } + fileByPath[pathKey] = TaskContextFileSummary{ + Path: path, + Summary: "来自依赖产物引用", + } + result = append(result, fileByPath[pathKey]) + } + return result +} + +// enforceTaskContextBudget 按预算裁剪低优先级信息,保证关键信息优先保留。 +func enforceTaskContextBudget(slice *TaskContextSlice, maxChars int) bool { + if slice == nil || maxChars <= 0 { + return false + } + if contextSliceRuneCount(*slice) <= maxChars { + return false + } + truncated := false + trimList := func(list *[]string, minKeep int) { + for len(*list) > minKeep && contextSliceRuneCount(*slice) > maxChars { + *list = (*list)[:len(*list)-1] + truncated = true + } + } + trimFiles := func(minKeep int) { + for len(slice.RelatedFiles) > minKeep && contextSliceRuneCount(*slice) > maxChars { + slice.RelatedFiles = slice.RelatedFiles[:len(slice.RelatedFiles)-1] + truncated = true + } + } + trimTodos := func(minKeep int) { + for len(slice.TodoFragment) > minKeep && contextSliceRuneCount(*slice) > maxChars { + slice.TodoFragment = slice.TodoFragment[:len(slice.TodoFragment)-1] + truncated = true + } + } + + trimList(&slice.ActivatedSkills, 0) + trimFiles(0) + trimTodos(1) + trimList(&slice.DependencyArtifacts, 0) + trimList(&slice.Acceptance, 1) + if contextSliceRuneCount(*slice) <= maxChars { + return truncated + } + + slice.Goal = truncateRunes(slice.Goal, maxChars/2) + if len(slice.Acceptance) > 0 { + for idx := range slice.Acceptance { + slice.Acceptance[idx] = truncateRunes(slice.Acceptance[idx], maxChars/6) + } + } + if len(slice.TodoFragment) > 0 { + slice.TodoFragment[0].Content = truncateRunes(slice.TodoFragment[0].Content, maxChars/3) + } + if contextSliceRuneCount(*slice) <= maxChars { + return true + } + + slice.DependencyArtifacts = nil + slice.RelatedFiles = nil + slice.ActivatedSkills = nil + if len(slice.Acceptance) > 1 { + slice.Acceptance = slice.Acceptance[:1] + } + if len(slice.TodoFragment) > 1 { + slice.TodoFragment = slice.TodoFragment[:1] + } + if contextSliceRuneCount(*slice) <= maxChars { + return true + } + + for contextSliceRuneCount(*slice) > maxChars { + switch { + case len(slice.TodoFragment) > 0 && len([]rune(slice.TodoFragment[0].Content)) > 0: + slice.TodoFragment[0].Content = trimOneRune(slice.TodoFragment[0].Content) + case len(slice.Acceptance) > 0 && len([]rune(slice.Acceptance[0])) > 1: + slice.Acceptance[0] = trimOneRune(slice.Acceptance[0]) + case len([]rune(slice.Goal)) > 1: + slice.Goal = trimOneRune(slice.Goal) + case len(slice.Acceptance) > 1: + slice.Acceptance = slice.Acceptance[:1] + default: + switch { + case len(slice.Acceptance) > 0: + slice.Acceptance = nil + case len(slice.TodoFragment) > 0: + slice.TodoFragment = nil + case len(slice.Goal) > 0: + slice.Goal = "" + default: + return true + } + } + } + return true +} + +// buildTaskContextDescriptor 生成可用于 compact 后重建的 descriptor。 +func buildTaskContextDescriptor(slice TaskContextSlice, dependencyIDs []string) TaskContextDescriptor { + fragmentIDs := make([]string, 0, len(slice.TodoFragment)) + for _, fragment := range slice.TodoFragment { + id := strings.TrimSpace(fragment.ID) + if id == "" { + continue + } + fragmentIDs = append(fragmentIDs, id) + } + relatedPaths := make([]string, 0, len(slice.RelatedFiles)) + for _, file := range slice.RelatedFiles { + path := strings.TrimSpace(file.Path) + if path == "" { + continue + } + relatedPaths = append(relatedPaths, path) + } + return TaskContextDescriptor{ + TaskID: strings.TrimSpace(slice.TaskID), + DependencyTaskIDs: dedupeAndTrim(dependencyIDs), + TodoFragmentIDs: dedupeAndTrim(fragmentIDs), + ArtifactRefs: dedupeAndTrim(slice.DependencyArtifacts), + RelatedFilePaths: dedupeAndTrim(relatedPaths), + SkillIDs: dedupeAndTrim(slice.ActivatedSkills), + }.normalize() +} + +// normalizeContextFileSummaries 规整文件摘要输入并保持稳定顺序。 +func normalizeContextFileSummaries(items []TaskContextFileSummary) []TaskContextFileSummary { + if len(items) == 0 { + return nil + } + result := make([]TaskContextFileSummary, 0, len(items)) + seen := make(map[string]struct{}, len(items)) + for _, item := range items { + path := strings.TrimSpace(item.Path) + if path == "" { + continue + } + key := strings.ToLower(path) + if _, exists := seen[key]; exists { + continue + } + seen[key] = struct{}{} + result = append(result, TaskContextFileSummary{ + Path: path, + Summary: strings.TrimSpace(item.Summary), + }) + } + if len(result) == 0 { + return nil + } + return result +} + +// filterRelatedFilesByPath 根据路径白名单过滤文件摘要,保持输入顺序。 +func filterRelatedFilesByPath(items []TaskContextFileSummary, allow []string) []TaskContextFileSummary { + allowed := dedupeAndTrim(allow) + if len(allowed) == 0 { + return normalizeContextFileSummaries(items) + } + set := make(map[string]struct{}, len(allowed)) + for _, path := range allowed { + set[strings.ToLower(strings.TrimSpace(path))] = struct{}{} + } + result := make([]TaskContextFileSummary, 0, len(items)) + for _, item := range normalizeContextFileSummaries(items) { + if _, ok := set[strings.ToLower(strings.TrimSpace(item.Path))]; !ok { + continue + } + result = append(result, item) + } + return result +} + +// filterByAllowlist 根据白名单过滤字符串切片,并保持原始顺序。 +func filterByAllowlist(items []string, allow []string) []string { + allowed := dedupeAndTrim(allow) + if len(allowed) == 0 { + return nil + } + set := make(map[string]struct{}, len(allowed)) + for _, item := range allowed { + set[strings.ToLower(strings.TrimSpace(item))] = struct{}{} + } + result := make([]string, 0, len(items)) + seen := make(map[string]struct{}, len(items)) + for _, item := range items { + trimmed := strings.TrimSpace(item) + if trimmed == "" { + continue + } + key := strings.ToLower(trimmed) + if _, ok := set[key]; !ok { + continue + } + if _, dup := seen[key]; dup { + continue + } + seen[key] = struct{}{} + result = append(result, trimmed) + } + return result +} + +// filterTodoFragmentsByID 根据 Todo ID 白名单过滤片段。 +func filterTodoFragmentsByID(fragments []TaskTodoFragment, allow []string) []TaskTodoFragment { + allowed := dedupeAndTrim(allow) + if len(allowed) == 0 { + return nil + } + set := make(map[string]struct{}, len(allowed)) + for _, id := range allowed { + set[strings.ToLower(strings.TrimSpace(id))] = struct{}{} + } + result := make([]TaskTodoFragment, 0, len(fragments)) + for _, item := range fragments { + if _, ok := set[strings.ToLower(strings.TrimSpace(item.ID))]; !ok { + continue + } + result = append(result, item) + } + return result +} + +// cloneTodoMap 深拷贝 Todo 映射,避免上层引用被修改。 +func cloneTodoMap(src map[string]agentsession.TodoItem) map[string]agentsession.TodoItem { + if len(src) == 0 { + return make(map[string]agentsession.TodoItem) + } + dst := make(map[string]agentsession.TodoItem, len(src)) + for id, item := range src { + dst[strings.TrimSpace(id)] = item.Clone() + } + return dst +} + +// toTaskTodoFragment 将 session Todo 转换为稳定的上下文片段结构。 +func toTaskTodoFragment(item agentsession.TodoItem) TaskTodoFragment { + return TaskTodoFragment{ + ID: strings.TrimSpace(item.ID), + Status: item.Status, + Content: strings.TrimSpace(item.Content), + Priority: item.Priority, + } +} + +// todoStatusRank 定义 Todo 状态在上下文中的优先级。 +func todoStatusRank(status agentsession.TodoStatus) int { + switch status { + case agentsession.TodoStatusInProgress: + return 0 + case agentsession.TodoStatusBlocked: + return 1 + case agentsession.TodoStatusPending: + return 2 + default: + return 3 + } +} + +// contextSliceRuneCount 计算渲染后上下文切片字符数,作为预算近似指标。 +func contextSliceRuneCount(slice TaskContextSlice) int { + return len([]rune(slice.Render())) +} + +// truncateRunes 按 rune 长度裁剪文本并保留省略标记。 +func truncateRunes(text string, max int) string { + trimmed := strings.TrimSpace(text) + if max <= 0 || trimmed == "" { + return "" + } + runes := []rune(trimmed) + if len(runes) <= max { + return trimmed + } + if max == 1 { + return "…" + } + return string(runes[:max-1]) + "…" +} + +// trimOneRune 删除字符串末尾一个 rune,用于预算极限场景的渐进收缩。 +func trimOneRune(text string) string { + trimmed := strings.TrimSpace(text) + runes := []rune(trimmed) + if len(runes) <= 1 { + return "" + } + return string(runes[:len(runes)-1]) +} + +// writeBulletSection 以统一 bullet 结构渲染字符串切片。 +func writeBulletSection(builder *strings.Builder, title string, items []string) { + if builder == nil || len(items) == 0 { + return + } + builder.WriteString(title) + builder.WriteString(":\n") + for _, item := range items { + builder.WriteString("- ") + builder.WriteString(strings.TrimSpace(item)) + builder.WriteString("\n") + } +} + +// writeFileSummarySection 渲染相关文件摘要列表。 +func writeFileSummarySection(builder *strings.Builder, title string, items []TaskContextFileSummary) { + if builder == nil || len(items) == 0 { + return + } + builder.WriteString(title) + builder.WriteString(":\n") + for _, file := range items { + builder.WriteString("- path: ") + builder.WriteString(strings.TrimSpace(file.Path)) + if summary := strings.TrimSpace(file.Summary); summary != "" { + builder.WriteString(" | summary: ") + builder.WriteString(summary) + } + builder.WriteString("\n") + } +} + +// writeTodoFragmentSection 渲染 Todo 片段列表,便于模型识别优先级和状态。 +func writeTodoFragmentSection(builder *strings.Builder, title string, items []TaskTodoFragment) { + if builder == nil || len(items) == 0 { + return + } + builder.WriteString(title) + builder.WriteString(":\n") + for _, item := range items { + builder.WriteString("- ") + builder.WriteString(item.ID) + builder.WriteString(" [") + builder.WriteString(string(item.Status)) + builder.WriteString("]") + if item.Priority != 0 { + builder.WriteString(" p=") + builder.WriteString(fmt.Sprintf("%d", item.Priority)) + } + if content := strings.TrimSpace(item.Content); content != "" { + builder.WriteString(": ") + builder.WriteString(content) + } + builder.WriteString("\n") + } +} diff --git a/internal/subagent/context_slice_test.go b/internal/subagent/context_slice_test.go new file mode 100644 index 00000000..9a3ef3dc --- /dev/null +++ b/internal/subagent/context_slice_test.go @@ -0,0 +1,279 @@ +package subagent + +import ( + "slices" + "testing" + "time" + + agentsession "neo-code/internal/session" +) + +func TestBuildTaskContextSliceIncludesExpectedSections(t *testing.T) { + t.Parallel() + + now := time.Unix(1710000000, 0).UTC() + task := agentsession.TodoItem{ + ID: "task-main", + Content: "实现子代理任务调度", + Status: agentsession.TodoStatusInProgress, + Priority: 4, + Dependencies: []string{"dep-a", "dep-b"}, + Acceptance: []string{"通过单元测试", "通过集成测试"}, + CreatedAt: now, + } + todos := map[string]agentsession.TodoItem{ + "dep-a": { + ID: "dep-a", + Content: "补齐权限能力", + Status: agentsession.TodoStatusCompleted, + Priority: 2, + Artifacts: []string{"docs/security.md", "artifacts/security-report.json"}, + CreatedAt: now.Add(-2 * time.Hour), + }, + "dep-b": { + ID: "dep-b", + Content: "补齐 TODO 模型", + Status: agentsession.TodoStatusCompleted, + Priority: 3, + Artifacts: []string{"docs/todo.md"}, + CreatedAt: now.Add(-time.Hour), + }, + "peer": { + ID: "peer", + Content: "并行修复覆盖率", + Status: agentsession.TodoStatusInProgress, + Priority: 5, + CreatedAt: now.Add(-30 * time.Minute), + }, + } + + slice := BuildTaskContextSlice(TaskContextSliceInput{ + Task: task, + Todos: todos, + ActivatedSkills: []string{"go-review", "go-review", "todo-planner"}, + RelatedFiles: []TaskContextFileSummary{ + {Path: "internal/subagent/scheduler.go", Summary: "调度主循环"}, + }, + MaxChars: 6000, + }) + + if slice.TaskID != "task-main" { + t.Fatalf("TaskID = %q, want task-main", slice.TaskID) + } + if slice.Goal != "实现子代理任务调度" { + t.Fatalf("Goal = %q, want 实现子代理任务调度", slice.Goal) + } + if len(slice.DependencyArtifacts) != 3 { + t.Fatalf("DependencyArtifacts len = %d, want 3", len(slice.DependencyArtifacts)) + } + if len(slice.ActivatedSkills) != 2 { + t.Fatalf("ActivatedSkills len = %d, want 2", len(slice.ActivatedSkills)) + } + if len(slice.TodoFragment) < 3 { + t.Fatalf("TodoFragment len = %d, want >= 3", len(slice.TodoFragment)) + } + if slice.TodoFragment[0].ID != "task-main" { + t.Fatalf("TodoFragment[0].ID = %q, want task-main", slice.TodoFragment[0].ID) + } + if slice.Descriptor.TaskID != "task-main" { + t.Fatalf("Descriptor.TaskID = %q, want task-main", slice.Descriptor.TaskID) + } + if !slices.Equal(slice.Descriptor.DependencyTaskIDs, []string{"dep-a", "dep-b"}) { + t.Fatalf("Descriptor.DependencyTaskIDs = %v, want [dep-a dep-b]", slice.Descriptor.DependencyTaskIDs) + } + if rendered := slice.Render(); rendered == "" { + t.Fatalf("Render() should not be empty") + } +} + +func TestBuildTaskContextSliceBudgetTrimming(t *testing.T) { + t.Parallel() + + task := agentsession.TodoItem{ + ID: "task-budget", + Content: "这是一个很长很长的任务目标,需要在预算内裁剪但保持关键信息", + Status: agentsession.TodoStatusInProgress, + Priority: 3, + Dependencies: []string{"dep"}, + Acceptance: []string{"验收条件一", "验收条件二", "验收条件三"}, + } + todos := map[string]agentsession.TodoItem{ + "dep": { + ID: "dep", + Content: "依赖任务", + Status: agentsession.TodoStatusCompleted, + Priority: 1, + Artifacts: []string{"very/long/path/that/consumes/context/budget.md"}, + }, + "extra": { + ID: "extra", + Content: "额外任务应在裁剪时优先被移除", + Status: agentsession.TodoStatusPending, + Priority: 1, + }, + } + + cases := []struct { + name string + maxChars int + requireAcceptance bool + }{ + {name: "tight budget", maxChars: 140, requireAcceptance: true}, + {name: "very tight budget", maxChars: 90, requireAcceptance: false}, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + slice := BuildTaskContextSlice(TaskContextSliceInput{ + Task: task, + Todos: todos, + MaxChars: tc.maxChars, + MaxRelatedFiles: 4, + }) + + if !slice.Truncated { + t.Fatalf("Truncated = false, want true") + } + if len([]rune(slice.Render())) > tc.maxChars { + t.Fatalf("rendered chars exceed budget: got %d, budget %d", len([]rune(slice.Render())), tc.maxChars) + } + if slice.Goal == "" { + t.Fatalf("Goal should remain non-empty after trimming") + } + if len(slice.TodoFragment) == 0 || slice.TodoFragment[0].ID != "task-budget" { + t.Fatalf("first todo fragment should keep current task, got %+v", slice.TodoFragment) + } + if tc.requireAcceptance && len(slice.Acceptance) == 0 { + t.Fatalf("Acceptance should keep at least one item") + } + }) + } +} + +func TestRebuildTaskContextSliceFromDescriptor(t *testing.T) { + t.Parallel() + + task := agentsession.TodoItem{ + ID: "task-rebuild", + Content: "重建上下文", + Status: agentsession.TodoStatusInProgress, + Dependencies: []string{"dep-a"}, + Acceptance: []string{"保留核心上下文"}, + } + todos := map[string]agentsession.TodoItem{ + "task-rebuild": task, + "dep-a": { + ID: "dep-a", + Content: "先完成依赖", + Status: agentsession.TodoStatusCompleted, + Artifacts: []string{"artifacts/dep-a.txt"}, + }, + } + + origin := BuildTaskContextSlice(TaskContextSliceInput{ + Task: task, + Todos: todos, + ActivatedSkills: []string{"skill-a"}, + RelatedFiles: []TaskContextFileSummary{ + {Path: "internal/subagent/context_slice.go", Summary: "上下文切片实现"}, + }, + MaxChars: 4000, + }) + + rebuilt, err := RebuildTaskContextSlice(TaskContextRebuildInput{ + Descriptor: origin.Descriptor, + Todos: todos, + ActivatedSkills: []string{"skill-a", "skill-b"}, + RelatedFiles: []TaskContextFileSummary{ + {Path: "internal/subagent/context_slice.go", Summary: "上下文切片实现"}, + {Path: "internal/ignored.go", Summary: "不应被带入"}, + }, + MaxChars: 4000, + }) + if err != nil { + t.Fatalf("RebuildTaskContextSlice() error = %v", err) + } + if rebuilt.TaskID != origin.TaskID { + t.Fatalf("TaskID = %q, want %q", rebuilt.TaskID, origin.TaskID) + } + if !slices.Equal(rebuilt.Descriptor.DependencyTaskIDs, origin.Descriptor.DependencyTaskIDs) { + t.Fatalf("dependency ids = %v, want %v", rebuilt.Descriptor.DependencyTaskIDs, origin.Descriptor.DependencyTaskIDs) + } + if !slices.Equal(rebuilt.Descriptor.ArtifactRefs, origin.Descriptor.ArtifactRefs) { + t.Fatalf("artifact refs = %v, want %v", rebuilt.Descriptor.ArtifactRefs, origin.Descriptor.ArtifactRefs) + } + if !slices.Equal(rebuilt.ActivatedSkills, []string{"skill-a"}) { + t.Fatalf("ActivatedSkills = %v, want [skill-a]", rebuilt.ActivatedSkills) + } + if len(rebuilt.RelatedFiles) == 0 { + t.Fatalf("RelatedFiles should not be empty") + } + if rebuilt.RelatedFiles[0].Path != "internal/subagent/context_slice.go" { + t.Fatalf("RelatedFiles[0].Path = %q, want internal/subagent/context_slice.go", rebuilt.RelatedFiles[0].Path) + } +} + +func TestBuildTaskContextSliceStableOrder(t *testing.T) { + t.Parallel() + + now := time.Unix(1711000000, 0).UTC() + task := agentsession.TodoItem{ + ID: "order-task", + Content: "稳定排序验证", + Status: agentsession.TodoStatusInProgress, + Priority: 2, + } + todos := map[string]agentsession.TodoItem{ + "a": {ID: "a", Content: "A", Status: agentsession.TodoStatusPending, Priority: 1, CreatedAt: now.Add(time.Minute)}, + "b": {ID: "b", Content: "B", Status: agentsession.TodoStatusInProgress, Priority: 1, CreatedAt: now.Add(2 * time.Minute)}, + "c": {ID: "c", Content: "C", Status: agentsession.TodoStatusInProgress, Priority: 3, CreatedAt: now.Add(3 * time.Minute)}, + } + + first := BuildTaskContextSlice(TaskContextSliceInput{ + Task: task, + Todos: todos, + ActivatedSkills: []string{"skill-b", "skill-a", "skill-b"}, + MaxChars: 4000, + }) + second := BuildTaskContextSlice(TaskContextSliceInput{ + Task: task, + Todos: todos, + ActivatedSkills: []string{"skill-b", "skill-a", "skill-b"}, + MaxChars: 4000, + }) + + firstIDs := make([]string, 0, len(first.TodoFragment)) + for _, item := range first.TodoFragment { + firstIDs = append(firstIDs, item.ID) + } + secondIDs := make([]string, 0, len(second.TodoFragment)) + for _, item := range second.TodoFragment { + secondIDs = append(secondIDs, item.ID) + } + if !slices.Equal(firstIDs, secondIDs) { + t.Fatalf("todo order unstable: first=%v second=%v", firstIDs, secondIDs) + } + if !slices.Equal(first.ActivatedSkills, []string{"skill-b", "skill-a"}) { + t.Fatalf("ActivatedSkills = %v, want [skill-b skill-a]", first.ActivatedSkills) + } +} + +func TestRebuildTaskContextSliceErrors(t *testing.T) { + t.Parallel() + + if _, err := RebuildTaskContextSlice(TaskContextRebuildInput{ + Descriptor: TaskContextDescriptor{}, + Todos: map[string]agentsession.TodoItem{}, + }); err == nil { + t.Fatalf("expected missing task id error") + } + + if _, err := RebuildTaskContextSlice(TaskContextRebuildInput{ + Descriptor: TaskContextDescriptor{TaskID: "missing"}, + Todos: map[string]agentsession.TodoItem{}, + }); err == nil { + t.Fatalf("expected task not found error") + } +} diff --git a/internal/subagent/output_contract.go b/internal/subagent/output_contract.go index 5b7514ea..0e34280f 100644 --- a/internal/subagent/output_contract.go +++ b/internal/subagent/output_contract.go @@ -11,22 +11,30 @@ var supportedOutputSections = map[string]struct{}{ "artifacts": {}, } -// validateOutputContract 校验输出结构是否满足角色策略要求。 +var mandatoryOutputSections = []string{ + "summary", + "findings", + "patches", + "risks", + "next_actions", + "artifacts", +} + +// validateOutputContract 校验子代理输出是否满足统一结构化契约。 func validateOutputContract(policy RolePolicy, output Output) error { - out := output.normalize() - requiredSections, err := normalizeRequiredSections(policy.RequiredSections) - if err != nil { + if _, err := normalizeRequiredSections(policy.RequiredSections); 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 mandatoryOutputSections { + 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)) diff --git a/internal/subagent/output_contract_test.go b/internal/subagent/output_contract_test.go index 3971c2d0..4a5ec325 100644 --- a/internal/subagent/output_contract_test.go +++ b/internal/subagent/output_contract_test.go @@ -72,3 +72,27 @@ func TestValidateOutputContractUnsupportedSection(t *testing.T) { t.Fatalf("expected unsupported section error") } } + +func TestValidateOutputContractAlwaysRequiresFullStructure(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("expected mandatory structure validation error") + } + if err := validateOutputContract(policy, Output{ + Summary: "ok", + Findings: []string{"f"}, + Patches: []string{"p"}, + Risks: []string{"r"}, + NextActions: []string{"n"}, + Artifacts: []string{"a"}, + }); err != nil { + t.Fatalf("validateOutputContract() error = %v", err) + } +} diff --git a/internal/subagent/scheduler.go b/internal/subagent/scheduler.go index fd116ba6..34352ad0 100644 --- a/internal/subagent/scheduler.go +++ b/internal/subagent/scheduler.go @@ -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 @@ -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 } @@ -269,10 +274,21 @@ 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{ + Task: item, + Todos: snapshot, + 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} diff --git a/internal/subagent/scheduler_test.go b/internal/subagent/scheduler_test.go index 82ad411a..cefecca0 100644 --- a/internal/subagent/scheduler_test.go +++ b/internal/subagent/scheduler_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "slices" "strings" "sync" "sync/atomic" @@ -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 { @@ -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) { @@ -353,6 +377,89 @@ 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 TestSchedulerRunConcurrencyLimit(t *testing.T) { t.Parallel() diff --git a/internal/subagent/scheduler_types.go b/internal/subagent/scheduler_types.go index 7f229784..313f08e0 100644 --- a/internal/subagent/scheduler_types.go +++ b/internal/subagent/scheduler_types.go @@ -6,7 +6,7 @@ import ( agentsession "neo-code/internal/session" ) -// TodoStore 定义调度器读写 Todo 的最小依赖,便于复用 runtime/session 不同实现。 +// TodoStore 定义调度器读写 Todo 的最小依赖。 type TodoStore interface { ListTodos() []agentsession.TodoItem FindTodo(id string) (agentsession.TodoItem, bool) @@ -19,13 +19,22 @@ type TodoStore interface { // RoleSelector 根据 Todo 节点选择执行角色。 type RoleSelector func(todo agentsession.TodoItem) Role -// BudgetSelector 根据 Todo 节点生成本任务预算。 +// BudgetSelector 根据 Todo 节点生成预算。 type BudgetSelector func(todo agentsession.TodoItem, defaults Budget) Budget -// CapabilitySelector 根据 Todo 节点生成本任务能力边界。 +// CapabilitySelector 根据 Todo 节点生成能力边界。 type CapabilitySelector func(todo agentsession.TodoItem) Capability -// RetryBackoff 计算第 N 次重试应等待的退避时长。 +// ContextSliceBuilder 根据任务快照构建可消费的上下文切片。 +type ContextSliceBuilder func(input TaskContextSliceInput) TaskContextSlice + +// ContextSkillSelector 根据任务快照选择激活技能列表。 +type ContextSkillSelector func(todo agentsession.TodoItem, snapshot map[string]agentsession.TodoItem) []string + +// ContextFileSelector 根据任务快照选择相关文件摘要。 +type ContextFileSelector func(todo agentsession.TodoItem, snapshot map[string]agentsession.TodoItem) []TaskContextFileSummary + +// RetryBackoff 计算第 N 次重试的退避时长。 type RetryBackoff func(attempt int) time.Duration // SchedulerEventType 描述调度器可观测事件类型。 @@ -34,25 +43,25 @@ type SchedulerEventType string const ( // SchedulerEventQueued 表示任务进入可调度队列。 SchedulerEventQueued SchedulerEventType = "queued" - // SchedulerEventClaimed 表示任务已被领取并进入执行中。 + // SchedulerEventClaimed 表示任务已经被领取。 SchedulerEventClaimed SchedulerEventType = "claimed" // SchedulerEventRunning 表示任务开始执行。 SchedulerEventRunning SchedulerEventType = "running" - // SchedulerEventCompleted 表示任务执行成功并回写完成。 + // SchedulerEventCompleted 表示任务成功完成。 SchedulerEventCompleted SchedulerEventType = "completed" - // SchedulerEventRetryScheduled 表示任务失败后已安排重试。 + // SchedulerEventRetryScheduled 表示任务失败后进入重试窗口。 SchedulerEventRetryScheduled SchedulerEventType = "retry_scheduled" - // SchedulerEventFailed 表示任务执行失败且不再重试。 + // SchedulerEventFailed 表示任务失败且不再重试。 SchedulerEventFailed SchedulerEventType = "failed" - // SchedulerEventCanceled 表示任务因外部取消被回写为 canceled。 + // SchedulerEventCanceled 表示任务被取消。 SchedulerEventCanceled SchedulerEventType = "canceled" - // SchedulerEventBlocked 表示任务因依赖未满足处于阻塞态。 + // SchedulerEventBlocked 表示任务因依赖或退避暂不可执行。 SchedulerEventBlocked SchedulerEventType = "blocked" - // SchedulerEventFinished 表示调度器本轮结束。 + // SchedulerEventFinished 表示调度轮次结束。 SchedulerEventFinished SchedulerEventType = "finished" ) -// SchedulerEvent 描述单次调度决策事件,供日志或 runtime 事件桥接使用。 +// SchedulerEvent 描述单次调度决策事件。 type SchedulerEvent struct { Type SchedulerEventType TaskID string @@ -63,10 +72,10 @@ type SchedulerEvent struct { At time.Time } -// SchedulerObserver 用于消费调度器事件。 +// SchedulerObserver 消费调度事件。 type SchedulerObserver func(event SchedulerEvent) -// ScheduleResult 汇总一次调度执行的结果统计。 +// ScheduleResult 汇总一次调度执行结果。 type ScheduleResult struct { StartedAt time.Time EndedAt time.Time @@ -77,7 +86,7 @@ type ScheduleResult struct { BlockedLeft []string } -// SchedulerConfig 描述调度器策略参数。 +// SchedulerConfig 描述调度策略参数。 type SchedulerConfig struct { MaxConcurrency int DefaultRole Role @@ -90,10 +99,20 @@ type SchedulerConfig struct { BudgetSelector BudgetSelector Backoff RetryBackoff Capabilities CapabilitySelector - Observer SchedulerObserver + + ContextBuilder ContextSliceBuilder + ContextSkills ContextSkillSelector + ContextFiles ContextFileSelector + + ContextMaxChars int + ContextMaxTodoFragments int + ContextMaxDependencyArtifacts int + ContextMaxRelatedFiles int + + Observer SchedulerObserver } -// normalize 返回带默认值的配置副本,保证调度器执行阶段不出现隐式零值。 +// normalize 返回带默认值的配置副本,避免执行阶段出现隐式零值。 func (c SchedulerConfig) normalize() SchedulerConfig { out := c if out.MaxConcurrency <= 0 { @@ -130,13 +149,42 @@ func (c SchedulerConfig) normalize() SchedulerConfig { return Capability{} } } + if out.ContextBuilder == nil { + out.ContextBuilder = BuildTaskContextSlice + } + if out.ContextSkills == nil { + out.ContextSkills = func(todo agentsession.TodoItem, snapshot map[string]agentsession.TodoItem) []string { + _ = todo + _ = snapshot + return nil + } + } + if out.ContextFiles == nil { + out.ContextFiles = func(todo agentsession.TodoItem, snapshot map[string]agentsession.TodoItem) []TaskContextFileSummary { + _ = todo + _ = snapshot + return nil + } + } + if out.ContextMaxChars <= 0 { + out.ContextMaxChars = defaultTaskContextMaxChars + } + if out.ContextMaxTodoFragments <= 0 { + out.ContextMaxTodoFragments = defaultTaskContextMaxTodoFragments + } + if out.ContextMaxDependencyArtifacts <= 0 { + out.ContextMaxDependencyArtifacts = defaultTaskContextMaxDependencyArtifacts + } + if out.ContextMaxRelatedFiles <= 0 { + out.ContextMaxRelatedFiles = defaultTaskContextMaxRelatedFiles + } if out.Observer == nil { out.Observer = func(SchedulerEvent) {} } return out } -// defaultRoleSelector 生成默认角色选择器:仅使用调度器可信默认角色,避免读取任务可变元数据。 +// defaultRoleSelector 返回默认角色选择策略。 func defaultRoleSelector(defaultRole Role) RoleSelector { return func(todo agentsession.TodoItem) Role { _ = todo @@ -144,13 +192,13 @@ func defaultRoleSelector(defaultRole Role) RoleSelector { } } -// defaultBudgetSelector 对每个任务统一返回标准预算,可被配置覆盖。 +// defaultBudgetSelector 返回默认预算策略。 func defaultBudgetSelector(todo agentsession.TodoItem, defaults Budget) Budget { _ = todo return defaults } -// defaultRetryBackoff 提供线性重试退避,避免瞬时失败造成热循环。 +// defaultRetryBackoff 提供线性重试退避。 func defaultRetryBackoff(attempt int) time.Duration { if attempt <= 0 { return 0 diff --git a/internal/subagent/types.go b/internal/subagent/types.go index 9e3ba823..f1af3137 100644 --- a/internal/subagent/types.go +++ b/internal/subagent/types.go @@ -73,6 +73,7 @@ type Task struct { Goal string ExpectedOutput string Workspace string + ContextSlice TaskContextSlice } // Validate 校验任务输入是否合法。 From 523114ee7dda5c25d0d59ff78ee53c1bc492b104 Mon Sep 17 00:00:00 2001 From: xgopilot Date: Fri, 17 Apr 2026 08:42:59 +0000 Subject: [PATCH 2/4] fix(subagent): enforce descriptor rebuild contracts and trim context clone cost - enforce descriptor allowlists for artifact refs and todo fragments during rebuild - treat empty todo fragment allowlist as explicit keep-none - add read-only todo snapshot flag to avoid deep clone in scheduler hot path - add regression tests and raise subagent coverage above 90% Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: Cai-Tang-www <106404101+Cai-Tang-www@users.noreply.github.com> --- internal/subagent/context_slice.go | 21 ++-- internal/subagent/context_slice_test.go | 129 ++++++++++++++++++++++++ internal/subagent/scheduler.go | 1 + internal/subagent/scheduler_test.go | 37 +++++++ 4 files changed, 179 insertions(+), 9 deletions(-) diff --git a/internal/subagent/context_slice.go b/internal/subagent/context_slice.go index 661ee255..482c7f49 100644 --- a/internal/subagent/context_slice.go +++ b/internal/subagent/context_slice.go @@ -89,6 +89,7 @@ func (s TaskContextSlice) Render() string { type TaskContextSliceInput struct { Task agentsession.TodoItem Todos map[string]agentsession.TodoItem + ReadOnlyTodos bool ActivatedSkills []string RelatedFiles []TaskContextFileSummary MaxChars int @@ -155,6 +156,7 @@ func RebuildTaskContextSlice(input TaskContextRebuildInput) (TaskContextSlice, e slice := BuildTaskContextSlice(TaskContextSliceInput{ Task: task, Todos: input.Todos, + ReadOnlyTodos: true, ActivatedSkills: activatedSkills, RelatedFiles: relatedFiles, MaxChars: input.MaxChars, @@ -163,12 +165,10 @@ func RebuildTaskContextSlice(input TaskContextRebuildInput) (TaskContextSlice, e MaxRelatedFiles: input.MaxRelatedFiles, }) - allowedTodoIDs := dedupeAndTrim(descriptor.TodoFragmentIDs) - if len(allowedTodoIDs) > 0 { - slice.TodoFragment = filterTodoFragmentsByID(slice.TodoFragment, allowedTodoIDs) - slice.Truncated = enforceTaskContextBudget(&slice, slice.BudgetChars) || slice.Truncated - slice.Descriptor = buildTaskContextDescriptor(slice, descriptor.DependencyTaskIDs) - } + slice.DependencyArtifacts = filterByAllowlist(slice.DependencyArtifacts, descriptor.ArtifactRefs) + slice.TodoFragment = filterTodoFragmentsByID(slice.TodoFragment, descriptor.TodoFragmentIDs) + slice.Truncated = enforceTaskContextBudget(&slice, slice.BudgetChars) || slice.Truncated + slice.Descriptor = buildTaskContextDescriptor(slice, descriptor.DependencyTaskIDs) return slice, nil } @@ -187,9 +187,12 @@ func normalizeTaskContextSliceInput(input TaskContextSliceInput) TaskContextSlic if out.MaxRelatedFiles <= 0 { out.MaxRelatedFiles = defaultTaskContextMaxRelatedFiles } - out.Todos = cloneTodoMap(out.Todos) - if strings.TrimSpace(out.Task.ID) != "" { - out.Todos[strings.TrimSpace(out.Task.ID)] = out.Task.Clone() + if out.ReadOnlyTodos { + if out.Todos == nil { + out.Todos = make(map[string]agentsession.TodoItem) + } + } else { + out.Todos = cloneTodoMap(out.Todos) } out.ActivatedSkills = dedupeAndTrim(out.ActivatedSkills) out.RelatedFiles = normalizeContextFileSummaries(out.RelatedFiles) diff --git a/internal/subagent/context_slice_test.go b/internal/subagent/context_slice_test.go index 9a3ef3dc..ccb6660b 100644 --- a/internal/subagent/context_slice_test.go +++ b/internal/subagent/context_slice_test.go @@ -215,6 +215,89 @@ func TestRebuildTaskContextSliceFromDescriptor(t *testing.T) { } } +func TestRebuildTaskContextSliceFiltersArtifactRefs(t *testing.T) { + t.Parallel() + + task := agentsession.TodoItem{ + ID: "task-rebuild-artifacts", + Content: "重建产物过滤", + Status: agentsession.TodoStatusInProgress, + Dependencies: []string{"dep-a"}, + } + todos := map[string]agentsession.TodoItem{ + "task-rebuild-artifacts": task, + "dep-a": { + ID: "dep-a", + Status: agentsession.TodoStatusCompleted, + Artifacts: []string{"artifacts/keep.txt", "artifacts/drop.txt"}, + }, + } + + rebuilt, err := RebuildTaskContextSlice(TaskContextRebuildInput{ + Descriptor: TaskContextDescriptor{ + TaskID: "task-rebuild-artifacts", + DependencyTaskIDs: []string{"dep-a"}, + ArtifactRefs: []string{"artifacts/keep.txt"}, + }, + Todos: todos, + MaxChars: 4000, + }) + if err != nil { + t.Fatalf("RebuildTaskContextSlice() error = %v", err) + } + + if !slices.Equal(rebuilt.DependencyArtifacts, []string{"artifacts/keep.txt"}) { + t.Fatalf("DependencyArtifacts = %v, want [artifacts/keep.txt]", rebuilt.DependencyArtifacts) + } + if !slices.Equal(rebuilt.Descriptor.ArtifactRefs, []string{"artifacts/keep.txt"}) { + t.Fatalf("Descriptor.ArtifactRefs = %v, want [artifacts/keep.txt]", rebuilt.Descriptor.ArtifactRefs) + } +} + +func TestRebuildTaskContextSliceEmptyTodoAllowlistMeansNone(t *testing.T) { + t.Parallel() + + task := agentsession.TodoItem{ + ID: "task-rebuild-empty-fragments", + Content: "空白名单语义", + Status: agentsession.TodoStatusInProgress, + Dependencies: []string{"dep-a"}, + } + todos := map[string]agentsession.TodoItem{ + "task-rebuild-empty-fragments": task, + "dep-a": { + ID: "dep-a", + Content: "依赖", + Status: agentsession.TodoStatusCompleted, + }, + "extra": { + ID: "extra", + Content: "额外运行中任务", + Status: agentsession.TodoStatusInProgress, + }, + } + + rebuilt, err := RebuildTaskContextSlice(TaskContextRebuildInput{ + Descriptor: TaskContextDescriptor{ + TaskID: "task-rebuild-empty-fragments", + DependencyTaskIDs: []string{"dep-a"}, + TodoFragmentIDs: nil, + }, + Todos: todos, + MaxChars: 4000, + }) + if err != nil { + t.Fatalf("RebuildTaskContextSlice() error = %v", err) + } + + if len(rebuilt.TodoFragment) != 0 { + t.Fatalf("TodoFragment len = %d, want 0", len(rebuilt.TodoFragment)) + } + if len(rebuilt.Descriptor.TodoFragmentIDs) != 0 { + t.Fatalf("Descriptor.TodoFragmentIDs = %v, want empty", rebuilt.Descriptor.TodoFragmentIDs) + } +} + func TestBuildTaskContextSliceStableOrder(t *testing.T) { t.Parallel() @@ -277,3 +360,49 @@ func TestRebuildTaskContextSliceErrors(t *testing.T) { t.Fatalf("expected task not found error") } } + +func TestNormalizeTaskContextSliceInputReadOnlyTodos(t *testing.T) { + t.Parallel() + + input := TaskContextSliceInput{ + Task: agentsession.TodoItem{ID: "task", Content: "goal"}, + ReadOnlyTodos: true, + } + out := normalizeTaskContextSliceInput(input) + if out.Todos == nil { + t.Fatalf("Todos should be initialized for read-only input") + } + if len(out.Todos) != 0 { + t.Fatalf("Todos len = %d, want 0", len(out.Todos)) + } +} + +func TestTodoStatusRankAndTruncateRunesEdgeCases(t *testing.T) { + t.Parallel() + + if got := todoStatusRank(agentsession.TodoStatusInProgress); got != 0 { + t.Fatalf("todoStatusRank(in_progress) = %d, want 0", got) + } + if got := todoStatusRank(agentsession.TodoStatusBlocked); got != 1 { + t.Fatalf("todoStatusRank(blocked) = %d, want 1", got) + } + if got := todoStatusRank(agentsession.TodoStatusPending); got != 2 { + t.Fatalf("todoStatusRank(pending) = %d, want 2", got) + } + if got := todoStatusRank(agentsession.TodoStatusCompleted); got != 3 { + t.Fatalf("todoStatusRank(completed) = %d, want 3", got) + } + + if got := truncateRunes(" ", 3); got != "" { + t.Fatalf("truncateRunes(blank, 3) = %q, want empty", got) + } + if got := truncateRunes("abcdef", 1); got != "…" { + t.Fatalf("truncateRunes(abcdef, 1) = %q, want …", got) + } + if got := truncateRunes("abc", 4); got != "abc" { + t.Fatalf("truncateRunes(abc, 4) = %q, want abc", got) + } + if got := truncateRunes("abcdef", 0); got != "" { + t.Fatalf("truncateRunes(abcdef, 0) = %q, want empty", got) + } +} diff --git a/internal/subagent/scheduler.go b/internal/subagent/scheduler.go index 34352ad0..a53d2c2d 100644 --- a/internal/subagent/scheduler.go +++ b/internal/subagent/scheduler.go @@ -277,6 +277,7 @@ func (s *Scheduler) startReadyTasks( contextSlice := s.cfg.ContextBuilder(TaskContextSliceInput{ Task: item, Todos: snapshot, + ReadOnlyTodos: true, ActivatedSkills: s.cfg.ContextSkills(item, snapshot), RelatedFiles: s.cfg.ContextFiles(item, snapshot), MaxChars: s.cfg.ContextMaxChars, diff --git a/internal/subagent/scheduler_test.go b/internal/subagent/scheduler_test.go index cefecca0..db1b7b95 100644 --- a/internal/subagent/scheduler_test.go +++ b/internal/subagent/scheduler_test.go @@ -460,6 +460,43 @@ func TestSchedulerBuildsTaskContextSlice(t *testing.T) { } } +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() From ac5d75ce07b24cf0f4d6142478194cb4ccd54080 Mon Sep 17 00:00:00 2001 From: xgopilot Date: Fri, 17 Apr 2026 09:21:10 +0000 Subject: [PATCH 3/4] fix(subagent): resolve six review risks in context slice Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: Cai-Tang-www <106404101+Cai-Tang-www@users.noreply.github.com> --- internal/subagent/context_slice.go | 240 +++++++++++++++++----- internal/subagent/context_slice_test.go | 127 +++++++++++- internal/subagent/output_contract.go | 14 +- internal/subagent/output_contract_test.go | 14 +- internal/subagent/types.go | 4 + internal/subagent/types_test.go | 37 ++++ 6 files changed, 358 insertions(+), 78 deletions(-) create mode 100644 internal/subagent/types_test.go diff --git a/internal/subagent/context_slice.go b/internal/subagent/context_slice.go index 482c7f49..4f63782e 100644 --- a/internal/subagent/context_slice.go +++ b/internal/subagent/context_slice.go @@ -4,6 +4,7 @@ import ( "fmt" "sort" "strings" + "unicode" agentsession "neo-code/internal/session" ) @@ -42,11 +43,11 @@ type TaskContextDescriptor struct { // normalize 规整 descriptor 字段,保证 compact 后重建输入稳定。 func (d TaskContextDescriptor) normalize() TaskContextDescriptor { d.TaskID = strings.TrimSpace(d.TaskID) - d.DependencyTaskIDs = dedupeAndTrim(d.DependencyTaskIDs) - d.TodoFragmentIDs = dedupeAndTrim(d.TodoFragmentIDs) - d.ArtifactRefs = dedupeAndTrim(d.ArtifactRefs) - d.RelatedFilePaths = dedupeAndTrim(d.RelatedFilePaths) - d.SkillIDs = dedupeAndTrim(d.SkillIDs) + d.DependencyTaskIDs = normalizeDescriptorOptionalSlice(d.DependencyTaskIDs) + d.TodoFragmentIDs = normalizeDescriptorOptionalSlice(d.TodoFragmentIDs) + d.ArtifactRefs = normalizeDescriptorOptionalSlice(d.ArtifactRefs) + d.RelatedFilePaths = normalizeDescriptorOptionalSlice(d.RelatedFilePaths) + d.SkillIDs = normalizeDescriptorOptionalSlice(d.SkillIDs) return d } @@ -67,14 +68,14 @@ type TaskContextSlice struct { // Render 以稳定顺序渲染切片,供执行引擎直接消费。 func (s TaskContextSlice) Render() string { var b strings.Builder - if strings.TrimSpace(s.TaskID) != "" { + if taskID := sanitizeRenderValue(s.TaskID); taskID != "" { b.WriteString("task_id: ") - b.WriteString(strings.TrimSpace(s.TaskID)) + b.WriteString(taskID) b.WriteString("\n") } - if strings.TrimSpace(s.Goal) != "" { + if goal := sanitizeRenderValue(s.Goal); goal != "" { b.WriteString("goal: ") - b.WriteString(strings.TrimSpace(s.Goal)) + b.WriteString(goal) b.WriteString("\n") } writeBulletSection(&b, "acceptance", s.Acceptance) @@ -373,26 +374,33 @@ func enforceTaskContextBudget(slice *TaskContextSlice, maxChars int) bool { if slice == nil || maxChars <= 0 { return false } - if contextSliceRuneCount(*slice) <= maxChars { + currentChars := contextSliceRuneCount(*slice) + if currentChars <= maxChars { return false } truncated := false + refreshChars := func() { + currentChars = contextSliceRuneCount(*slice) + } trimList := func(list *[]string, minKeep int) { - for len(*list) > minKeep && contextSliceRuneCount(*slice) > maxChars { + for len(*list) > minKeep && currentChars > maxChars { *list = (*list)[:len(*list)-1] truncated = true + refreshChars() } } trimFiles := func(minKeep int) { - for len(slice.RelatedFiles) > minKeep && contextSliceRuneCount(*slice) > maxChars { + for len(slice.RelatedFiles) > minKeep && currentChars > maxChars { slice.RelatedFiles = slice.RelatedFiles[:len(slice.RelatedFiles)-1] truncated = true + refreshChars() } } trimTodos := func(minKeep int) { - for len(slice.TodoFragment) > minKeep && contextSliceRuneCount(*slice) > maxChars { + for len(slice.TodoFragment) > minKeep && currentChars > maxChars { slice.TodoFragment = slice.TodoFragment[:len(slice.TodoFragment)-1] truncated = true + refreshChars() } } @@ -401,20 +409,33 @@ func enforceTaskContextBudget(slice *TaskContextSlice, maxChars int) bool { trimTodos(1) trimList(&slice.DependencyArtifacts, 0) trimList(&slice.Acceptance, 1) - if contextSliceRuneCount(*slice) <= maxChars { + if currentChars <= maxChars { return truncated } - slice.Goal = truncateRunes(slice.Goal, maxChars/2) + goalLimit := maxChars / 2 + if goalLimit < 1 { + goalLimit = 1 + } + slice.Goal = truncateRunes(slice.Goal, goalLimit) if len(slice.Acceptance) > 0 { + acceptanceLimit := maxChars / 6 + if acceptanceLimit < 1 { + acceptanceLimit = 1 + } for idx := range slice.Acceptance { - slice.Acceptance[idx] = truncateRunes(slice.Acceptance[idx], maxChars/6) + slice.Acceptance[idx] = truncateRunes(slice.Acceptance[idx], acceptanceLimit) } } if len(slice.TodoFragment) > 0 { - slice.TodoFragment[0].Content = truncateRunes(slice.TodoFragment[0].Content, maxChars/3) + todoLimit := maxChars / 3 + if todoLimit < 1 { + todoLimit = 1 + } + slice.TodoFragment[0].Content = truncateRunes(slice.TodoFragment[0].Content, todoLimit) } - if contextSliceRuneCount(*slice) <= maxChars { + refreshChars() + if currentChars <= maxChars { return true } @@ -427,11 +448,12 @@ func enforceTaskContextBudget(slice *TaskContextSlice, maxChars int) bool { if len(slice.TodoFragment) > 1 { slice.TodoFragment = slice.TodoFragment[:1] } - if contextSliceRuneCount(*slice) <= maxChars { + refreshChars() + if currentChars <= maxChars { return true } - for contextSliceRuneCount(*slice) > maxChars { + for currentChars > maxChars { switch { case len(slice.TodoFragment) > 0 && len([]rune(slice.TodoFragment[0].Content)) > 0: slice.TodoFragment[0].Content = trimOneRune(slice.TodoFragment[0].Content) @@ -447,12 +469,21 @@ func enforceTaskContextBudget(slice *TaskContextSlice, maxChars int) bool { slice.Acceptance = nil case len(slice.TodoFragment) > 0: slice.TodoFragment = nil - case len(slice.Goal) > 0: - slice.Goal = "" + case strings.TrimSpace(slice.Goal) == "": + slice.Goal = "…" default: return true } } + refreshChars() + if strings.TrimSpace(slice.Goal) == "" { + slice.Goal = "…" + refreshChars() + } + if currentChars > maxChars && len(slice.Acceptance) == 0 && len(slice.TodoFragment) == 0 && + len([]rune(strings.TrimSpace(slice.Goal))) <= 1 { + return true + } } return true } @@ -515,9 +546,12 @@ func normalizeContextFileSummaries(items []TaskContextFileSummary) []TaskContext // filterRelatedFilesByPath 根据路径白名单过滤文件摘要,保持输入顺序。 func filterRelatedFilesByPath(items []TaskContextFileSummary, allow []string) []TaskContextFileSummary { + if allow == nil { + return normalizeContextFileSummaries(items) + } allowed := dedupeAndTrim(allow) if len(allowed) == 0 { - return normalizeContextFileSummaries(items) + return nil } set := make(map[string]struct{}, len(allowed)) for _, path := range allowed { @@ -535,6 +569,9 @@ func filterRelatedFilesByPath(items []TaskContextFileSummary, allow []string) [] // filterByAllowlist 根据白名单过滤字符串切片,并保持原始顺序。 func filterByAllowlist(items []string, allow []string) []string { + if allow == nil { + return dedupeAndTrim(items) + } allowed := dedupeAndTrim(allow) if len(allowed) == 0 { return nil @@ -565,6 +602,11 @@ func filterByAllowlist(items []string, allow []string) []string { // filterTodoFragmentsByID 根据 Todo ID 白名单过滤片段。 func filterTodoFragmentsByID(fragments []TaskTodoFragment, allow []string) []TaskTodoFragment { + if allow == nil { + result := make([]TaskTodoFragment, len(fragments)) + copy(result, fragments) + return result + } allowed := dedupeAndTrim(allow) if len(allowed) == 0 { return nil @@ -621,7 +663,26 @@ func todoStatusRank(status agentsession.TodoStatus) int { // contextSliceRuneCount 计算渲染后上下文切片字符数,作为预算近似指标。 func contextSliceRuneCount(slice TaskContextSlice) int { - return len([]rune(slice.Render())) + lines := make([]string, 0, 16) + if taskID := sanitizeRenderValue(slice.TaskID); taskID != "" { + lines = append(lines, "task_id: "+taskID) + } + if goal := sanitizeRenderValue(slice.Goal); goal != "" { + lines = append(lines, "goal: "+goal) + } + lines = append(lines, renderedBulletLines("acceptance", slice.Acceptance)...) + lines = append(lines, renderedBulletLines("dependency_artifacts", slice.DependencyArtifacts)...) + lines = append(lines, renderedFileSummaryLines("related_files", slice.RelatedFiles)...) + lines = append(lines, renderedBulletLines("activated_skills", slice.ActivatedSkills)...) + lines = append(lines, renderedTodoLines("todo_fragment", slice.TodoFragment)...) + if len(lines) == 0 { + return 0 + } + total := len(lines) - 1 + for _, line := range lines { + total += len([]rune(line)) + } + return total } // truncateRunes 按 rune 长度裁剪文本并保留省略标记。 @@ -655,11 +716,8 @@ func writeBulletSection(builder *strings.Builder, title string, items []string) if builder == nil || len(items) == 0 { return } - builder.WriteString(title) - builder.WriteString(":\n") - for _, item := range items { - builder.WriteString("- ") - builder.WriteString(strings.TrimSpace(item)) + for _, line := range renderedBulletLines(title, items) { + builder.WriteString(line) builder.WriteString("\n") } } @@ -669,15 +727,8 @@ func writeFileSummarySection(builder *strings.Builder, title string, items []Tas if builder == nil || len(items) == 0 { return } - builder.WriteString(title) - builder.WriteString(":\n") - for _, file := range items { - builder.WriteString("- path: ") - builder.WriteString(strings.TrimSpace(file.Path)) - if summary := strings.TrimSpace(file.Summary); summary != "" { - builder.WriteString(" | summary: ") - builder.WriteString(summary) - } + for _, line := range renderedFileSummaryLines(title, items) { + builder.WriteString(line) builder.WriteString("\n") } } @@ -687,22 +738,109 @@ func writeTodoFragmentSection(builder *strings.Builder, title string, items []Ta if builder == nil || len(items) == 0 { return } - builder.WriteString(title) - builder.WriteString(":\n") + for _, line := range renderedTodoLines(title, items) { + builder.WriteString(line) + builder.WriteString("\n") + } +} + +// renderedBulletLines 生成 bullet section 的稳定渲染行。 +func renderedBulletLines(title string, items []string) []string { + rows := make([]string, 0, len(items)+1) for _, item := range items { - builder.WriteString("- ") - builder.WriteString(item.ID) - builder.WriteString(" [") - builder.WriteString(string(item.Status)) - builder.WriteString("]") + sanitized := sanitizeRenderValue(item) + if sanitized == "" { + continue + } + if len(rows) == 0 { + rows = append(rows, title+":") + } + rows = append(rows, "- "+sanitized) + } + return rows +} + +// renderedFileSummaryLines 生成相关文件 section 的稳定渲染行。 +func renderedFileSummaryLines(title string, items []TaskContextFileSummary) []string { + rows := make([]string, 0, len(items)+1) + for _, file := range items { + path := sanitizeRenderValue(file.Path) + if path == "" { + continue + } + line := "- path: " + path + if summary := sanitizeRenderValue(file.Summary); summary != "" { + line += " | summary: " + summary + } + if len(rows) == 0 { + rows = append(rows, title+":") + } + rows = append(rows, line) + } + return rows +} + +// renderedTodoLines 生成 Todo 片段 section 的稳定渲染行。 +func renderedTodoLines(title string, items []TaskTodoFragment) []string { + rows := make([]string, 0, len(items)+1) + for _, item := range items { + id := sanitizeRenderValue(item.ID) + if id == "" { + continue + } + line := "- " + id + " [" + sanitizeRenderValue(string(item.Status)) + "]" if item.Priority != 0 { - builder.WriteString(" p=") - builder.WriteString(fmt.Sprintf("%d", item.Priority)) + line += " p=" + fmt.Sprintf("%d", item.Priority) } - if content := strings.TrimSpace(item.Content); content != "" { - builder.WriteString(": ") - builder.WriteString(content) + if content := sanitizeRenderValue(item.Content); content != "" { + line += ": " + content } - builder.WriteString("\n") + if len(rows) == 0 { + rows = append(rows, title+":") + } + rows = append(rows, line) + } + return rows +} + +// sanitizeRenderValue 把换行和控制字符规整为空格,避免渲染结构被注入。 +func sanitizeRenderValue(text string) string { + trimmed := strings.TrimSpace(text) + if trimmed == "" { + return "" + } + var builder strings.Builder + builder.Grow(len(trimmed)) + lastSpace := false + for _, r := range trimmed { + switch { + case r == '\n' || r == '\r' || r == '\t': + r = ' ' + case unicode.IsControl(r): + continue + } + if unicode.IsSpace(r) { + if lastSpace { + continue + } + lastSpace = true + builder.WriteRune(' ') + continue + } + lastSpace = false + builder.WriteRune(r) + } + return strings.TrimSpace(builder.String()) +} + +// normalizeDescriptorOptionalSlice 规整 descriptor 列表字段并保留“显式空列表”语义。 +func normalizeDescriptorOptionalSlice(items []string) []string { + if items == nil { + return nil + } + normalized := dedupeAndTrim(items) + if normalized == nil { + return []string{} } + return normalized } diff --git a/internal/subagent/context_slice_test.go b/internal/subagent/context_slice_test.go index ccb6660b..c563c7cc 100644 --- a/internal/subagent/context_slice_test.go +++ b/internal/subagent/context_slice_test.go @@ -2,6 +2,7 @@ package subagent import ( "slices" + "strings" "testing" "time" @@ -266,9 +267,10 @@ func TestRebuildTaskContextSliceEmptyTodoAllowlistMeansNone(t *testing.T) { todos := map[string]agentsession.TodoItem{ "task-rebuild-empty-fragments": task, "dep-a": { - ID: "dep-a", - Content: "依赖", - Status: agentsession.TodoStatusCompleted, + ID: "dep-a", + Content: "依赖", + Status: agentsession.TodoStatusCompleted, + Artifacts: []string{"artifacts/dep-a.txt"}, }, "extra": { ID: "extra", @@ -281,7 +283,7 @@ func TestRebuildTaskContextSliceEmptyTodoAllowlistMeansNone(t *testing.T) { Descriptor: TaskContextDescriptor{ TaskID: "task-rebuild-empty-fragments", DependencyTaskIDs: []string{"dep-a"}, - TodoFragmentIDs: nil, + TodoFragmentIDs: []string{}, }, Todos: todos, MaxChars: 4000, @@ -298,6 +300,56 @@ func TestRebuildTaskContextSliceEmptyTodoAllowlistMeansNone(t *testing.T) { } } +func TestRebuildTaskContextSliceNilTodoAllowlistKeepsSnapshotFragments(t *testing.T) { + t.Parallel() + + task := agentsession.TodoItem{ + ID: "task-rebuild-nil-fragments", + Content: "nil 白名单语义", + Status: agentsession.TodoStatusInProgress, + Dependencies: []string{"dep-a"}, + } + todos := map[string]agentsession.TodoItem{ + "task-rebuild-nil-fragments": task, + "dep-a": { + ID: "dep-a", + Content: "依赖", + Status: agentsession.TodoStatusCompleted, + Artifacts: []string{"artifacts/dep-a.txt"}, + }, + "extra": { + ID: "extra", + Content: "额外运行中任务", + Status: agentsession.TodoStatusInProgress, + }, + } + + rebuilt, err := RebuildTaskContextSlice(TaskContextRebuildInput{ + Descriptor: TaskContextDescriptor{ + TaskID: "task-rebuild-nil-fragments", + DependencyTaskIDs: []string{"dep-a"}, + TodoFragmentIDs: nil, + ArtifactRefs: nil, + SkillIDs: nil, + }, + Todos: todos, + ActivatedSkills: []string{"skill-a"}, + MaxChars: 4000, + }) + if err != nil { + t.Fatalf("RebuildTaskContextSlice() error = %v", err) + } + if len(rebuilt.TodoFragment) == 0 { + t.Fatalf("TodoFragment should keep snapshot fragments when allowlist is nil") + } + if len(rebuilt.DependencyArtifacts) == 0 { + t.Fatalf("DependencyArtifacts should keep snapshot artifacts when allowlist is nil") + } + if !slices.Equal(rebuilt.ActivatedSkills, []string{"skill-a"}) { + t.Fatalf("ActivatedSkills = %v, want [skill-a]", rebuilt.ActivatedSkills) + } +} + func TestBuildTaskContextSliceStableOrder(t *testing.T) { t.Parallel() @@ -406,3 +458,70 @@ func TestTodoStatusRankAndTruncateRunesEdgeCases(t *testing.T) { t.Fatalf("truncateRunes(abcdef, 0) = %q, want empty", got) } } + +func TestBuildTaskContextSliceExtremeBudgetKeepsNonEmptyGoal(t *testing.T) { + t.Parallel() + + task := agentsession.TodoItem{ + ID: "task-extreme-budget", + Content: "极小预算仍需保留目标语义", + Status: agentsession.TodoStatusInProgress, + Acceptance: []string{"验收条件"}, + } + slice := BuildTaskContextSlice(TaskContextSliceInput{ + Task: task, + Todos: map[string]agentsession.TodoItem{"task-extreme-budget": task}, + MaxChars: 18, + }) + if strings.TrimSpace(slice.Goal) == "" { + t.Fatalf("Goal should remain non-empty under extreme budget") + } +} + +func TestTaskContextSliceRenderSanitizesControlChars(t *testing.T) { + t.Parallel() + + slice := TaskContextSlice{ + TaskID: "task-1\nforged", + Goal: "goal\r\nline2", + Acceptance: []string{"ok\n- forged"}, + DependencyArtifacts: []string{"a\tb"}, + RelatedFiles: []TaskContextFileSummary{ + {Path: "internal/x.go\ninject", Summary: "sum\r\nline"}, + }, + ActivatedSkills: []string{"skill\nbreak"}, + TodoFragment: []TaskTodoFragment{ + {ID: "todo-1\nx", Status: agentsession.TodoStatusInProgress, Content: "content\r\nx"}, + }, + } + rendered := slice.Render() + if strings.Contains(rendered, "\n- forged") { + t.Fatalf("rendered text should sanitize embedded newlines, got %q", rendered) + } + if strings.Contains(rendered, "\r") { + t.Fatalf("rendered text should not contain carriage return, got %q", rendered) + } +} + +func TestContextSliceRuneCountMatchesRenderLength(t *testing.T) { + t.Parallel() + + slice := TaskContextSlice{ + TaskID: "task-1", + Goal: "goal", + Acceptance: []string{"a", "b"}, + DependencyArtifacts: []string{"artifacts/a.txt"}, + RelatedFiles: []TaskContextFileSummary{ + {Path: "internal/subagent/context_slice.go", Summary: "summary"}, + }, + ActivatedSkills: []string{"skill-a"}, + TodoFragment: []TaskTodoFragment{ + {ID: "todo-1", Status: agentsession.TodoStatusInProgress, Content: "do it", Priority: 2}, + }, + } + got := contextSliceRuneCount(slice) + want := len([]rune(slice.Render())) + if got != want { + t.Fatalf("contextSliceRuneCount() = %d, want %d", got, want) + } +} diff --git a/internal/subagent/output_contract.go b/internal/subagent/output_contract.go index 0e34280f..9f681526 100644 --- a/internal/subagent/output_contract.go +++ b/internal/subagent/output_contract.go @@ -11,22 +11,14 @@ var supportedOutputSections = map[string]struct{}{ "artifacts": {}, } -var mandatoryOutputSections = []string{ - "summary", - "findings", - "patches", - "risks", - "next_actions", - "artifacts", -} - // validateOutputContract 校验子代理输出是否满足统一结构化契约。 func validateOutputContract(policy RolePolicy, output Output) error { - if _, err := normalizeRequiredSections(policy.RequiredSections); err != nil { + requiredSections, err := normalizeRequiredSections(policy.RequiredSections) + if err != nil { return err } out := output.normalize() - for _, section := range mandatoryOutputSections { + for _, section := range requiredSections { if !hasOutputSectionContent(out, section) { return errorsf("output section %q is required", section) } diff --git a/internal/subagent/output_contract_test.go b/internal/subagent/output_contract_test.go index 4a5ec325..55635e19 100644 --- a/internal/subagent/output_contract_test.go +++ b/internal/subagent/output_contract_test.go @@ -73,7 +73,7 @@ func TestValidateOutputContractUnsupportedSection(t *testing.T) { } } -func TestValidateOutputContractAlwaysRequiresFullStructure(t *testing.T) { +func TestValidateOutputContractOnlyRequiresConfiguredSections(t *testing.T) { t.Parallel() policy := RolePolicy{ @@ -82,17 +82,7 @@ func TestValidateOutputContractAlwaysRequiresFullStructure(t *testing.T) { AllowedTools: []string{"bash"}, RequiredSections: []string{"summary"}, } - if err := validateOutputContract(policy, Output{Summary: "ok"}); err == nil { - t.Fatalf("expected mandatory structure validation error") - } - if err := validateOutputContract(policy, Output{ - Summary: "ok", - Findings: []string{"f"}, - Patches: []string{"p"}, - Risks: []string{"r"}, - NextActions: []string{"n"}, - Artifacts: []string{"a"}, - }); err != nil { + if err := validateOutputContract(policy, Output{Summary: "ok"}); err != nil { t.Fatalf("validateOutputContract() error = %v", err) } } diff --git a/internal/subagent/types.go b/internal/subagent/types.go index f1af3137..ca8b426e 100644 --- a/internal/subagent/types.go +++ b/internal/subagent/types.go @@ -84,6 +84,10 @@ func (t Task) Validate() error { if strings.TrimSpace(t.Goal) == "" { return errorsf("task goal is required") } + contextTaskID := strings.TrimSpace(t.ContextSlice.TaskID) + if contextTaskID != "" && !strings.EqualFold(contextTaskID, strings.TrimSpace(t.ID)) { + return errorsf("task context slice task id %q mismatched task id %q", contextTaskID, strings.TrimSpace(t.ID)) + } return nil } diff --git a/internal/subagent/types_test.go b/internal/subagent/types_test.go new file mode 100644 index 00000000..a17b9052 --- /dev/null +++ b/internal/subagent/types_test.go @@ -0,0 +1,37 @@ +package subagent + +import "testing" + +func TestTaskValidateContextSliceTaskIDMismatch(t *testing.T) { + t.Parallel() + + err := (Task{ + ID: "task-a", + Goal: "goal", + ContextSlice: TaskContextSlice{ + TaskID: "task-b", + }, + }).Validate() + if err == nil { + t.Fatalf("expected context slice task id mismatch error") + } +} + +func TestTaskValidateAllowsEmptyOrMatchedContextSliceTaskID(t *testing.T) { + t.Parallel() + + cases := []Task{ + {ID: "task-a", Goal: "goal", ContextSlice: TaskContextSlice{}}, + {ID: "task-a", Goal: "goal", ContextSlice: TaskContextSlice{TaskID: "task-a"}}, + {ID: "task-a", Goal: "goal", ContextSlice: TaskContextSlice{TaskID: " TASK-A "}}, + } + for _, task := range cases { + task := task + t.Run(task.ContextSlice.TaskID, func(t *testing.T) { + t.Parallel() + if err := task.Validate(); err != nil { + t.Fatalf("Validate() error = %v", err) + } + }) + } +} From dfa029924c66d76e04ac22446543bae89bed0c7b Mon Sep 17 00:00:00 2001 From: xgopilot Date: Fri, 17 Apr 2026 10:13:07 +0000 Subject: [PATCH 4/4] fix(subagent): enforce descriptor related-files filtering and hard budget cap Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: Cai-Tang-www <106404101+Cai-Tang-www@users.noreply.github.com> --- internal/subagent/context_slice.go | 38 +++++++++++++ internal/subagent/context_slice_test.go | 72 +++++++++++++++++++++++++ 2 files changed, 110 insertions(+) diff --git a/internal/subagent/context_slice.go b/internal/subagent/context_slice.go index 4f63782e..965d88ed 100644 --- a/internal/subagent/context_slice.go +++ b/internal/subagent/context_slice.go @@ -167,6 +167,7 @@ func RebuildTaskContextSlice(input TaskContextRebuildInput) (TaskContextSlice, e }) slice.DependencyArtifacts = filterByAllowlist(slice.DependencyArtifacts, descriptor.ArtifactRefs) + slice.RelatedFiles = filterRelatedFilesByPath(slice.RelatedFiles, descriptor.RelatedFilePaths) slice.TodoFragment = filterTodoFragmentsByID(slice.TodoFragment, descriptor.TodoFragmentIDs) slice.Truncated = enforceTaskContextBudget(&slice, slice.BudgetChars) || slice.Truncated slice.Descriptor = buildTaskContextDescriptor(slice, descriptor.DependencyTaskIDs) @@ -482,12 +483,49 @@ func enforceTaskContextBudget(slice *TaskContextSlice, maxChars int) bool { } if currentChars > maxChars && len(slice.Acceptance) == 0 && len(slice.TodoFragment) == 0 && len([]rune(strings.TrimSpace(slice.Goal))) <= 1 { + if forceFitTaskContextBudget(slice, maxChars, ¤tChars) { + truncated = true + } return true } } return true } +// forceFitTaskContextBudget 在极端预算下执行硬性收缩,确保渲染长度不超过预算。 +func forceFitTaskContextBudget(slice *TaskContextSlice, maxChars int, currentChars *int) bool { + if slice == nil || currentChars == nil || maxChars < 0 || *currentChars <= maxChars { + return false + } + changed := false + refreshChars := func() { + *currentChars = contextSliceRuneCount(*slice) + } + for *currentChars > maxChars { + switch { + case strings.TrimSpace(slice.TaskID) != "": + slice.TaskID = trimOneRune(slice.TaskID) + case len(slice.Acceptance) > 0: + slice.Acceptance = nil + case len(slice.TodoFragment) > 0: + slice.TodoFragment = nil + case len(slice.DependencyArtifacts) > 0: + slice.DependencyArtifacts = nil + case len(slice.RelatedFiles) > 0: + slice.RelatedFiles = nil + case len(slice.ActivatedSkills) > 0: + slice.ActivatedSkills = nil + case strings.TrimSpace(slice.Goal) != "": + slice.Goal = trimOneRune(slice.Goal) + default: + return changed + } + changed = true + refreshChars() + } + return changed +} + // buildTaskContextDescriptor 生成可用于 compact 后重建的 descriptor。 func buildTaskContextDescriptor(slice TaskContextSlice, dependencyIDs []string) TaskContextDescriptor { fragmentIDs := make([]string, 0, len(slice.TodoFragment)) diff --git a/internal/subagent/context_slice_test.go b/internal/subagent/context_slice_test.go index c563c7cc..1864b3e0 100644 --- a/internal/subagent/context_slice_test.go +++ b/internal/subagent/context_slice_test.go @@ -300,6 +300,46 @@ func TestRebuildTaskContextSliceEmptyTodoAllowlistMeansNone(t *testing.T) { } } +func TestRebuildTaskContextSliceEmptyRelatedFileAllowlistMeansNone(t *testing.T) { + t.Parallel() + + task := agentsession.TodoItem{ + ID: "task-rebuild-empty-related", + Content: "空文件白名单语义", + Status: agentsession.TodoStatusInProgress, + Dependencies: []string{"dep-a"}, + } + todos := map[string]agentsession.TodoItem{ + "task-rebuild-empty-related": task, + "dep-a": { + ID: "dep-a", + Content: "依赖", + Status: agentsession.TodoStatusCompleted, + Artifacts: []string{"artifacts/dep-a.txt"}, + }, + } + + rebuilt, err := RebuildTaskContextSlice(TaskContextRebuildInput{ + Descriptor: TaskContextDescriptor{ + TaskID: "task-rebuild-empty-related", + DependencyTaskIDs: []string{"dep-a"}, + RelatedFilePaths: []string{}, + }, + Todos: todos, + MaxChars: 4000, + }) + if err != nil { + t.Fatalf("RebuildTaskContextSlice() error = %v", err) + } + + if len(rebuilt.RelatedFiles) != 0 { + t.Fatalf("RelatedFiles len = %d, want 0", len(rebuilt.RelatedFiles)) + } + if len(rebuilt.Descriptor.RelatedFilePaths) != 0 { + t.Fatalf("Descriptor.RelatedFilePaths = %v, want empty", rebuilt.Descriptor.RelatedFilePaths) + } +} + func TestRebuildTaskContextSliceNilTodoAllowlistKeepsSnapshotFragments(t *testing.T) { t.Parallel() @@ -478,6 +518,38 @@ func TestBuildTaskContextSliceExtremeBudgetKeepsNonEmptyGoal(t *testing.T) { } } +func TestBuildTaskContextSliceTinyBudgetNeverExceedsLimit(t *testing.T) { + t.Parallel() + + task := agentsession.TodoItem{ + ID: "task-very-long-id", + Content: "这是一个非常长的目标描述,用于触发预算极限裁剪", + Status: agentsession.TodoStatusInProgress, + Acceptance: []string{"验收条件A"}, + Dependencies: []string{"dep-a"}, + } + todos := map[string]agentsession.TodoItem{ + "task-very-long-id": task, + "dep-a": { + ID: "dep-a", + Status: agentsession.TodoStatusCompleted, + Artifacts: []string{"artifacts/dep-a.txt"}, + }, + } + slice := BuildTaskContextSlice(TaskContextSliceInput{ + Task: task, + Todos: todos, + MaxChars: 6, + }) + + if !slice.Truncated { + t.Fatalf("Truncated = false, want true") + } + if got := len([]rune(slice.Render())); got > 6 { + t.Fatalf("rendered chars exceed tiny budget: got %d, budget 6", got) + } +} + func TestTaskContextSliceRenderSanitizesControlChars(t *testing.T) { t.Parallel()