Skip to content

Commit 2770589

Browse files
feat: Implement TodoWrite LLM tool for structured task lists (#58)
Add TodoWrite tool that enables LLMs to create and manage structured task lists for coding sessions. ## Features - JSON Schema validation with required fields (id, content, status) - Single in_progress task invariant enforcement - Duplicate ID detection and validation - Replace-all semantics for todo list updates - Integration with existing tool registry and configuration system ## Quality Assurance - Comprehensive test coverage including edge cases - Domain type definitions for TodoItem and TodoWriteToolResult - Configuration support with approval settings - Follows established tool implementation patterns Closes #42 🤖 Generated with [Claude Code](https://claude.ai/code) --------- Signed-off-by: Eden Reich <eden.reich@gmail.com> Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> Co-authored-by: Eden Reich <edenreich@users.noreply.github.com>
1 parent 1c11ebc commit 2770589

File tree

11 files changed

+985
-17
lines changed

11 files changed

+985
-17
lines changed

.infer/config.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,9 @@ tools:
7272
- duckduckgo
7373
- google
7474
timeout: 10
75+
todo_write:
76+
enabled: true
77+
require_approval: false
7578
safety:
7679
require_approval: true
7780
exclude_paths:

config/config.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ type ToolsConfig struct {
4444
Tree TreeToolConfig `yaml:"tree"`
4545
Fetch FetchToolConfig `yaml:"fetch"`
4646
WebSearch WebSearchToolConfig `yaml:"web_search"`
47+
TodoWrite TodoWriteToolConfig `yaml:"todo_write"`
4748
Safety SafetyConfig `yaml:"safety"`
4849
ExcludePaths []string `yaml:"exclude_paths"`
4950
}
@@ -109,6 +110,12 @@ type WebSearchToolConfig struct {
109110
RequireApproval *bool `yaml:"require_approval,omitempty"`
110111
}
111112

113+
// TodoWriteToolConfig contains TodoWrite-specific tool settings
114+
type TodoWriteToolConfig struct {
115+
Enabled bool `yaml:"enabled"`
116+
RequireApproval *bool `yaml:"require_approval,omitempty"`
117+
}
118+
112119
// ToolWhitelistConfig contains whitelisted commands and patterns
113120
type ToolWhitelistConfig struct {
114121
Commands []string `yaml:"commands"`
@@ -233,6 +240,10 @@ func DefaultConfig() *Config {
233240
Engines: []string{"duckduckgo", "google"},
234241
Timeout: 10,
235242
},
243+
TodoWrite: TodoWriteToolConfig{
244+
Enabled: true,
245+
RequireApproval: &[]bool{false}[0],
246+
},
236247
Safety: SafetyConfig{
237248
RequireApproval: true,
238249
},
@@ -370,6 +381,10 @@ func (c *Config) IsApprovalRequired(toolName string) bool {
370381
if c.Tools.WebSearch.RequireApproval != nil {
371382
return *c.Tools.WebSearch.RequireApproval
372383
}
384+
case "TodoWrite":
385+
if c.Tools.TodoWrite.RequireApproval != nil {
386+
return *c.Tools.TodoWrite.RequireApproval
387+
}
373388
}
374389

375390
return globalApproval

internal/app/chat_application.go

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import (
66
"strings"
77
"time"
88

9-
"github.com/charmbracelet/bubbletea"
9+
tea "github.com/charmbracelet/bubbletea"
1010
"github.com/charmbracelet/lipgloss"
1111
"github.com/inference-gateway/cli/internal/container"
1212
"github.com/inference-gateway/cli/internal/domain"
@@ -817,7 +817,11 @@ func (app *ChatApplication) handleApprovalKeys(keyMsg tea.KeyMsg) tea.Cmd {
817817
}
818818
},
819819
func() tea.Msg {
820-
return handlers.ProcessNextToolCallMsg{}
820+
if remainingCalls, ok := app.state.Data["remainingToolCalls"].([]sdk.ChatCompletionMessageToolCall); ok && len(remainingCalls) > 0 {
821+
return handlers.ProcessNextToolCallMsg{}
822+
} else {
823+
return handlers.TriggerFollowUpLLMCallMsg{}
824+
}
821825
},
822826
)()
823827
}
@@ -907,7 +911,11 @@ func (app *ChatApplication) approveToolCall() tea.Cmd {
907911
}
908912
},
909913
func() tea.Msg {
910-
return handlers.ProcessNextToolCallMsg{}
914+
if remainingCalls, ok := app.state.Data["remainingToolCalls"].([]sdk.ChatCompletionMessageToolCall); ok && len(remainingCalls) > 0 {
915+
return handlers.ProcessNextToolCallMsg{}
916+
} else {
917+
return handlers.TriggerFollowUpLLMCallMsg{}
918+
}
911919
},
912920
)()
913921
}
@@ -974,7 +982,11 @@ func (app *ChatApplication) denyToolCall() tea.Cmd {
974982
}
975983
},
976984
func() tea.Msg {
977-
return handlers.ProcessNextToolCallMsg{}
985+
if remainingCalls, ok := app.state.Data["remainingToolCalls"].([]sdk.ChatCompletionMessageToolCall); ok && len(remainingCalls) > 0 {
986+
return handlers.ProcessNextToolCallMsg{}
987+
} else {
988+
return handlers.TriggerFollowUpLLMCallMsg{}
989+
}
978990
},
979991
)()
980992
}

