// This file implements the todo tool (US-011, #127): a structured task list the // model uses to plan and track multi-step work, with progress visible to the // user. Unlike the file tools this one is stateful — the written list lives in a // per-session TodoStore the tool holds, so a later write replaces the plan and // the REPL can render the current progress after each update. // // pi itself has no such tool; this mirrors Claude Code's TodoWrite: the model // submits the WHOLE list each call (not incremental edits), each item carries a // content string and a status of pending | in_progress | completed. package agenttool import ( "context" "encoding/json" "fmt" "strings" "sync" "github.com/smallnest/pigo/internal/agentcore" ) // TodoStatus is the lifecycle state of a single todo item. type TodoStatus string const ( // TodoPending is a task not yet started. TodoPending TodoStatus = "pending" // TodoInProgress is the task currently being worked on. TodoInProgress TodoStatus = "in_progress" // TodoCompleted is a finished task. TodoCompleted TodoStatus = "completed" ) // validTodoStatus reports whether s is one of the three accepted statuses. func validTodoStatus(s TodoStatus) bool { switch s { case TodoPending, TodoInProgress, TodoCompleted: return true default: return false } } // TodoItem is one entry in the task list. type TodoItem struct { // Content is the human-readable task description. Content string `json:"content"` // Status is the item's lifecycle state. Status TodoStatus `json:"status"` } // TodoStore holds the current task list for a session. It is safe for concurrent // use so the tool (which may run in a batch) and the REPL renderer can touch it // without racing. A single store is shared for a session's lifetime. type TodoStore struct { mu sync.RWMutex items []TodoItem } // NewTodoStore returns an empty store. func NewTodoStore() *TodoStore { return &TodoStore{} } // Set replaces the whole list with items (a copy, so the caller's slice can be // reused). func (s *TodoStore) Set(items []TodoItem) { s.mu.Lock() defer s.mu.Unlock() s.items = append(s.items[:0:0], items...) } // Snapshot returns a copy of the current list, safe to read without holding the // lock. func (s *TodoStore) Snapshot() []TodoItem { s.mu.RLock() defer s.mu.RUnlock() return append([]TodoItem(nil), s.items...) } // TodoTool is the stateful todo-list tool. It writes the submitted list into // Store, replacing any previous list, and returns a rendered progress view. type TodoTool struct { // Store holds the session task list. Must be non-nil; NewTodoStore builds one. Store *TodoStore } // todoToolArgs is the decoded argument shape: the full task list to store. type todoToolArgs struct { Todos []TodoItem `json:"todos"` } // Name implements AgentTool. func (t *TodoTool) Name() string { return "todo" } // Description implements AgentTool. func (t *TodoTool) Description() string { return "Record and update a structured task list to plan and track multi-step " + "work. Submit the ENTIRE list every call; it replaces the previous list. " + "Each item has a content string and a status of pending, in_progress, or " + "completed. Keep exactly one item in_progress at a time and mark items " + "completed as soon as they are done." } // Schema implements AgentTool. func (t *TodoTool) Schema() json.RawMessage { return json.RawMessage(`{ "type": "object", "properties": { "todos": { "type": "array", "description": "The full task list, replacing any previous list.", "items": { "type": "object", "properties": { "content": {"type": "string", "description": "Task description."}, "status": {"type": "string", "enum": ["pending", "in_progress", "completed"], "description": "Task lifecycle state."} }, "required": ["content", "status"], "additionalProperties": false } } }, "required": ["todos"], "additionalProperties": false }`) } // ExecutionMode implements AgentTool. Updating the shared list mutates session // state → sequential so a batch cannot interleave two list writes. func (t *TodoTool) ExecutionMode() agentcore.ToolExecutionMode { return agentcore.ToolExecutionSequential } // Execute implements AgentTool. It validates every item's status, stores the // list, and returns the rendered progress as the result content. Invalid input // degrades to an error result (matching the file tools) rather than a Go error. func (t *TodoTool) Execute(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) { a, bad := decodeArgs[todoToolArgs](args, "todo") if bad != nil { return *bad, nil } for i, it := range a.Todos { if strings.TrimSpace(it.Content) == "" { return errorResult(fmt.Sprintf("todo: item %d has empty content", i+1)), nil } if !validTodoStatus(it.Status) { return errorResult(fmt.Sprintf("todo: item %d has invalid status %q (want pending|in_progress|completed)", i+1, it.Status)), nil } } if t.Store == nil { t.Store = NewTodoStore() } t.Store.Set(a.Todos) rendered := RenderTodoList(a.Todos) return agentcore.AgentToolResult{ Content: agentcore.ContentList{agentcore.NewTextContent(rendered)}, Details: map[string]any{"todos": a.Todos}, }, nil } // RenderTodoList renders items as a checkbox progress block, one line per task, // with a trailing summary count. An empty list renders as a single "(no tasks)" // line so an intentional clear is still visible. The marks are: [ ] pending, // [~] in_progress, [x] completed. func RenderTodoList(items []TodoItem) string { if len(items) == 0 { return "Todos: (no tasks)" } var b strings.Builder done := 0 b.WriteString("Todos:") for _, it := range items { mark := " " switch it.Status { case TodoInProgress: mark = "~" case TodoCompleted: mark = "x" done++ } fmt.Fprintf(&b, "\n [%s] %s", mark, it.Content) } fmt.Fprintf(&b, "\n(%d/%d completed)", done, len(items)) return b.String() }