Skip to content

Commit c822fd8

Browse files
committed
Add subagents
1 parent 1066e84 commit c822fd8

12 files changed

Lines changed: 960 additions & 364 deletions

README.md

Lines changed: 8 additions & 8 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 03 of 12 — todo tracking with nag reminder injection
5+
> **Current progress:** Stage 04 of 12 — subagents with recursive loop and fresh context
66
77
![demo](demo.gif)
88

@@ -80,13 +80,13 @@ The minimum viable agent: a loop and a small set of good tools.
8080

8181
The features that make an agent feel like a usable product: context, memory management, and persistence.
8282

83-
| Stage | What It Adds | Tag |
84-
| ----- | ------------------------------------------------------------ | --- |
85-
| 04 | Subagents: recursive loop with fresh context | |
86-
| 05 | Skill loading: `.md` files injected as tool results ||
87-
| 06 | Context compaction: 3-layer strategy (micro, auto, manual) ||
88-
| 07 | Task system: file-based CRUD with dependency DAG ||
89-
| 08 | Background tasks: `Task {}` + actor-based notification queue ||
83+
| Stage | What It Adds | Tag |
84+
| ------ | ------------------------------------------------------------ | -------------- |
85+
| **04** | Subagents: recursive loop with fresh context | `04-subagents` |
86+
| 05 | Skill loading: `.md` files injected as tool results | |
87+
| 06 | Context compaction: 3-layer strategy (micro, auto, manual) | |
88+
| 07 | Task system: file-based CRUD with dependency DAG | |
89+
| 08 | Background tasks: `Task {}` + actor-based notification queue | |
9090

9191
### Phase 3 — Experimental
9292

Sources/Core/API/APIClient.swift

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ public protocol APIClientProtocol {
88
}
99

