Skip to content

Commit 86cedf7

Browse files
committed
Add background task system with actor-based notification queue
1 parent 0c4abeb commit 86cedf7

12 files changed

Lines changed: 771 additions & 54 deletions

README.md

Lines changed: 13 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,6 @@
22

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

5-
> **Current progress:** Stage 07 of 08 — persistent task system with file-based dependency DAG
6-
75
![demo](demo.gif)
86

97
## Why This Exists
@@ -69,24 +67,24 @@ Progress is tracked via git tags. The roadmap is split into two phases — core
6967

7068
The minimum viable agent: a loop and a small set of good tools.
7169

72-
| Stage | What It Adds | Tag |
73-
| ------ | ---------------------------------------------------------------------- | ------------------ |
74-
| **00** | Bootstrap: SPM project, two-target layout, CI | `00-bootstrap` |
75-
| **01** | Agent loop + bash tool | `01-agent-loop` |
76-
| **02** | Tool dispatch: `read_file`, `write_file`, `edit_file` with path safety | `02-tool-dispatch` |
77-
| **03** | Todo tracking with nag reminder injection | `03-todo-write` |
70+
| Stage | What It Adds | Tag |
71+
| ----- | ---------------------------------------------------------------------- | ------------------ |
72+
| 00 | Bootstrap: SPM project, two-target layout, CI | `00-bootstrap` |
73+
| 01 | Agent loop + bash tool | `01-agent-loop` |
74+
| 02 | Tool dispatch: `read_file`, `write_file`, `edit_file` with path safety | `02-tool-dispatch` |
75+
| 03 | Todo tracking with nag reminder injection | `03-todo-write` |
7876

7977
### Phase 2 — Product Mechanics
8078

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

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

9189
## Architecture
9290

Sources/Core/Agent.swift

Lines changed: 111 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,20 @@
22
import Foundation
33

44
public enum Limits {
5+
// Output/API
56
public static let maxOutputSize = 50_000
6-
public static let defaultTokenThreshold = 50_000
77
public static let defaultMaxTokens = 4096
88
static let logPreviewLength = 200
9+
// Compaction
10+
public static let defaultTokenThreshold = 50_000
11+
// Background
12+
public static let backgroundTimeout: TimeInterval = 300
13+
public static let backgroundCommandPreview = 80
14+
public static let backgroundResultPreview = 500
915
}
1016

