Skip to content

Commit d8ff96f

Browse files
committed
Add todo tool with nag reminder injection
1 parent d6ce92f commit d8ff96f

7 files changed

Lines changed: 412 additions & 4 deletions

File tree

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
Exploring the architecture of coding agents by rebuilding a Claude Code-style CLI from scratch in Swift.
44

5-
> **Current progress:** Stage 02 of 12 — tool dispatch with `read_file`, `write_file`, `edit_file`
5+
> **Current progress:** Stage 03 of 12 — todo tracking with nag reminder injection
66
77
![demo](demo.gif)
88

@@ -74,7 +74,7 @@ The minimum viable agent: a loop and a small set of good tools.
7474
| **00** | Bootstrap: SPM project, two-target layout, CI | `00-bootstrap` |
7575
| **01** | Agent loop + bash tool | `01-agent-loop` |
7676
| **02** | Tool dispatch: `read_file`, `write_file`, `edit_file` with path safety | `02-tool-dispatch` |
77-
| 03 | Todo tracking with nag reminder injection | |
77+
| **03** | Todo tracking with nag reminder injection | `03-todo-write` |
7878

7979
### Phase 2 — Product Mechanics
8080

Sources/Core/API/JSONValue.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,13 @@ extension JSONValue {
6969
return nil
7070
}
7171

