diff --git a/internal/subagent/context_slice.go b/internal/subagent/context_slice.go new file mode 100644 index 00000000..965d88ed --- /dev/null +++ b/internal/subagent/context_slice.go @@ -0,0 +1,884 @@ +package subagent + +import ( + "fmt" + "sort" + "strings" + "unicode" + + 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 = 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 +} + +// 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 taskID := sanitizeRenderValue(s.TaskID); taskID != "" { + b.WriteString("task_id: ") + b.WriteString(taskID) + b.WriteString("\n") + } + if goal := sanitizeRenderValue(s.Goal); goal != "" { + b.WriteString("goal: ") + b.WriteString(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 + ReadOnlyTodos bool + 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, + ReadOnlyTodos: true, + ActivatedSkills: activatedSkills, + RelatedFiles: relatedFiles, + MaxChars: input.MaxChars, + MaxTodoFragments: input.MaxTodoFragments, + MaxDependencyArtifacts: input.MaxDependencyArtifacts, + MaxRelatedFiles: input.MaxRelatedFiles, + }) + + 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) + 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 + } + 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) + 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 + } + 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 && currentChars > maxChars { + *list = (*list)[:len(*list)-1] + truncated = true + refreshChars() + } + } + trimFiles := func(minKeep int) { + 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 && currentChars > maxChars { + slice.TodoFragment = slice.TodoFragment[:len(slice.TodoFragment)-1] + truncated = true + refreshChars() + } + } + + trimList(&slice.ActivatedSkills, 0) + trimFiles(0) + trimTodos(1) + trimList(&slice.DependencyArtifacts, 0) + trimList(&slice.Acceptance, 1) + if currentChars <= maxChars { + return truncated + } + + 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], acceptanceLimit) + } + } + if len(slice.TodoFragment) > 0 { + todoLimit := maxChars / 3 + if todoLimit < 1 { + todoLimit = 1 + } + slice.TodoFragment[0].Content = truncateRunes(slice.TodoFragment[0].Content, todoLimit) + } + refreshChars() + if currentChars <= 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] + } + refreshChars() + if currentChars <= maxChars { + return true + } + + 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) + 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 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 { + 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)) + 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 { + if allow == nil { + return normalizeContextFileSummaries(items) + } + allowed := dedupeAndTrim(allow) + if len(allowed) == 0 { + return nil + } + 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 { + if allow == nil { + return dedupeAndTrim(items) + } + 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 { + if allow == nil { + result := make([]TaskTodoFragment, len(fragments)) + copy(result, fragments) + return result + } + 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 { + 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 长度裁剪文本并保留省略标记。 +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 + } + for _, line := range renderedBulletLines(title, items) { + builder.WriteString(line) + builder.WriteString("\n") + } +} + +// writeFileSummarySection 渲染相关文件摘要列表。 +func writeFileSummarySection(builder *strings.Builder, title string, items []TaskContextFileSummary) { + if builder == nil || len(items) == 0 { + return + } + for _, line := range renderedFileSummaryLines(title, items) { + builder.WriteString(line) + builder.WriteString("\n") + } +} + +// writeTodoFragmentSection 渲染 Todo 片段列表,便于模型识别优先级和状态。 +func writeTodoFragmentSection(builder *strings.Builder, title string, items []TaskTodoFragment) { + if builder == nil || len(items) == 0 { + return + } + 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 { + 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 { + line += " p=" + fmt.Sprintf("%d", item.Priority) + } + if content := sanitizeRenderValue(item.Content); content != "" { + line += ": " + content + } + 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 new file mode 100644 index 00000000..1864b3e0 --- /dev/null +++ b/internal/subagent/context_slice_test.go @@ -0,0 +1,599 @@ +package subagent + +import ( + "slices" + "strings" + "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 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, + Artifacts: []string{"artifacts/dep-a.txt"}, + }, + "extra": { + ID: "extra", + Content: "额外运行中任务", + Status: agentsession.TodoStatusInProgress, + }, + } + + rebuilt, err := RebuildTaskContextSlice(TaskContextRebuildInput{ + Descriptor: TaskContextDescriptor{ + TaskID: "task-rebuild-empty-fragments", + DependencyTaskIDs: []string{"dep-a"}, + TodoFragmentIDs: []string{}, + }, + 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 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() + + 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() + + 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") + } +} + +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) + } +} + +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 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() + + 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 5b7514ea..9f681526 100644 --- a/internal/subagent/output_contract.go +++ b/internal/subagent/output_contract.go @@ -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)) diff --git a/internal/subagent/output_contract_test.go b/internal/subagent/output_contract_test.go index 3971c2d0..55635e19 100644 --- a/internal/subagent/output_contract_test.go +++ b/internal/subagent/output_contract_test.go @@ -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) + } +} diff --git a/internal/subagent/scheduler.go b/internal/subagent/scheduler.go index fd116ba6..a53d2c2d 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,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{ + 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} diff --git a/internal/subagent/scheduler_test.go b/internal/subagent/scheduler_test.go index 82ad411a..db1b7b95 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,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() 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..ca8b426e 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 校验任务输入是否合法。 @@ -83,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) + } + }) + } +}