1117
public final class Agent {
12-
public static let version = "0.7.0"
18+
public static let version = "0.8.0"
1319

1420
private static let todoReminderThreshold = 3
1521

@@ -23,6 +29,7 @@ public final class Agent {
2329
private let contextCompactor: ContextCompactor
2430
private let todoManager: TodoManager
2531
private let taskManager: TaskManager
32+
let backgroundManager: BackgroundManager
2633

2734
private var messages: [Message] = []
2835

@@ -45,6 +52,7 @@ public final class Agent {
4552
)
4653
self.todoManager = TodoManager()
4754
self.taskManager = TaskManager(directory: "\(workingDirectory)/.tasks")
55+
self.backgroundManager = BackgroundManager(executor: self.shellExecutor)
4856
self.systemPrompt =
4957
systemPrompt
5058
?? Self.buildSystemPrompt(
@@ -71,19 +79,21 @@ public final class Agent {
7179
var messages = initialMessages
7280
var turnsWithoutTodo = 0
7381
var iteration = 0
74-
var lastAssistantText = ""
75-
7682
let allowedTools = Set(config.tools.map(\.name))
7783

7884
while true {
7985
try Task.checkCancellation()
8086

8187
iteration += 1
8288
if iteration > config.maxIterations {
83-
return (lastAssistantText + "\n(\(config.label) reached iteration limit)", messages)
89+
let lastText = messages.last?.content.textContent ?? ""
90+
return (lastText + "\n(\(config.label) reached iteration limit)", messages)
8491
}
8592

8693
messages = await applyCompaction(messages)
94+
if config.drainBackground {
95+
messages = await drainBackgroundNotifications(messages)
96+
}
8797

8898
let request = APIRequest(
8999
model: model,
@@ -95,12 +105,9 @@ public final class Agent {
95105

96106
let response = try await apiClient.createMessage(request: request)
97107
messages.append(Message(role: .assistant, content: response.content))
98-
lastAssistantText = response.content.textContent
99108

100-
for block in response.content {
101-
if case .text(let text) = block {
102-
print("[\(config.label)] \(ANSIColor.cyan)\(text)\(ANSIColor.reset)")
103-
}
109+
for case .text(let text) in response.content {
110+
print("[\(config.label)] \(ANSIColor.cyan)\(text)\(ANSIColor.reset)")
104111
}
105112

106113
guard response.stopReason == .toolUse else {
@@ -132,8 +139,6 @@ public final class Agent {
132139
}
133140
}
134141

135-
// MARK: - Compaction
136-
137142
private func applyCompaction(_ messages: [Message]) async -> [Message] {
138143
var compacted = messages
139144
contextCompactor.microCompact(messages: &compacted)
@@ -148,6 +153,36 @@ public final class Agent {
148153
return compacted
149154
}
150155

156+
func drainBackgroundNotifications(_ messages: [Message]) async -> [Message] {
157+
let notifications = await backgroundManager.drainNotifications()
158+
guard !notifications.isEmpty else {
159+
return messages
160+
}
161+
162+
let text =
163+
notifications
164+
.map { "[bg:\($0.jobId)] \($0.status.rawValue): \($0.result)" }
165+
.joined(separator: "\n")
166+
167+
var result = messages
168+
let wrappedText = "<background-results>\n\(text)\n</background-results>"
169+
170+
// Avoid consecutive user messages (violates API alternation requirement).
171+
// If last message is user, append to its content; otherwise add new user message.
172+
if let lastMessage = result.last, lastMessage.role == .user {
173+
var updatedContent = lastMessage.content
174+
updatedContent.append(.text(wrappedText))
175+
result[result.count - 1] = Message(role: .user, content: updatedContent)
176+
} else {
177+
result.append(.user(wrappedText))
178+
}
179+
180+
result.append(.assistant("Noted background results."))
181+
182+
print("[background] \(ANSIColor.dim)\(notifications.count) result(s) injected\(ANSIColor.reset)")
183+
return result
184+
}
185+
151186
public static func buildSystemPrompt(cwd: String, skillDescriptions: String = "") -> String {
152187
var prompt = """
153188
You are a coding agent at \(cwd). Use tools to solve tasks. \
@@ -158,6 +193,8 @@ public final class Agent {
158193
- Use the todo tool to plan multi-step tasks. Mark in_progress before starting, completed when done.
159194
- Use task tools for persistent multi-step work with dependencies. \
160195
Tasks survive context compaction and process restarts.
196+
- Use background_run for long-running commands (builds, tests, installs). \
197+
Check with background_check.
161198
"""
162199

163200
if !skillDescriptions.isEmpty {
@@ -386,6 +423,37 @@ extension Agent {
386423
]),
387424
"required": .array(["task_id"])
388425
])
426+
),
427+
ToolDefinition(
428+
name: "background_run",
429+
description: """
430+
Run a command in the background. Returns job_id immediately. \
431+
Use for long-running commands (builds, tests, installs).
432+
""",
433+
inputSchema: .object([
434+
"type": "object",
435+
"properties": .object([
436+
"command": .object([
437+
"type": "string",
438+
"description": "The shell command to execute in the background"
439+
])
440+
]),
441+
"required": .array(["command"])
442+
])
443+
),
444+
ToolDefinition(
445+
name: "background_check",
446+
description: "Check background job status. Omit job_id to list all.",
447+
inputSchema: .object([
448+
"type": "object",
449+
"properties": .object([
450+
"job_id": .object([
451+
"type": "string",
452+
"description": "The job ID to check"
453+
])
454+
]),
455+
"required": .array([])
456+
])
389457
)
390458
]
391459

@@ -402,7 +470,9 @@ extension Agent {
402470
"task_create": executeTaskCreate,
403471
"task_update": executeTaskUpdate,
404472
"task_list": executeTaskList,
405-
"task_get": executeTaskGet
473+
"task_get": executeTaskGet,
474+
"background_run": executeBackgroundRun,
475+
"background_check": executeBackgroundCheck
406476
]
407477

408478
guard let handler = handlers[name] else {
@@ -422,6 +492,8 @@ extension Agent {
422492
do {
423493
let result = try await shellExecutor.execute(command)
424494
return .success(result.formatted)
495+
} catch ShellExecutorError.blockedCommand(let pattern) {
496+
return .failure(.executionFailed("Dangerous command blocked (matched '\(pattern)')"))
425497
} catch {
426498
return .failure(.executionFailed("\(error)"))
427499
}
@@ -444,8 +516,7 @@ extension Agent {
444516

445517
if let limit = input["limit"]?.intValue, limit < lines.count {
446518
output =
447-
lines.prefix(limit).joined(separator: "\n")
448-
+ "\n... (\(lines.count - limit) more lines)"
519+
lines.prefix(limit).joined(separator: "\n") + "\n... (\(lines.count - limit) more lines)"
449520
} else {
450521
output = text
451522
}
@@ -641,6 +712,21 @@ extension Agent {
641712
}
642713
}
643714

715+
private func executeBackgroundRun(_ input: JSONValue) async -> Result<String, ToolError> {
716+
guard let command = input["command"]?.stringValue else {
717+
return .failure(.missingParameter("command"))
718+
}
719+
720+
let confirmation = await backgroundManager.run(command: command)
721+
return .success(confirmation)
722+
}
723+
724+
private func executeBackgroundCheck(_ input: JSONValue) async -> Result<String, ToolError> {
725+
let jobId = input["job_id"]?.stringValue
726+
let result = await backgroundManager.check(jobId: jobId)
727+
return .success(result)
728+
}
729+
644730
// MARK: Helpers
645731

646732
struct ToolProcessingResult {
@@ -729,25 +815,33 @@ extension Agent {
729815
// MARK: - Configuration
730816

731817
extension Agent {
732-
fileprivate struct LoopConfig {
818+
struct LoopConfig {
733819
let tools: [ToolDefinition]
734820
let maxIterations: Int
735821
let enableNag: Bool
822+
let drainBackground: Bool
736823
let label: String
737824

738825
static let `default` = LoopConfig(
739826
tools: Agent.toolDefinitions,
740827
maxIterations: .max,
741828
enableNag: true,
829+
drainBackground: true,
742830
label: "agent"
743831
)
744832

833+
static let subagentExcludedTools: Set<String> = [
834+
"agent", "todo", "compact", "task_create", "task_update",
835+
"background_run", "background_check"
836+
]
837+
745838
static let subagent = LoopConfig(
746839
tools: Agent.toolDefinitions.filter {
747-
!Set(["agent", "todo", "compact", "task_create", "task_update"]).contains($0.name)
840+
!subagentExcludedTools.contains($0.name)
748841
},
749842
maxIterations: 30,
750843
enableNag: false,
844+
drainBackground: false,
751845
label: "subagent"
752846
)
753847
}

0 commit comments

Comments
 (0)