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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions internal/app/bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import (
"neo-code/internal/tools/filesystem"
"neo-code/internal/tools/mcp"
memotool "neo-code/internal/tools/memo"
"neo-code/internal/tools/todo"
"neo-code/internal/tools/webfetch"
"neo-code/internal/tui"
)
Expand Down Expand Up @@ -244,6 +245,7 @@ func buildToolRegistry(cfg config.Config) (*tools.Registry, func() error, error)
MaxResponseBytes: cfg.Tools.WebFetch.MaxResponseBytes,
SupportedContentTypes: cfg.Tools.WebFetch.SupportedContentTypes,
}))
toolRegistry.Register(todo.New())
mcpRegistry, err := buildMCPRegistry(cfg)
if err != nil {
return nil, nil, err
Expand Down
2 changes: 2 additions & 0 deletions internal/context/builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ func NewBuilderWithToolPolicies(policies MicroCompactPolicySource) Builder {
corePromptSource{},
&projectRulesSource{},
taskStateSource{},
todosSource{},
skillPromptSource{},
systemSource,
},
Expand All @@ -43,6 +44,7 @@ func NewBuilderWithMemo(policies MicroCompactPolicySource, memoSource SectionSou
corePromptSource{},
&projectRulesSource{},
taskStateSource{},
todosSource{},
skillPromptSource{},
}
if memoSource != nil {
Expand Down
33 changes: 33 additions & 0 deletions internal/context/builder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"reflect"
"strings"
"testing"
"time"

"neo-code/internal/config"
"neo-code/internal/context/internalcompact"
Expand Down Expand Up @@ -142,6 +143,38 @@ func TestDefaultBuilderBuildIncludesTaskStateBeforeSystemState(t *testing.T) {
}
}

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

builder := NewBuilder()
got, err := builder.Build(stdcontext.Background(), BuildInput{
Messages: []providertypes.Message{{Role: "user", Content: "hello"}},
Todos: []agentsession.TodoItem{
{
ID: "todo-1",
Content: "implement todo tool",
Status: agentsession.TodoStatusInProgress,
Priority: 3,
Revision: 2,
CreatedAt: time.Now(),
},
},
Metadata: testMetadata(t.TempDir()),
})
if err != nil {
t.Fatalf("Build() error = %v", err)
}

todoIndex := strings.Index(got.SystemPrompt, "## Todo State")
systemStateIndex := strings.Index(got.SystemPrompt, "## System State")
if todoIndex < 0 || systemStateIndex < 0 {
t.Fatalf("expected todo and system sections, got %q", got.SystemPrompt)
}
if todoIndex > systemStateIndex {
t.Fatalf("expected todo section before system section, got %q", got.SystemPrompt)
}
}

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

