Skip to content

Commit 7e6d3f5

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

7 files changed

Lines changed: 412 additions & 6 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 & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
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
11-
private var messages: [Message] = []
14+
private let todoManager = TodoManager()
1215

1316
public init(
1417
apiClient: APIClientProtocol,
@@ -26,7 +29,8 @@ public final class Agent {
2629
// MARK: - Agent loop
2730

2831
public func run(query: String) async throws -> String {
29-
messages.append(.user(query))
32+
var messages: [Message] = [.user(query)]
33+
var turnsWithoutTodo = 0
3034

3135
while true {
3236
let request = APIRequest(
@@ -51,11 +55,17 @@ public final class Agent {
5155
}
5256

5357
var results: [ContentBlock] = []
58+
var didUseTodo = false
59+
5460
for block in response.content {
5561
if case .toolUse(let id, let name, let input) = block {
5662
printToolCall(name: name, input: input)
5763
let toolResult = await executeTool(name: name, input: input)
5864

65+
if name == "todo" {
66+
didUseTodo = true
67+
}
68+
5969
switch toolResult {
6070
case .success(let output):
6171
print("\(ANSIColor.dim)\(String(output.prefix(200)))\(ANSIColor.reset)")
@@ -68,6 +78,11 @@ public final class Agent {
6878
}
6979
}
7080

81+
turnsWithoutTodo = didUseTodo ? 0 : turnsWithoutTodo + 1
82+
if turnsWithoutTodo >= Self.todoReminderThreshold && todoManager.hasOpenItems() {
83+
results.append(.text("Update your todos."))
84+
}
85+
7186
messages.append(Message(role: .user, content: results))
7287
}
7388
}
@@ -79,6 +94,7 @@ public final class Agent {
7994
8095
- Prefer read_file/write_file/edit_file over bash for file operations
8196
- Always check tool results before proceeding
97+
- Use the todo tool to plan multi-step tasks. Mark in_progress before starting, completed when done.
8298
"""
8399
}
84100
}
@@ -164,6 +180,31 @@ extension Agent {
164180
]),
165181
"required": .array(["path", "old_text", "new_text"])
166182
])
183+
),
184+
ToolDefinition(
185+
name: "todo",
186+
description: "Update task list. Track progress on multi-step tasks.",
187+
inputSchema: .object([
188+
"type": "object",
189+
"properties": .object([
190+
"items": .object([
191+
"type": "array",
192+
"items": .object([
193+
"type": "object",
194+
"properties": .object([
195+
"id": .object(["type": "string"]),
196+
"text": .object(["type": "string"]),
197+
"status": .object([
198+
"type": "string",
199+
"enum": .array(["pending", "in_progress", "completed"])
200+
])
201+
]),
202+
"required": .array(["id", "text", "status"])
203+
])
204+
])
205+
]),
206+
"required": .array(["items"])
207+
])
167208
)
168209
]
169210

@@ -172,7 +213,8 @@ extension Agent {
172213
"bash": executeBash,
173214
"read_file": executeReadFile,
174215
"write_file": executeWriteFile,
175-
"edit_file": executeEditFile
216+
"edit_file": executeEditFile,
217+
"todo": executeTodo
176218
]
177219

178220
guard let handler = handlers[name] else {
@@ -294,6 +336,36 @@ extension Agent {
294336
}
295337
}
296338

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

299371
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)