| 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 |