| 1 | package builtin |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "encoding/json" |
| 7 | "fmt" |
| 8 | "io" |
| 9 | "strings" |
| 10 | |
| 11 | "reasonix/internal/tool" |
| 12 | ) |
| 13 | |
| 14 | func init() { tool.RegisterBuiltin(todoWrite{}) } |
| 15 | |
| 16 | // todoWrite replaces the current turn's complete, flat task list. The list is |
| 17 | // model-managed progress UI; it is deliberately independent from Plan and Goal |
| 18 | // authorization and from delivery evidence. |
| 19 | type todoWrite struct{} |
| 20 | |
| 21 | type todoItem struct { |
| 22 | Content string `json:"content"` |
| 23 | Status string `json:"status"` |
| 24 | } |
| 25 | |
| 26 | type todoWriteResponse struct { |
| 27 | Todos []todoItem `json:"todos"` |
| 28 | Counts struct { |
| 29 | Total int `json:"total"` |
| 30 | Pending int `json:"pending"` |
| 31 | InProgress int `json:"in_progress"` |
| 32 | Completed int `json:"completed"` |
| 33 | } `json:"counts"` |
| 34 | } |
| 35 | |
| 36 | func (todoWrite) Name() string { return "todo_write" } |
| 37 | |
| 38 | func (todoWrite) Description() string { |
| 39 | return "Replace the current turn's complete task list. Send the full flat list on every call; an empty list clears it. Items may be reordered, removed, replanned, or have any number in progress. Each item contains only content and status (pending|in_progress|completed)." |
| 40 | } |
| 41 | |
| 42 | func (todoWrite) Schema() json.RawMessage { |
| 43 | return json.RawMessage(`{ |
| 44 | "type":"object", |
| 45 | "additionalProperties":false, |
| 46 | "properties":{ |
| 47 | "todos":{ |
| 48 | "type":"array", |
| 49 | "description":"The complete flat task list for this turn. Replaces the previous list; [] clears it.", |
| 50 | "items":{ |
| 51 | "type":"object", |
| 52 | "additionalProperties":false, |
| 53 | "properties":{ |
| 54 | "content":{"type":"string","minLength":1,"description":"Task text. Leading and trailing whitespace is removed."}, |
| 55 | "status":{"type":"string","enum":["pending","in_progress","completed"]} |
| 56 | }, |
| 57 | "required":["content","status"] |
| 58 | } |
| 59 | } |
| 60 | }, |
| 61 | "required":["todos"] |
| 62 | }`) |
| 63 | } |
| 64 | |
| 65 | func (todoWrite) ReadOnly() bool { return true } |
| 66 | |
| 67 | func (todoWrite) Execute(_ context.Context, args json.RawMessage) (string, error) { |
| 68 | var p struct { |
| 69 | Todos *[]json.RawMessage `json:"todos"` |
| 70 | } |
| 71 | dec := json.NewDecoder(bytes.NewReader(args)) |
| 72 | dec.DisallowUnknownFields() |
| 73 | if err := dec.Decode(&p); err != nil { |
| 74 | return "", fmt.Errorf("invalid todo_write arguments: %w; send only {todos:[{content,status}]}", err) |
| 75 | } |
| 76 | if err := ensureJSONEnd(dec); err != nil { |
| 77 | return "", fmt.Errorf("invalid todo_write arguments: %w", err) |
| 78 | } |
| 79 | if p.Todos == nil { |
| 80 | return "", fmt.Errorf("todos is required and must be an array") |
| 81 | } |
| 82 | |
| 83 | rawTodos := *p.Todos |
| 84 | response := todoWriteResponse{Todos: make([]todoItem, len(rawTodos))} |
| 85 | seen := make(map[string]int, len(rawTodos)) |
| 86 | for i, raw := range rawTodos { |
| 87 | if bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { |
| 88 | return "", fmt.Errorf("todos[%d] must be an object with content and status", i) |
| 89 | } |
| 90 | var item todoItem |
| 91 | itemDecoder := json.NewDecoder(bytes.NewReader(raw)) |
| 92 | itemDecoder.DisallowUnknownFields() |
| 93 | if err := itemDecoder.Decode(&item); err != nil { |
| 94 | return "", fmt.Errorf("todos[%d] is invalid: %w; use only content and status", i, err) |
| 95 | } |
| 96 | if err := ensureJSONEnd(itemDecoder); err != nil { |
| 97 | return "", fmt.Errorf("todos[%d] is invalid: %w", i, err) |
| 98 | } |
| 99 | item.Content = strings.TrimSpace(item.Content) |
| 100 | if item.Content == "" { |
| 101 | return "", fmt.Errorf("todos[%d].content must be non-empty after trimming", i) |
| 102 | } |
| 103 | if previous, ok := seen[item.Content]; ok { |
| 104 | return "", fmt.Errorf("todos[%d].content duplicates todos[%d].content %q; merge or rename one item", i, previous, item.Content) |
| 105 | } |
| 106 | seen[item.Content] = i |
| 107 | switch item.Status { |
| 108 | case "pending": |
| 109 | response.Counts.Pending++ |
| 110 | case "in_progress": |
| 111 | response.Counts.InProgress++ |
| 112 | case "completed": |
| 113 | response.Counts.Completed++ |
| 114 | default: |
| 115 | return "", fmt.Errorf("todos[%d].status %q is invalid; use pending, in_progress, or completed", i, item.Status) |
| 116 | } |
| 117 | response.Todos[i] = item |
| 118 | } |
| 119 | response.Counts.Total = len(response.Todos) |
| 120 | out, err := json.Marshal(response) |
| 121 | if err != nil { |
| 122 | return "", fmt.Errorf("encode todo_write result: %w", err) |
| 123 | } |
| 124 | return string(out), nil |
| 125 | } |
| 126 | |
| 127 | func ensureJSONEnd(dec *json.Decoder) error { |
| 128 | var extra any |
| 129 | if err := dec.Decode(&extra); err == io.EOF { |
| 130 | return nil |
| 131 | } else if err != nil { |
| 132 | return err |
| 133 | } |
| 134 | return fmt.Errorf("multiple JSON values are not allowed") |
| 135 | } |
| 136 |