todos.go

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