Files
2026-08-14 23:41:57 +08:00

187 lines
6.0 KiB
Go

// 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()
}