Skip to content

Commit f0a20bf

Browse files
committed
todo: extract storage interface with in-memory implementation
Introduce TodoStorage interface to decouple the todo toolset from its storage layer. Add MemoryTodoStorage as the default in-memory implementation and a WithStorage functional option so callers can provide their own. Assisted-By: cagent
1 parent b5748eb commit f0a20bf

File tree

2 files changed

+120
-39
lines changed

2 files changed

+120
-39
lines changed

pkg/tools/builtin/todo.go

Lines changed: 89 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -50,18 +50,84 @@ type UpdateTodosArgs struct {
5050
Updates []TodoUpdate `json:"updates" jsonschema:"List of todo updates"`
5151
}
5252

53-
type todoHandler struct {
53+
// TodoStorage defines the storage layer for todo items.
54+
type TodoStorage interface {
55+
// Add appends a new todo item.
56+
Add(todo Todo)
57+
// All returns a copy of all todo items.
58+
All() []Todo
59+
// Len returns the number of todo items.
60+
Len() int
61+
// FindByID returns the index of the todo with the given ID, or -1 if not found.
62+
FindByID(id string) int
63+
// Update modifies the todo at the given index using the provided function.
64+
Update(index int, fn func(Todo) Todo)
65+
// Clear removes all todo items.
66+
Clear()
67+
}
68+
69+
// MemoryTodoStorage is an in-memory, concurrency-safe implementation of TodoStorage.
70+
type MemoryTodoStorage struct {
5471
todos *concurrent.Slice[Todo]
5572
}
5673

57-
var NewSharedTodoTool = sync.OnceValue(NewTodoTool)
74+
func NewMemoryTodoStorage() *MemoryTodoStorage {
75+
return &MemoryTodoStorage{
76+
todos: concurrent.NewSlice[Todo](),
77+
}
78+
}
79+
80+
func (s *MemoryTodoStorage) Add(todo Todo) {
81+
s.todos.Append(todo)
82+
}
83+
84+
func (s *MemoryTodoStorage) All() []Todo {
85+
return s.todos.All()
86+
}
87+
88+
func (s *MemoryTodoStorage) Len() int {
89+
return s.todos.Length()
90+
}
91+
92+
func (s *MemoryTodoStorage) FindByID(id string) int {
93+
_, idx := s.todos.Find(func(t Todo) bool { return t.ID == id })
94+
return idx
95+
}
96+
97+
func (s *MemoryTodoStorage) Update(index int, fn func(Todo) Todo) {
98+
s.todos.Update(index, fn)
99+
}
100+
101+
func (s *MemoryTodoStorage) Clear() {
102+
s.todos.Clear()
103+
}
104+
105+
// TodoOption is a functional option for configuring a TodoTool.
106+
type TodoOption func(*TodoTool)
107+
108+
// WithStorage sets a custom storage implementation for the TodoTool.
109+
func WithStorage(storage TodoStorage) TodoOption {
110+
return func(t *TodoTool) {
111+
t.handler.storage = storage
112+
}
113+
}
58114

59-
func NewTodoTool() *TodoTool {
60-
return &TodoTool{
115+
type todoHandler struct {
116+
storage TodoStorage
117+
}
118+
119+
var NewSharedTodoTool = sync.OnceValue(func() *TodoTool { return NewTodoTool() })
120+
121+
func NewTodoTool(opts ...TodoOption) *TodoTool {
122+
t := &TodoTool{
61123
handler: &todoHandler{
62-
todos: concurrent.NewSlice[Todo](),
124+
storage: NewMemoryTodoStorage(),
63125
},
64126
}
127+
for _, opt := range opts {
128+
opt(t)
129+
}
130+
return t
65131
}
66132

67133
func (t *TodoTool) Instructions() string {
@@ -86,25 +152,25 @@ This toolset is REQUIRED for maintaining task state and ensuring all steps are c
86152
}
87153

88154
func (h *todoHandler) createTodo(_ context.Context, params CreateTodoArgs) (*tools.ToolCallResult, error) {
89-
id := fmt.Sprintf("todo_%d", h.todos.Length()+1)
155+
id := fmt.Sprintf("todo_%d", h.storage.Len()+1)
90156
todo := Todo{
91157
ID: id,
92158
Description: params.Description,
93159
Status: "pending",
94160
}
95-
h.todos.Append(todo)
161+
h.storage.Add(todo)
96162

97163
return &tools.ToolCallResult{
98164
Output: fmt.Sprintf("Created todo [%s]: %s", id, params.Description),
99-
Meta: h.todos.All(),
165+
Meta: h.storage.All(),
100166
}, nil
101167
}
102168

103169
func (h *todoHandler) createTodos(_ context.Context, params CreateTodosArgs) (*tools.ToolCallResult, error) {
104-
start := h.todos.Length()
170+
start := h.storage.Len()
105171
for i, desc := range params.Descriptions {
106172
id := fmt.Sprintf("todo_%d", start+i+1)
107-
h.todos.Append(Todo{
173+
h.storage.Add(Todo{
108174
ID: id,
109175
Description: desc,
110176
Status: "pending",
@@ -122,7 +188,7 @@ func (h *todoHandler) createTodos(_ context.Context, params CreateTodosArgs) (*t
122188

123189
return &tools.ToolCallResult{
124190
Output: output.String(),
125-
Meta: h.todos.All(),
191+
Meta: h.storage.All(),
126192
}, nil
127193
}
128194

@@ -131,13 +197,13 @@ func (h *todoHandler) updateTodos(_ context.Context, params UpdateTodosArgs) (*t
131197
var updated []string
132198

133199
for _, update := range params.Updates {
134-
_, idx := h.todos.Find(func(t Todo) bool { return t.ID == update.ID })
200+
idx := h.storage.FindByID(update.ID)
135201
if idx == -1 {
136202
notFound = append(notFound, update.ID)
137203
continue
138204
}
139205

140-
h.todos.Update(idx, func(t Todo) Todo {
206+
h.storage.Update(idx, func(t Todo) Todo {
141207
t.Status = update.Status
142208
return t
143209
})
@@ -159,44 +225,40 @@ func (h *todoHandler) updateTodos(_ context.Context, params UpdateTodosArgs) (*t
159225
return tools.ResultError(output.String()), nil
160226
}
161227

162-
// Clear all todos if all are completed
163228
if h.allCompleted() {
164-
h.todos.Clear()
229+
h.storage.Clear()
165230
}
166231

167232
return &tools.ToolCallResult{
168233
Output: output.String(),
169-
Meta: h.todos.All(),
234+
Meta: h.storage.All(),
170235
}, nil
171236
}
172237

173238
func (h *todoHandler) allCompleted() bool {
174-
if h.todos.Length() == 0 {
239+
all := h.storage.All()
240+
if len(all) == 0 {
175241
return false
176242
}
177-
allDone := true
178-
h.todos.Range(func(_ int, todo Todo) bool {
243+
for _, todo := range all {
179244
if todo.Status != "completed" {
180-
allDone = false
181245
return false
182246
}
183-
return true
184-
})
185-
return allDone
247+
}
248+
return true
186249
}
187250

188251
func (h *todoHandler) listTodos(_ context.Context, _ tools.ToolCall) (*tools.ToolCallResult, error) {
189252
var output strings.Builder
190253
output.WriteString("Current todos:\n")
191254

192-
h.todos.Range(func(_ int, todo Todo) bool {
255+
for _, todo := range h.storage.All() {
193256
fmt.Fprintf(&output, "- [%s] %s (Status: %s)\n", todo.ID, todo.Description, todo.Status)
194-
return true
195-
})
257+
}
196258

197259
return &tools.ToolCallResult{
198260
Output: output.String(),
199-
Meta: h.todos.All(),
261+
Meta: h.storage.All(),
200262
}, nil
201263
}
202264

pkg/tools/builtin/todo_test.go

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@ func TestTodoTool_DisplayNames(t *testing.T) {
2222
}
2323

2424
func TestTodoTool_CreateTodo(t *testing.T) {
25-
tool := NewTodoTool()
25+
storage := NewMemoryTodoStorage()
26+
tool := NewTodoTool(WithStorage(storage))
2627

2728
result, err := tool.handler.createTodo(t.Context(), CreateTodoArgs{
2829
Description: "Test todo item",
@@ -31,8 +32,8 @@ func TestTodoTool_CreateTodo(t *testing.T) {
3132
require.NoError(t, err)
3233
assert.Contains(t, result.Output, "Created todo [todo_1]: Test todo item")
3334

34-
assert.Equal(t, 1, tool.handler.todos.Length())
35-
todos := tool.handler.todos.All()
35+
assert.Equal(t, 1, storage.Len())
36+
todos := storage.All()
3637
require.Len(t, todos, 1)
3738
assert.Equal(t, "todo_1", todos[0].ID)
3839
assert.Equal(t, "Test todo item", todos[0].Description)
@@ -48,7 +49,8 @@ func TestTodoTool_CreateTodo(t *testing.T) {
4849
}
4950

5051
func TestTodoTool_CreateTodos(t *testing.T) {
51-
tool := NewTodoTool()
52+
storage := NewMemoryTodoStorage()
53+
tool := NewTodoTool(WithStorage(storage))
5254

5355
result, err := tool.handler.createTodos(t.Context(), CreateTodosArgs{
5456
Descriptions: []string{
@@ -64,7 +66,7 @@ func TestTodoTool_CreateTodos(t *testing.T) {
6466
assert.Contains(t, result.Output, "todo_2")
6567
assert.Contains(t, result.Output, "todo_3")
6668

67-
assert.Equal(t, 3, tool.handler.todos.Length())
69+
assert.Equal(t, 3, storage.Len())
6870

6971
// Verify Meta contains all todos (order not guaranteed from map)
7072
metaTodos, ok := result.Meta.([]Todo)
@@ -85,7 +87,7 @@ func TestTodoTool_CreateTodos(t *testing.T) {
8587
require.NoError(t, err)
8688
assert.Contains(t, result.Output, "Created 1 todos:")
8789
assert.Contains(t, result.Output, "todo_4")
88-
assert.Equal(t, 4, tool.handler.todos.Length())
90+
assert.Equal(t, 4, storage.Len())
8991

9092
// Verify Meta for second call contains all 4 todos
9193
metaTodos, ok = result.Meta.([]Todo)
@@ -126,7 +128,8 @@ func TestTodoTool_ListTodos(t *testing.T) {
126128
}
127129

128130
func TestTodoTool_UpdateTodos(t *testing.T) {
129-
tool := NewTodoTool()
131+
storage := NewMemoryTodoStorage()
132+
tool := NewTodoTool(WithStorage(storage))
130133

131134
// Create multiple todos first
132135
_, err := tool.handler.createTodos(t.Context(), CreateTodosArgs{
@@ -152,7 +155,7 @@ func TestTodoTool_UpdateTodos(t *testing.T) {
152155
assert.Contains(t, result.Output, "todo_3 -> in-progress")
153156

154157
// Verify the todos were updated
155-
todos := tool.handler.todos.All()
158+
todos := storage.All()
156159
require.Len(t, todos, 3)
157160
assert.Equal(t, "completed", todos[0].Status)
158161
assert.Equal(t, "pending", todos[1].Status)
@@ -165,7 +168,8 @@ func TestTodoTool_UpdateTodos(t *testing.T) {
165168
}
166169

167170
func TestTodoTool_UpdateTodos_PartialFailure(t *testing.T) {
168-
tool := NewTodoTool()
171+
storage := NewMemoryTodoStorage()
172+
tool := NewTodoTool(WithStorage(storage))
169173

170174
// Create two todos so we can complete one without clearing the list
171175
_, err := tool.handler.createTodos(t.Context(), CreateTodosArgs{
@@ -186,7 +190,7 @@ func TestTodoTool_UpdateTodos_PartialFailure(t *testing.T) {
186190
assert.Contains(t, result.Output, "Not found: nonexistent")
187191

188192
// Verify the existing todo was updated (list not cleared because todo_2 still pending)
189-
todos := tool.handler.todos.All()
193+
todos := storage.All()
190194
require.Len(t, todos, 2)
191195
assert.Equal(t, "completed", todos[0].Status)
192196
assert.Equal(t, "pending", todos[1].Status)
@@ -208,7 +212,8 @@ func TestTodoTool_UpdateTodos_AllNotFound(t *testing.T) {
208212
}
209213

210214
func TestTodoTool_UpdateTodos_ClearsWhenAllCompleted(t *testing.T) {
211-
tool := NewTodoTool()
215+
storage := NewMemoryTodoStorage()
216+
tool := NewTodoTool(WithStorage(storage))
212217

213218
// Create multiple todos
214219
_, err := tool.handler.createTodos(t.Context(), CreateTodosArgs{
@@ -227,7 +232,7 @@ func TestTodoTool_UpdateTodos_ClearsWhenAllCompleted(t *testing.T) {
227232
assert.Contains(t, result.Output, "Updated 2 todos")
228233

229234
// Verify all todos were cleared
230-
todos := tool.handler.todos.All()
235+
todos := storage.All()
231236
assert.Empty(t, todos)
232237

233238
// Verify Meta is also empty
@@ -236,6 +241,20 @@ func TestTodoTool_UpdateTodos_ClearsWhenAllCompleted(t *testing.T) {
236241
assert.Empty(t, metaTodos)
237242
}
238243

244+
func TestTodoTool_WithStorage(t *testing.T) {
245+
storage := NewMemoryTodoStorage()
246+
tool := NewTodoTool(WithStorage(storage))
247+
248+
_, err := tool.handler.createTodo(t.Context(), CreateTodoArgs{
249+
Description: "Test item",
250+
})
251+
require.NoError(t, err)
252+
253+
// Verify the custom storage received the item
254+
assert.Equal(t, 1, storage.Len())
255+
assert.Equal(t, "Test item", storage.All()[0].Description)
256+
}
257+
239258
func TestTodoTool_OutputSchema(t *testing.T) {
240259
tool := NewTodoTool()
241260

0 commit comments

Comments
 (0)