1010
public struct APIClient: APIClientProtocol, Sendable {
11+
private static let requestTimeout = TimeAmount.seconds(300)
12+
private static let maxResponseBodySize = 10 * 1024 * 1024
13+
1114
private let apiKey: String
1215
private let baseURL: String
1316
private let httpClient: HTTPClient
@@ -32,8 +35,8 @@ public struct APIClient: APIClientProtocol, Sendable {
3235
httpRequest.headers.add(name: "content-type", value: "application/json")
3336
httpRequest.body = .bytes(ByteBuffer(data: body))
3437

35-
let response = try await httpClient.execute(httpRequest, timeout: .seconds(300))
36-
let responseBody = try await response.body.collect(upTo: 10 * 1024 * 1024)
38+
let response = try await httpClient.execute(httpRequest, timeout: Self.requestTimeout)
39+
let responseBody = try await response.body.collect(upTo: Self.maxResponseBodySize)
3740
let data = Data(buffer: responseBody)
3841

3942
guard (200..<300).contains(Int(response.status.code)) else {

Sources/Core/API/APIModels.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,7 @@ public struct APIRequest: Codable, Sendable {
172172

173173
public init(
174174
model: String,
175-
maxTokens: Int = 4096,
175+
maxTokens: Int = Limits.defaultMaxTokens,
176176
system: String? = nil,
177177
messages: [Message],
178178
tools: [ToolDefinition]? = nil

Sources/Core/Agent.swift

Lines changed: 154 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
// swiftlint:disable file_length
22
import Foundation
33

4+
public enum Limits {
5+
public static let maxOutputSize = 50_000
6+
public static let defaultMaxTokens = 4096
7+
static let logPreviewLength = 200
8+
}
9+
410
public final class Agent {
5-
public static let version = "0.3.0"
11+
public static let version = "0.4.0"
612

713
private static let todoReminderThreshold = 3
814

@@ -33,60 +39,69 @@ public final class Agent {
3339

3440
public func run(query: String) async throws -> String {
3541
messages.append(.user(query))
42+
43+
let result = try await agentLoop(initialMessages: messages, config: .default)
44+
messages = result.messages
45+
46+
return result.text
47+
}
48+
49+
private func agentLoop(
50+
initialMessages: [Message],
51+
config: LoopConfig
52+
) async throws -> (text: String, messages: [Message]) {
53+
var messages = initialMessages
3654
var turnsWithoutTodo = 0
55+
var iteration = 0
56+
var lastAssistantText = ""
57+
58+
let allowedTools = Set(config.tools.map(\.name))
3759

3860
while true {
61+
try Task.checkCancellation()
62+
63+
iteration += 1
64+
if iteration > config.maxIterations {
65+
return (lastAssistantText + "\n(\(config.label) reached iteration limit)", messages)
66+
}
67+
3968
let request = APIRequest(
4069
model: model,
41-
maxTokens: 4096,
70+
maxTokens: Limits.defaultMaxTokens,
4271
system: systemPrompt,
4372
messages: messages,
44-
tools: Self.toolDefinitions
73+
tools: config.tools
4574
)
4675

4776
let response = try await apiClient.createMessage(request: request)
4877
messages.append(Message(role: .assistant, content: response.content))
78+
lastAssistantText = response.content.textContent
4979

5080
for block in response.content {
5181
if case .text(let text) = block {
52-
print("\(ANSIColor.cyan)\(text)\(ANSIColor.reset)")
82+
print("[\(config.label)] \(ANSIColor.cyan)\(text)\(ANSIColor.reset)")
5383
}
5484
}
5585

5686
guard response.stopReason == .toolUse else {
57-
return response.content.textContent
87+
return (response.content.textContent, messages)
5888
}
5989

60-
var results: [ContentBlock] = []
61-
var didUseTodo = false
90+
let (results, didUseTodo) = await processToolUses(
91+
response: response,
92+
allowedTools: allowedTools,
93+
label: config.label
94+
)
6295

63-
for block in response.content {
64-
if case .toolUse(let id, let name, let input) = block {
65-
printToolCall(name: name, input: input)
66-
let toolResult = await executeTool(name: name, input: input)
67-
68-
if name == "todo" {
69-
didUseTodo = true
70-
}
71-
72-
switch toolResult {
73-
case .success(let output):
74-
print("\(ANSIColor.dim)\(String(output.prefix(200)))\(ANSIColor.reset)")
75-
results.append(.toolResult(toolUseId: id, content: output, isError: false))
76-
case .failure(let error):
77-
let message = "\(error)"
78-
print("\(ANSIColor.red)\(message)\(ANSIColor.reset)")
79-
results.append(.toolResult(toolUseId: id, content: message, isError: true))
80-
}
96+
var toolResults = results
97+
if config.enableNag {
98+
turnsWithoutTodo = didUseTodo ? 0 : turnsWithoutTodo + 1
99+
if turnsWithoutTodo >= Self.todoReminderThreshold && todoManager.hasOpenItems() {
100+
toolResults.append(.text("Update your todos."))
81101
}
82102
}
83103

84-
turnsWithoutTodo = didUseTodo ? 0 : turnsWithoutTodo + 1
85-
if turnsWithoutTodo >= Self.todoReminderThreshold && todoManager.hasOpenItems() {
86-
results.append(.text("Update your todos."))
87-
}
88-
89-
messages.append(Message(role: .user, content: results))
104+
messages.append(Message(role: .user, content: toolResults))
90105
}
91106
}
92107

@@ -208,6 +223,20 @@ extension Agent {
208223
]),
209224
"required": .array(["items"])
210225
])
226+
),
227+
ToolDefinition(
228+
name: "agent",
229+
description: "Spawn a subagent to handle a complex subtask independently.",
230+
inputSchema: .object([
231+
"type": "object",
232+
"properties": .object([
233+
"prompt": .object([
234+
"type": "string",
235+
"description": "The task for the subagent to complete"
236+
])
237+
]),
238+
"required": .array(["prompt"])
239+
])
211240
)
212241
]
213242

@@ -217,7 +246,8 @@ extension Agent {
217246
"read_file": executeReadFile,
218247
"write_file": executeWriteFile,
219248
"edit_file": executeEditFile,
220-
"todo": executeTodo
249+
"todo": executeTodo,
250+
"agent": executeAgent
221251
]
222252

223253
guard let handler = handlers[name] else {
@@ -265,8 +295,8 @@ extension Agent {
265295
output = text
266296
}
267297

268-
if output.count > 50_000 {
269-
output = String(output.prefix(50_000))
298+
if output.count > Limits.maxOutputSize {
299+
output = String(output.prefix(Limits.maxOutputSize))
270300
}
271301

272302
return .success(output)
@@ -339,6 +369,30 @@ extension Agent {
339369
}
340370
}
341371

372+
private func executeAgent(_ input: JSONValue) async -> Result<String, ToolError> {
373+
guard let prompt = input["prompt"]?.stringValue else {
374+
return .failure(.missingParameter("prompt"))
375+
}
376+
377+
do {
378+
let result = try await agentLoop(
379+
initialMessages: [Message.user(prompt)],
380+
config: .subagent
381+
)
382+
var output = result.text
383+
384+
if output.isEmpty {
385+
output = "(no output)"
386+
} else if output.count > Limits.maxOutputSize {
387+
output = String(output.prefix(Limits.maxOutputSize))
388+
}
389+
390+
return .success(output)
391+
} catch {
392+
return .failure(.executionFailed("Subagent failed: \(error)"))
393+
}
394+
}
395+
342396
private func executeTodo(_ input: JSONValue) async -> Result<String, ToolError> {
343397
guard let itemsArray = input["items"]?.arrayValue else {
344398
return .failure(.missingParameter("items"))
@@ -371,6 +425,43 @@ extension Agent {
371425

372426
// MARK: Helpers
373427

428+
private func processToolUses(
429+
response: APIResponse,
430+
allowedTools: Set<String>,
431+
label: String
432+
) async -> (results: [ContentBlock], didUseTodo: Bool) {
433+
var results: [ContentBlock] = []
434+
var didUseTodo = false
435+
436+
for case .toolUse(let id, let name, let input) in response.content {
437+
guard allowedTools.contains(name) else {
438+
let message = "Tool '\(name)' is not allowed in this context"
439+
print("[\(label)] \(ANSIColor.red)\(message)\(ANSIColor.reset)")
440+
results.append(.toolResult(toolUseId: id, content: message, isError: true))
441+
continue
442+
}
443+
444+
printToolCall(name: name, input: input, label: label)
445+
let toolResult = await executeTool(name: name, input: input)
446+
447+
if name == "todo" {
448+
didUseTodo = true
449+
}
450+
451+
switch toolResult {
452+
case .success(let output):
453+
print("[\(label)] \(ANSIColor.dim)\(String(output.prefix(Limits.logPreviewLength)))\(ANSIColor.reset)")
454+
results.append(.toolResult(toolUseId: id, content: output, isError: false))
455+
case .failure(let error):
456+
let message = "\(error)"
457+
print("[\(label)] \(ANSIColor.red)\(message)\(ANSIColor.reset)")
458+
results.append(.toolResult(toolUseId: id, content: message, isError: true))
459+
}
460+
}
461+
462+
return (results, didUseTodo)
463+
}
464+
374465
private func resolveSafePath(_ relativePath: String) -> Result<String, ToolError> {
375466
let workDirURL = URL(fileURLWithPath: workingDirectory, isDirectory: true)
376467
let resolvedWorkDir = workDirURL.standardized
@@ -391,13 +482,38 @@ extension Agent {
391482
return .success(fullURL.path)
392483
}
393484

394-
private func printToolCall(name: String, input: JSONValue) {
485+
private func printToolCall(name: String, input: JSONValue, label: String) {
395486
if name == "bash", let command = input["command"]?.stringValue {
396-
print("\(ANSIColor.yellow)$ \(command)\(ANSIColor.reset)")
487+
print("[\(label)] \(ANSIColor.yellow)$ \(command)\(ANSIColor.reset)")
397488
} else if let path = input["path"]?.stringValue {
398-
print("\(ANSIColor.yellow)> \(name): \(path)\(ANSIColor.reset)")
489+
print("[\(label)] \(ANSIColor.yellow)> \(name): \(path)\(ANSIColor.reset)")
399490
} else {
400-
print("\(ANSIColor.yellow)> \(name)\(ANSIColor.reset)")
491+
print("[\(label)] \(ANSIColor.yellow)> \(name)\(ANSIColor.reset)")
401492
}
402493
}
403494
}
495+
496+
// MARK: - Configuration
497+
498+
extension Agent {
499+
fileprivate struct LoopConfig {
500+
let tools: [ToolDefinition]
501+
let maxIterations: Int
502+
let enableNag: Bool
503+
let label: String
504+
505+
static let `default` = LoopConfig(
506+
tools: Agent.toolDefinitions,
507+
maxIterations: .max,
508+
enableNag: true,
509+
label: "agent"
510+
)
511+
512+
static let subagent = LoopConfig(
513+
tools: Agent.toolDefinitions.filter { $0.name != "agent" && $0.name != "todo" },
514+
maxIterations: 30,
515+
enableNag: false,
516+
label: "subagent"
517+
)
518+
}
519+
}

Sources/Core/ShellExecutor.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@ public struct ShellResult: Sendable {
1616
output += "\n[exit code: \(exitCode)]"
1717
}
1818

19-
if output.count > 50_000 {
20-
output = String(output.prefix(50_000))
19+
if output.count > Limits.maxOutputSize {
20+
output = String(output.prefix(Limits.maxOutputSize))
2121
}
2222

2323
return output.isEmpty ? "(no output)" : output

Tests/CoreTests/AgentFileToolTests.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ struct ReadFileToolTests {
7474
)
7575
let output = try result.get()
7676

77-
#expect(output.count == 50_000)
77+
#expect(output.count == Limits.maxOutputSize)
7878
}
7979

8080
@Test func fileNotFound() async throws {

0 commit comments

Comments
 (0)