Vectrum
Sign In
117 lines · 3.7 KB · Go
package tools

import (
	"context"
	"fmt"
	"strings"
	"sync"
)

// Todo statuses. They are the three both Claude Code and Codex settled on,
// and the set is closed: a fourth state invites a list that is never finished.
const (
	TodoPending    = "pending"
	TodoInProgress = "in_progress"
	TodoDone       = "completed"
)

// Todo is one item of the working plan.
type Todo struct {
	Content string `json:"content" jsonschema_description:"What the step is, in a few words"`
	Status  string `json:"status" jsonschema_description:"pending, in_progress or completed"`
}

// Todos holds the current plan. It lives outside the tool so a front end can
// show the list beside the conversation rather than only where it scrolled
// past, and so the list survives being rewritten every call.
type Todos struct {
	mu    sync.Mutex
	items []Todo
}

func NewTodos() *Todos { return &Todos{} }

// Snapshot is the current list, copied so a reader cannot race a rewrite.
func (t *Todos) Snapshot() []Todo {
	t.mu.Lock()
	defer t.mu.Unlock()
	return append([]Todo(nil), t.items...)
}

func (t *Todos) set(items []Todo) {
	t.mu.Lock()
	defer t.mu.Unlock()
	t.items = append([]Todo(nil), items...)
}

// TodoWriteArgs is the todo_write input. The whole list is sent every time:
// a patch protocol would need stable ids, and ids are one more thing for a
// model to get wrong for no gain on a list this short.
type TodoWriteArgs struct {
	Todos []Todo `json:"todos" jsonschema_description:"The complete list, replacing the previous one"`
}

// NewTodoWrite builds the todo_write tool.
func NewTodoWrite(t *Todos) Tool {
	if t == nil {
		return nil
	}
	return New("todo_write", strings.TrimSpace(`
Record the plan for a multi-step task and keep it current. Send the whole list
each time; it replaces the previous one. Keep exactly one item in_progress and
mark it completed as soon as it is done, rather than marking several at the end.

Use it for work with several distinct steps, where the order matters, or where
the user asked for more than one thing. Do not use it for a task you can finish
in one or two actions: a list of one step is overhead.`),
		func(_ context.Context, in TodoWriteArgs) (string, error) {
			if len(in.Todos) == 0 {
				return "", fmt.Errorf("todos is required and must not be empty; to clear a finished plan, send the list with every item completed")
			}
			var inProgress int
			for i, item := range in.Todos {
				if strings.TrimSpace(item.Content) == "" {
					return "", fmt.Errorf("todo %d has no content", i+1)
				}
				switch item.Status {
				case TodoPending, TodoInProgress, TodoDone:
				default:
					return "", fmt.Errorf("todo %d has status %q; it must be one of %s, %s, %s",
						i+1, item.Status, TodoPending, TodoInProgress, TodoDone)
				}
				if item.Status == TodoInProgress {
					inProgress++
				}
			}
			t.set(in.Todos)
			return renderTodos(in.Todos, inProgress), nil
		})
}

// renderTodos shows the list back. The result is what the user sees in the
// transcript, so it is written for them as much as for the model.
func renderTodos(items []Todo, inProgress int) string {
	var b strings.Builder
	var done int
	for _, item := range items {
		mark := " "
		switch item.Status {
		case TodoDone:
			mark = "x"
			done++
		case TodoInProgress:
			mark = "»"
		}
		fmt.Fprintf(&b, "[%s] %s\n", mark, item.Content)
	}
	fmt.Fprintf(&b, "\n%d of %d done.", done, len(items))
	// Said rather than refused: a wrong count is worth one sentence of
	// correction, not a failed call that costs a whole turn to retry.
	if inProgress > 1 {
		fmt.Fprintf(&b, " %d items are in_progress; keep one, and leave the rest pending.", inProgress)
	}
	if inProgress == 0 && done < len(items) {
		b.WriteString(" Nothing is in_progress; mark the step you are starting.")
	}
	return b.String()
}