todos.go

  1package tools
  2
  3import (
  4	"context"
  5	_ "embed"
  6	"fmt"
  7	"maps"
  8
  9	"charm.land/fantasy"
 10	"github.com/charmbracelet/crush/internal/config"
 11	"github.com/charmbracelet/crush/internal/session"
 12)
 13
 14//go:embed todos.md
 15var todosDescription []byte
 16
 17const TodosToolName = "todos"
 18
 19type TodosParams struct {
 20	Todos []TodoItem `json:"todos" description:"The updated todo list"`
 21}
 22
 23type TodoItem struct {
 24	Content    string `json:"content" description:"What needs to be done (imperative form)"`
 25	Status     string `json:"status" description:"Task status: pending, in_progress, or completed"`
 26	ActiveForm string `json:"active_form" description:"Present continuous form (e.g., 'Running tests')"`
 27}
 28
 29type TodosResponseMetadata struct {
 30	IsNew         bool           `json:"is_new"`
 31	Todos         []session.Todo `json:"todos"`
 32	JustCompleted []string       `json:"just_completed,omitempty"`
 33	JustStarted   string         `json:"just_started,omitempty"`
 34	Completed     int            `json:"completed"`
 35	Total         int            `json:"total"`
 36}
 37
 38func NewTodosTool(sessions session.Service, cfg *config.ConfigStore) fantasy.AgentTool {
 39	return fantasy.NewAgentTool(
 40		TodosToolName,
 41		FirstLineDescription(todosDescription),
 42		func(ctx context.Context, params TodosParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
 43			sessionID := GetSessionFromContext(ctx)
 44			if sessionID == "" {
 45				return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for managing todos")
 46			}
 47
 48			currentSession, err := sessions.Get(ctx, sessionID)
 49			if err != nil {
 50				return fantasy.ToolResponse{}, fmt.Errorf("failed to get session: %w", err)
 51			}
 52
 53			isNew := len(currentSession.Todos) == 0
 54			oldStatusByContent := make(map[string]session.TodoStatus)
 55			for _, todo := range currentSession.Todos {
 56				oldStatusByContent[todo.Content] = todo.Status
 57			}
 58
 59			for _, item := range params.Todos {
 60				switch item.Status {
 61				case "pending", "in_progress", "completed":
 62				default:
 63					return fantasy.ToolResponse{}, fmt.Errorf("invalid status %q for todo %q", item.Status, item.Content)
 64				}
 65			}
 66
 67			todos := make([]session.Todo, len(params.Todos))
 68			var justCompleted []string
 69			var justStarted string
 70			completedCount := 0
 71
 72			for i, item := range params.Todos {
 73				todos[i] = session.Todo{
 74					Content:    item.Content,
 75					Status:     session.TodoStatus(item.Status),
 76					ActiveForm: item.ActiveForm,
 77				}
 78
 79				newStatus := session.TodoStatus(item.Status)
 80				oldStatus, existed := oldStatusByContent[item.Content]
 81
 82				if newStatus == session.TodoStatusCompleted {
 83					completedCount++
 84					if existed && oldStatus != session.TodoStatusCompleted {
 85						justCompleted = append(justCompleted, item.Content)
 86					}
 87				}
 88
 89				if newStatus == session.TodoStatusInProgress {
 90					if !existed || oldStatus != session.TodoStatusInProgress {
 91						if item.ActiveForm != "" {
 92							justStarted = item.ActiveForm
 93						} else {
 94							justStarted = item.Content
 95						}
 96					}
 97				}
 98			}
 99
100			currentSession.Todos = todos
101			models := maps.Clone(cfg.Config().Models)
102			for name, model := range models {
103				model.ProviderOptions = maps.Clone(model.ProviderOptions)
104				models[name] = model
105			}
106			_, err = sessions.SaveWithModels(ctx, currentSession, models)
107			if err != nil {
108				return fantasy.ToolResponse{}, fmt.Errorf("failed to save todos: %w", err)
109			}
110
111			response := "Todo list updated successfully.\n\n"
112
113			pendingCount := 0
114			inProgressCount := 0
115
116			for _, todo := range todos {
117				switch todo.Status {
118				case session.TodoStatusPending:
119					pendingCount++
120				case session.TodoStatusInProgress:
121					inProgressCount++
122				}
123			}
124
125			response += fmt.Sprintf("Status: %d pending, %d in progress, %d completed\n",
126				pendingCount, inProgressCount, completedCount)
127
128			response += "Todos have been modified successfully. Ensure that you continue to use the todo list to track your progress. Please proceed with the current tasks if applicable."
129
130			metadata := TodosResponseMetadata{
131				IsNew:         isNew,
132				Todos:         todos,
133				JustCompleted: justCompleted,
134				JustStarted:   justStarted,
135				Completed:     completedCount,
136				Total:         len(todos),
137			}
138
139			return fantasy.WithResponseMetadata(fantasy.NewTextResponse(response), metadata), nil
140		})
141}