72+
public var arrayValue: [JSONValue]? {
73+
if case .array(let value) = self {
74+
return value
75+
}
76+
return nil
77+
}
78+
7279
public var intValue: Int? {
7380
if case .int(let value) = self {
7481
return value

Sources/Core/Agent.swift

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
11
import Foundation
22

33
public final class Agent {
4-
public static let version = "0.2.0"
4+
public static let version = "0.3.0"
5+
6+
private static let todoReminderThreshold = 3
57

68
private let apiClient: any APIClientProtocol
79
private let model: String
810
private let systemPrompt: String
911
private let workingDirectory: String
12+
1013
private let shellExecutor: ShellExecutor
14+
private let todoManager = TodoManager()
15+
1116
private var messages: [Message] = []
1217

1318
public init(
@@ -27,6 +32,7 @@ public final class Agent {
2732

2833
public func run(query: String) async throws -> String {
2934
messages.append(.user(query))
35+
var turnsWithoutTodo = 0
3036

3137
while true {
3238
let request = APIRequest(
@@ -51,11 +57,17 @@ public final class Agent {
5157
}
5258

5359
var results: [ContentBlock] = []
60+
var didUseTodo = false
61+
5462
for block in response.content {
5563
if case .toolUse(let id, let name, let input) = block {
5664
printToolCall(name: name, input: input)
5765
let toolResult = await executeTool(name: name, input: input)
5866

67+
if name == "todo" {
68+
didUseTodo = true
69+
}
70+
5971
switch toolResult {
6072
case .success(let output):
6173
print("\(ANSIColor.dim)\(String(output.prefix(200)))\(ANSIColor.reset)")
@@ -68,6 +80,11 @@ public final class Agent {
6880
}
6981
}
7082

83+
turnsWithoutTodo = didUseTodo ? 0 : turnsWithoutTodo + 1
84+
if turnsWithoutTodo >= Self.todoReminderThreshold && todoManager.hasOpenItems() {
85+
results.append(.text("Update your todos."))
86+
}
87+
7188
messages.append(Message(role: .user, content: results))
7289
}
7390
}
@@ -79,6 +96,7 @@ public final class Agent {
7996
8097
- Prefer read_file/write_file/edit_file over bash for file operations
8198
- Always check tool results before proceeding
99+
- Use the todo tool to plan multi-step tasks. Mark in_progress before starting, completed when done.
82100
"""
83101
}
84102
}
@@ -164,6 +182,31 @@ extension Agent {
164182
]),
165183
"required": .array(["path", "old_text", "new_text"])
166184
])
185+
),
186+
ToolDefinition(
187+
name: "todo",
188+
description: "Update task list. Track progress on multi-step tasks.",
189+
inputSchema: .object([
190+
"type": "object",
191+
"properties": .object([
192+
"items": .object([
193+
"type": "array",
194+
"items": .object([
195+
"type": "object",
196+
"properties": .object([
197+
"id": .object(["type": "string"]),
198+
"text": .object(["type": "string"]),
199+
"status": .object([
200+
"type": "string",
201+
"enum": .array(["pending", "in_progress", "completed"])
202+
])
203+
]),
204+
"required": .array(["id", "text", "status"])
205+
])
206+
])
207+
]),
208+
"required": .array(["items"])
209+
])
167210
)
168211
]
169212

@@ -172,7 +215,8 @@ extension Agent {
172215
"bash": executeBash,
173216
"read_file": executeReadFile,
174217
"write_file": executeWriteFile,
175-
"edit_file": executeEditFile
218+
"edit_file": executeEditFile,
219+
"todo": executeTodo
176220
]
177221

178222
guard let handler = handlers[name] else {
@@ -294,6 +338,36 @@ extension Agent {
294338
}
295339
}
296340

341+
private func executeTodo(_ input: JSONValue) async -> Result<String, ToolError> {
342+
guard let itemsArray = input["items"]?.arrayValue else {
343+
return .failure(.missingParameter("items"))
344+
}
345+
346+
var todoItems: [TodoItem] = []
347+
for element in itemsArray {
348+
guard let id = element["id"]?.stringValue else {
349+
return .failure(.missingParameter("items[].id"))
350+
}
351+
guard let text = element["text"]?.stringValue else {
352+
return .failure(.missingParameter("items[].text"))
353+
}
354+
guard let statusString = element["status"]?.stringValue else {
355+
return .failure(.missingParameter("items[].status"))
356+
}
357+
guard let status = TodoStatus(rawValue: statusString) else {
358+
return .failure(.executionFailed("Invalid status '\(statusString)' for item \(id)"))
359+
}
360+
todoItems.append(TodoItem(id: id, text: text, status: status))
361+
}
362+
363+
do {
364+
try todoManager.update(items: todoItems)
365+
return .success(todoManager.render())
366+
} catch {
367+
return .failure(.executionFailed("\(error)"))
368+
}
369+
}
370+
297371
// MARK: Helpers
298372

299373
private func resolveSafePath(_ relativePath: String) -> Result<String, ToolError> {

Sources/Core/TodoManager.swift

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import Foundation
2+
3+
public enum TodoStatus: String, Sendable, Equatable, Codable {
4+
case pending
5+
case inProgress = "in_progress"
6+
case completed
7+
8+
public var marker: String {
9+
switch self {
10+
case .pending: "[ ]"
11+
case .inProgress: "[>]"
12+
case .completed: "[x]"
13+
}
14+
}
15+
}
16+
17+
public struct TodoItem: Sendable, Equatable, Codable {
18+
public let id: String
19+
public let text: String
20+
public let status: TodoStatus
21+
22+
public init(id: String, text: String, status: TodoStatus) {
23+
self.id = id
24+
self.text = text
25+
self.status = status
26+
}
27+
}
28+
29+
public final class TodoManager {
30+
public static let maxItems = 20
31+
public private(set) var items: [TodoItem] = []
32+
33+
public enum ValidationError: Error, Equatable, Sendable {
34+
case tooManyItems
35+
case emptyText(String)
36+
case multipleInProgress
37+
}
38+
39+
public init() {}
40+
41+
public func update(items: [TodoItem]) throws {
42+
if items.count > Self.maxItems {
43+
throw ValidationError.tooManyItems
44+
}
45+
46+
for item in items where item.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
47+
throw ValidationError.emptyText(item.id)
48+
}
49+
50+
let inProgressCount = items.filter { $0.status == .inProgress }.count
51+
if inProgressCount > 1 {
52+
throw ValidationError.multipleInProgress
53+
}
54+
55+
self.items = items
56+
}
57+
58+
public func render() -> String {
59+
if items.isEmpty {
60+
return "No todos."
61+
}
62+
63+
let completedCount = items.filter { $0.status == .completed }.count
64+
var lines = items.map { "\($0.status.marker) \($0.text)" }
65+
lines.append("(\(completedCount)/\(items.count) completed)")
66+
67+
return lines.joined(separator: "\n")
68+
}
69+
70+
public func hasOpenItems() -> Bool {
71+
items.contains { $0.status != .completed }
72+
}
73+
}

0 commit comments

Comments
 (0)