Expand Down
1 change: 1 addition & 0 deletions internal/context/prompt.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ var defaultPromptSections = []promptSection{
Title: "Tool Usage",
Content: "- Use the minimum set of tools needed to make progress or verify a result safely.\n" +
"- Only call tools that are actually exposed in the current tool schema. Do not invent tool names.\n" +
"- For multi-step implementation work, keep task state explicit via `todo_write` (plan/add/update/set_status/claim/complete/fail) instead of relying on implicit memory.\n" +
"- Prefer structured workspace tools over `bash` whenever possible: use `filesystem_read_file`, `filesystem_grep`, and `filesystem_glob` for reading/search, `filesystem_edit` for precise edits, and `filesystem_write_file` only for new files or full rewrites.\n" +
"- Do not use `bash` to edit files when the filesystem tools can make the change safely.\n" +
"- When using `bash`, avoid interactive or blocking commands and pass non-interactive flags when they are available.\n" +
Expand Down
3 changes: 3 additions & 0 deletions internal/context/prompt_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,9 @@ func TestDefaultToolUsagePromptIncludesPermissionAndAntiLoopGuidance(t *testing.
if !strings.Contains(toolUsage, "Do not invent tool names") {
t.Fatalf("expected Tool Usage to forbid invented tool names, got %q", toolUsage)
}
if !strings.Contains(toolUsage, "`todo_write`") {
t.Fatalf("expected Tool Usage to mention todo_write for task state, got %q", toolUsage)
}
if !strings.Contains(toolUsage, "`filesystem_read_file`, `filesystem_grep`, and `filesystem_glob`") {
t.Fatalf("expected Tool Usage to prefer structured read/search tools, got %q", toolUsage)
}
Expand Down
145 changes: 145 additions & 0 deletions internal/context/source_todos.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
package context

import (
"context"
"fmt"
"sort"
"strings"

agentsession "neo-code/internal/session"
)

const (
maxPromptTodos = 24
maxPromptTodoIDLength = 80
maxPromptTodoTextLen = 240
maxPromptTodoDeps = 8
maxPromptOwnerLen = 64
)

// todosSource 负责把会话 Todo 摘要渲染为 prompt section。
type todosSource struct{}

// Sections 渲染非终态 Todo,按状态与优先级排序后注入上下文。
func (todosSource) Sections(ctx context.Context, input BuildInput) ([]promptSection, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
if len(input.Todos) == 0 {
return nil, nil
}

active := make([]agentsession.TodoItem, 0, len(input.Todos))
for _, item := range input.Todos {
if !item.Status.IsTerminal() {
active = append(active, item.Clone())
}
}
if len(active) == 0 {
return nil, nil
}

sort.SliceStable(active, func(i, j int) bool {
left := todoStatusRank(active[i].Status)
right := todoStatusRank(active[j].Status)
if left != right {
return left < right
}
if active[i].Priority != active[j].Priority {
return active[i].Priority > active[j].Priority
}
return active[i].CreatedAt.Before(active[j].CreatedAt)
})
if len(active) > maxPromptTodos {
active = active[:maxPromptTodos]
}

lines := make([]string, 0, len(active)+1)
for _, item := range active {
id := sanitizePromptValue(item.ID, maxPromptTodoIDLength)
content := sanitizePromptValue(item.Content, maxPromptTodoTextLen)
line := fmt.Sprintf("- [%s] id=%q (p=%d, rev=%d) content=%q", item.Status, id, item.Priority, item.Revision, content)
lines = append(lines, line)
if len(item.Dependencies) > 0 {
deps := sanitizePromptList(item.Dependencies, maxPromptTodoDeps, maxPromptTodoIDLength)
quotedDeps := make([]string, 0, len(deps))
for _, dep := range deps {
quotedDeps = append(quotedDeps, fmt.Sprintf("%q", dep))
}
lines = append(lines, fmt.Sprintf(" deps: %s", strings.Join(quotedDeps, ", ")))
}
if strings.TrimSpace(item.OwnerType) != "" || strings.TrimSpace(item.OwnerID) != "" {
ownerType := sanitizePromptValue(item.OwnerType, maxPromptOwnerLen)
ownerID := sanitizePromptValue(item.OwnerID, maxPromptOwnerLen)
lines = append(lines, fmt.Sprintf(" owner: type=%q id=%q", ownerType, ownerID))
}
}

return []promptSection{
{
Title: "Todo State",
Content: strings.Join(lines, "\n"),
},
}, nil
}

// sanitizePromptValue 对 Todo 文本字段做去控制字符、折叠空白与长度截断,降低提示注入风险。
func sanitizePromptValue(value string, maxLen int) string {
value = strings.TrimSpace(value)
if value == "" || maxLen <= 0 {
return ""
}
parts := strings.Fields(strings.Map(func(r rune) rune {
if r < 32 || r == 127 {
return ' '
}
return r
}, value))
normalized := strings.Join(parts, " ")
runes := []rune(normalized)
if len(runes) <= maxLen {
return normalized
}
return string(runes[:maxLen])
}

// sanitizePromptList 对文本列表做逐项规范化、去重和数量限制,避免单条 Todo 扩大注入面。
func sanitizePromptList(values []string, maxItems int, itemMaxLen int) []string {
if len(values) == 0 || maxItems <= 0 || itemMaxLen <= 0 {
return nil
}
result := make([]string, 0, len(values))
seen := make(map[string]struct{}, len(values))
for _, raw := range values {
value := sanitizePromptValue(raw, itemMaxLen)
if value == "" {
continue
}
if _, exists := seen[value]; exists {
continue
}
seen[value] = struct{}{}
result = append(result, value)
if len(result) >= maxItems {
break
}
}
if len(result) == 0 {
return nil
}
return result
}

// 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
}
}
Loading
Loading