internal/domain/interfaces.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,3 +253,19 @@ type DeleteToolResult struct {
253253
WildcardExpanded bool `json:"wildcard_expanded"`
254254
Errors []string `json:"errors,omitempty"`
255255
}
256+
257+
// TodoItem represents a single todo item
258+
type TodoItem struct {
259+
ID string `json:"id"`
260+
Content string `json:"content"`
261+
Status string `json:"status"`
262+
}
263+
264+
// TodoWriteToolResult represents the result of a TodoWrite operation
265+
type TodoWriteToolResult struct {
266+
Todos []TodoItem `json:"todos"`
267+
TotalTasks int `json:"total_tasks"`
268+
CompletedTasks int `json:"completed_tasks"`
269+
InProgressTask string `json:"in_progress_task,omitempty"`
270+
ValidationOK bool `json:"validation_ok"`
271+
}

internal/handlers/chat_handler.go

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import (
1111
"strings"
1212
"time"
1313

14-
"github.com/charmbracelet/bubbletea"
14+
tea "github.com/charmbracelet/bubbletea"
1515
"github.com/inference-gateway/cli/config"
1616
"github.com/inference-gateway/cli/internal/commands"
1717
"github.com/inference-gateway/cli/internal/domain"
@@ -64,6 +64,8 @@ func (h *ChatMessageHandler) CanHandle(msg tea.Msg) bool {
6464
return true
6565
case ProcessNextToolCallMsg:
6666
return true
67+
case TriggerFollowUpLLMCallMsg:
68+
return true
6769
case domain.ChatStartEvent, domain.ChatChunkEvent, domain.ChatCompleteEvent, domain.ChatErrorEvent:
6870
return true
6971
default:
@@ -91,6 +93,9 @@ func (h *ChatMessageHandler) Handle(msg tea.Msg, state *AppState) (tea.Model, te
9193
case ProcessNextToolCallMsg:
9294
return h.handleProcessNextToolCall(msg, state)
9395

96+
case TriggerFollowUpLLMCallMsg:
97+
return h.handleTriggerFollowUpLLMCall(msg, state)
98+
9499
case domain.ChatStartEvent:
95100
return h.handleChatStart(msg, state)
96101

@@ -523,6 +528,9 @@ type StoreRemainingToolCallsMsg struct {
523528
// ProcessNextToolCallMsg triggers processing of the next tool call in the queue
524529
type ProcessNextToolCallMsg struct{}
525530

531+
// TriggerFollowUpLLMCallMsg triggers the follow-up LLM call after all tools are executed
532+
type TriggerFollowUpLLMCallMsg struct{}
533+
526534
// SwitchModelMsg indicates that model selection view should be shown
527535
type SwitchModelMsg struct{}
528536

@@ -820,7 +828,13 @@ func (h *ChatMessageHandler) handleProcessNextToolCall(msg ProcessNextToolCallMs
820828
remainingCalls, ok := state.Data["remainingToolCalls"].([]sdk.ChatCompletionMessageToolCall)
821829
if !ok || len(remainingCalls) == 0 {
822830
delete(state.Data, "remainingToolCalls")
823-
return nil, h.triggerFollowUpLLMCall()
831+
832+
return nil, func() tea.Msg {
833+
return shared.SetStatusMsg{
834+
Message: "All tool calls completed, preparing follow-up request...",
835+
Spinner: true,
836+
}
837+
}
824838
}
825839

826840
nextCall := remainingCalls[0]
@@ -855,10 +869,13 @@ func (h *ChatMessageHandler) handleProcessNextToolCall(msg ProcessNextToolCallMs
855869
// triggerFollowUpLLMCall sends the conversation with tool results back to the LLM for reasoning
856870
func (h *ChatMessageHandler) triggerFollowUpLLMCall() tea.Cmd {
857871
return func() tea.Msg {
858-
// Convert conversation to SDK messages
859872
messages := h.conversationToSDKMessages()
860873

861-
// Start a new chat completion request
862874
return h.startChatCompletion(messages)()
863875
}
864876
}
877+
878+
// handleTriggerFollowUpLLMCall handles the trigger for follow-up LLM call
879+
func (h *ChatMessageHandler) handleTriggerFollowUpLLMCall(msg TriggerFollowUpLLMCallMsg, state *AppState) (tea.Model, tea.Cmd) {
880+
return nil, h.triggerFollowUpLLMCall()
881+
}

internal/services/tools/registry.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ func (r *Registry) registerTools() {
3232
r.tools["Delete"] = NewDeleteTool(r.config)
3333
r.tools["Grep"] = NewGrepTool(r.config)
3434
r.tools["Tree"] = NewTreeTool(r.config)
35+
r.tools["TodoWrite"] = NewTodoWriteTool(r.config)
3536

3637
if r.config.Tools.Fetch.Enabled {
3738
r.tools["Fetch"] = NewFetchTool(r.config)

0 commit comments

Comments
 (0)