返回 DeepSeek-Reasonix
todo.go
根目录 / internal / tool / builtin / todo.go
1 package builtin
2
3 import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "strings"
8
9 "reasonix/internal/evidence"
10 "reasonix/internal/tool"
11 )
12
13 func init() { tool.RegisterBuiltin(todoWrite{}) }
14
15 // todoWrite records the agent's running task list. It has no host side effects —
16 // the full list lives in the call's args (the model re-sends it whole on every
17 // update), which a frontend renders as a checklist. Execute just validates the
18 // shape and acks with a count, so the model gets a stable confirmation. The agent
19 // keeps one item in_progress at a time and flips each to completed as it finishes.
20 type todoWrite struct{}
21
22 type todoItem struct {
23 Content string `json:"content"`
24 Status string `json:"status"`
25 ActiveForm string `json:"activeForm,omitempty"`
26 Level int `json:"level,omitempty"`
27 }
28
29 func (todoWrite) Name() string { return "todo_write" }
30
31 func (todoWrite) Description() string {
32 return "Record and update a structured task list for the current work. Send the COMPLETE list every call — it replaces the previous one. Use it to plan multi-step work and show progress: keep exactly one item in_progress at a time, and flip an item to completed the moment it's done (don't batch completions). Skip it for trivial single-step tasks. The list is two-level: a `level` 0 item is a PHASE (a milestone) and the `level` 1 items after it are its concrete sub-steps; omit `level` (0) for a flat list. Each item has `content` (imperative, e.g. \"Add the parser\"), `status` (pending|in_progress|completed), `activeForm` (present-continuous shown while in progress, e.g. \"Adding the parser\"), and optional `level` (0 phase | 1 sub-step)."
33 }
34
35 func (todoWrite) Schema() json.RawMessage {
36 return json.RawMessage(`{
37 "type":"object",
38 "properties":{
39 "todos":{
40 "type":"array",
41 "description":"The complete task list, in order. Replaces any previous list.",
42 "items":{
43 "type":"object",
44 "properties":{
45 "content":{"type":"string","description":"Imperative description of the task."},
46 "status":{"type":"string","enum":["pending","in_progress","completed"],"description":"Task state. Keep at most one in_progress."},
47 "activeForm":{"type":"string","description":"Present-continuous form shown while the task is in progress (e.g. \"Running tests\")."},
48 "level":{"type":"integer","enum":[0,1],"description":"Nesting level: 0 = phase/milestone, 1 = a sub-step of the phase above it. Omit for a flat list."}
49 },
50 "required":["content","status"]
51 }
52 }
53 },
54 "required":["todos"]
55 }`)
56 }
57
58 // ReadOnly is true: todo_write only records a list (no filesystem or process
59 // effect), so it never needs approval and stays available in plan mode — where
60 // laying out a plan as todos is exactly the point.
61 func (todoWrite) ReadOnly() bool { return true }
62
63 func (todoWrite) Execute(ctx context.Context, args json.RawMessage) (string, error) {
64 var p struct {
65 Todos []todoItem `json:"todos"`
66 }
67 if err := json.Unmarshal(args, &p); err != nil {
68 return "", fmt.Errorf("invalid args: %w", err)
69 }
70 var done, active, pending int
71 for i, t := range p.Todos {
72 if t.Content == "" {
73 return "", fmt.Errorf("todo %d: content is required", i+1)
74 }
75 if t.Level < 0 || t.Level > 1 {
76 return "", fmt.Errorf("todo %d: invalid level %d (want 0 phase | 1 sub-step)", i+1, t.Level)
77 }
78 switch t.Status {
79 case "completed":
80 done++
81 case "in_progress":
82 active++
83 case "pending", "":
84 pending++
85 default:
86 return "", fmt.Errorf("todo %d: invalid status %q (want pending|in_progress|completed)", i+1, t.Status)
87 }
88 }
89 if err := evidence.ValidateSerialTodos(toEvidenceTodos(p.Todos)); err != nil {
90 return "", err
91 }
92 if !tool.HasPlanReplacementAuthorization(ctx) {
93 if err := verifyTodoCurrentContinuity(ctx, p.Todos); err != nil {
94 return "", err
95 }
96 }
97 if err := verifyCompletedTodoPositions(ctx, p.Todos); err != nil {
98 return "", err
99 }
100 if err := verifyTodoCompletionTransitions(ctx, p.Todos); err != nil {
101 return "", err
102 }
103 return fmt.Sprintf("Todos updated: %d total — %d completed, %d in progress, %d pending.",
104 len(p.Todos), done, active, pending), nil
105 }
106
107 func verifyTodoCurrentContinuity(ctx context.Context, todos []todoItem) error {
108 previous := todoBaseline(ctx)
109 if len(previous) == 0 {
110 return nil
111 }
112 // The single current item must survive the rewrite. In a layered phase this
113 // is either its active sub-step or, after all children finish, the phase
114 // header waiting for final sign-off.
115 for i, todo := range previous {
116 if strings.TrimSpace(todo.Status) != "in_progress" {
117 continue
118 }
119 match, found := evidence.MatchTodoIdentity(todo, toEvidenceTodos(todos))
120 if !found {
121 return fmt.Errorf("current todo %d %q cannot be removed or replaced while it is in_progress; complete it with complete_step before changing the remaining list", i+1, todo.Content)
122 }
123 if match.Status == "pending" || match.Status == "" {
124 return fmt.Errorf("current todo %d %q cannot move back to pending; keep it in_progress or complete it with complete_step", i+1, todo.Content)
125 }
126 }
127 return nil
128 }
129
130 func verifyCompletedTodoPositions(ctx context.Context, todos []todoItem) error {
131 previous := todoBaseline(ctx)
132 if len(previous) == 0 {
133 return nil
134 }
135 for i, todo := range todos {
136 if todo.Status != "completed" {
137 continue
138 }
139 match, found := evidence.MatchTodoIdentity(toEvidenceTodo(todo), previous)
140 if !found || match.Index != i+1 {
141 return fmt.Errorf("completed todo %d %q cannot be inserted, duplicated, or reordered; preserve the completed prefix and sign off the current item with complete_step", i+1, todo.Content)
142 }
143 }
144 if len(evidence.IncompleteTodos(previous)) > 0 && !evidence.PreservesCompletedTodoPositions(previous, toEvidenceTodos(todos)) {
145 return fmt.Errorf("completed task history cannot be removed, changed, or reordered while the plan is active; preserve every completed item at its original position")
146 }
147 return nil
148 }
149
150 func todoBaseline(ctx context.Context) []evidence.TodoItem {
151 if ledger, ok := evidence.FromContext(ctx); ok {
152 if previous, ok := ledger.LatestTodos(); ok && len(previous) > 0 {
153 return previous
154 }
155 }
156 previous, _ := evidence.TodoStateFromContext(ctx)
157 return previous
158 }
159
160 func verifyTodoCompletionTransitions(ctx context.Context, todos []todoItem) error {
161 ledger, ok := evidence.FromContext(ctx)
162 if !ok {
163 return nil
164 }
165 missing, hasBaseline := ledger.UnverifiedCompletedTodos(toEvidenceTodos(todos))
166 if !hasBaseline {
167 if previous, ok := evidence.TodoStateFromContext(ctx); ok && len(previous) > 0 {
168 for i, todo := range todos {
169 if todo.Status != "completed" {
170 continue
171 }
172 match, found := evidence.MatchTodoIdentity(toEvidenceTodo(todo), previous)
173 if !found || match.Status != "completed" {
174 return fmt.Errorf("todo %d %q cannot become completed without signing off the current item with complete_step", i+1, todo.Content)
175 }
176 }
177 return nil
178 }
179 for i, todo := range todos {
180 if todo.Status == "completed" {
181 return fmt.Errorf("initial todo %d %q cannot start completed; establish the task list before doing the work, then sign off the current item with complete_step", i+1, todo.Content)
182 }
183 }
184 return nil
185 }
186 if len(missing) == 0 {
187 return nil
188 }
189 const hint = "; sign each finished item off with complete_step first, then re-send this todo_write"
190 if len(missing) == 1 {
191 m := missing[0]
192 return fmt.Errorf("todo %d %q is newly completed but has no matching successful complete_step receipt in this turn%s", m.Index, m.Content, hint)
193 }
194 return fmt.Errorf("%d todos are newly completed but have no matching successful complete_step receipts in this turn%s", len(missing), hint)
195 }
196
197 func toEvidenceTodos(todos []todoItem) []evidence.TodoItem {
198 out := make([]evidence.TodoItem, 0, len(todos))
199 for _, t := range todos {
200 out = append(out, evidence.TodoItem{
201 Content: t.Content,
202 Status: t.Status,
203 ActiveForm: t.ActiveForm,
204 Level: t.Level,
205 })
206 }
207 return out
208 }
209
210 func toEvidenceTodo(todo todoItem) evidence.TodoItem {
211 return evidence.TodoItem{
212 Content: todo.Content,
213 Status: todo.Status,
214 ActiveForm: todo.ActiveForm,
215 Level: todo.Level,
216 }
217 }
218
218 lines GO