Skip to content
Ivan Magda
Go back

Building a Coding Agent in Swift, Part 4: Subagents

Suggest Changes

Our agent can now run commands, read and write files, edit code, and track its own work with a todo list. That’s a capable set of tools — but every one of them shares the same context. Ask the agent to research which testing framework a project uses, and it might read five files, grep through a directory, and try a few bash commands before arriving at the answer: “XCTest.” All of those intermediate tool calls — the file contents, the grep output, the exploratory commands — stay in the messages array permanently. The parent conversation didn’t need any of that. It just needed the one-word answer.

This is context pollution. The agent’s messages array is its working memory, and every tool call adds to it. A research task that reads ten files adds ten tool results to the context, even though the caller only cares about the conclusion. Over a long session with several such tasks, the context fills with intermediate results that crowd out the information that actually matters. Worse, those old results contribute to instruction-following decay — the very problem we tackled in the previous guide.

The fix is delegation with isolation. Instead of doing everything in one conversation, the agent can spawn a subagent — a child that gets a fresh messages array, does its work, and returns only a text summary. The parent’s context stays clean. The child’s entire working history is discarded. In this guide, let’s build that delegation mechanism and introduce LoopConfig, a struct that lets the same agent loop behave differently depending on whether it’s running as a parent or a child.

The complete source code for this stage is available at the 04-subagents tag on GitHub. Code blocks below show key excerpts.


A fresh messages array as a stack frame

The analogy that makes subagents click is a function call. When we call a function, it gets its own stack frame — local variables, local control flow — and returns a value. The caller doesn’t see the function’s internal state; it just gets the result. A subagent works the same way: it starts with messages = [Message.user(prompt)], runs the agent loop with its own growing context, and returns the final assistant text. The parent receives that text as a normal tool result — one content block instead of dozens.

The key architectural decision here is how agentLoop relates to the agent’s state. In the previous guides, the while true loop lived directly inside run() and mutated self.messages in place. To support subagents, we need to extract that loop into a method that can operate on any messages array — the parent’s or a fresh one. The natural approach in Swift would be inout [Message], letting the method mutate the caller’s array directly. But Swift 6.2’s strict concurrency checker rejects inout parameters on self properties inside async methods — it can’t prove exclusive access across await suspension points. That’s a hard compiler error, not a warning.

The alternative is pure value semantics: agentLoop takes [Message] by value and returns a (text: String, messages: [Message]) tuple. The caller decides what to do with the returned messages. For the parent, run() writes them back to self.messages. For a subagent, the caller discards them — the isolation is automatic:

// Sources/Core/Agent.swift
public func run(query: String) async throws -> String {
    messages.append(.user(query))

    let result = try await agentLoop(initialMessages: messages, config: .default)
    messages = result.messages

    return result.text
}

The parent calls agentLoop with its accumulated messages and writes the result back. A subagent calls the same method with [Message.user(prompt)] and lets the result fall away. Same function, different inputs, different lifecycles. Swift’s value semantics mean the parent can never accidentally share state with a child — the fresh array is a copy, not a reference. That’s a safety guarantee we get for free from the language.


LoopConfig: same loop, different rules

Extracting the loop solves context isolation, but parent and child need to behave differently too. The parent has access to all tools; the child shouldn’t be able to spawn its own subagents (that’s unbounded recursion) or update the parent’s todo list (the TodoManager is shared state on the same Agent instance). The parent runs indefinitely; the child needs a safety limit. The parent nags about todos; the child shouldn’t, since it can’t call todo anyway.

All of these behavioral differences live in a single struct:

// Sources/Core/Agent.swift
fileprivate struct LoopConfig {
    let tools: [ToolDefinition]
    let maxIterations: Int
    let enableNag: Bool
    let label: String

    static let `default` = LoopConfig(
        tools: Agent.toolDefinitions,
        maxIterations: .max,
        enableNag: true,
        label: "agent"
    )

    static let subagent = LoopConfig(
        tools: Agent.toolDefinitions.filter {
          $0.name != "agent" && $0.name != "todo"
        },
        maxIterations: 30,
        enableNag: false,
        label: "subagent"
    )
}

Two static presets cover everything. The parent gets all tools, unlimited iterations, nag enabled, labeled "agent". The subagent filters out agent and todo, caps at 30 iterations, disables nag, and labels itself "subagent" so log output is distinguishable. The tool exclusion uses a denylist — filter { $0.name != ... } — rather than an allowlist, so new tools added in future stages are automatically available to subagents unless explicitly excluded.

The label field is a small touch that matters more than it looks. When a subagent is running, every tool call and text output is prefixed with [subagent] instead of [agent]. Watching the terminal, it’s immediately clear which loop is active — essential for debugging delegation behavior.


Wiring the agent tool and guarding the dispatch

The agent tool handler is the simplest in the codebase. It extracts the prompt, calls agentLoop with a fresh single-message array and the .subagent config, and returns the text:

// Sources/Core/Agent.swift
private func executeAgent(_ input: JSONValue) async -> Result<String, ToolError> {
    guard let prompt = input["prompt"]?.stringValue else {
        return .failure(.missingParameter("prompt"))
    }

    do {
        let result = try await agentLoop(
            initialMessages: [Message.user(prompt)],
            config: .subagent
        )
        var output = result.text

        if output.isEmpty {
            output = "(no output)"
        } else if output.count > Limits.maxOutputSize {
            output = String(output.prefix(Limits.maxOutputSize))
        }

        return .success(output)
    } catch {
        return .failure(.executionFailed("Subagent failed: \(error)"))
    }
}

The result.messages — the subagent’s entire working history — is never assigned anywhere. It falls out of scope when executeAgent returns, and with it goes every intermediate tool call the child made. The parent sees only result.text.

There’s one more piece that matters: defense in depth. Even though LoopConfig.subagent doesn’t include the agent tool definition, the model can still hallucinate a tool_use block for it. Language models don’t always respect the tool list — they’ve seen these tool names in training data and may emit them regardless. Without a guard, a hallucinated agent call inside a subagent would trigger unbounded recursion. The fix is an allowedTools check in processToolUses:

// Sources/Core/Agent.swift
private func processToolUses(
    response: APIResponse,
    allowedTools: Set<String>,
    label: String
) async -> (results: [ContentBlock], didUseTodo: Bool) {
    var results: [ContentBlock] = []
    var didUseTodo = false

    for case .toolUse(let id, let name, let input) in response.content {
        guard allowedTools.contains(name) else {
            let message = "Tool '\(name)' is not allowed in this context"
            results.append(.toolResult(toolUseId: id, content: message, isError: true))
            continue
        }
        // ... execute tool, append result ...
    }

    return (results, didUseTodo)
}

The allowedTools set is built once from config.tools at the top of agentLoop. If the model emits a tool call for a name not in the set, the handler returns an error result with isError: true — the model sees the rejection and adjusts. No recursion, no crash.


The assembled loop

With LoopConfig and processToolUses in place, let’s look at the complete agentLoop. It’s the same while true kernel from the previous guides — API call, check stop reason, process tools, append results — now parameterized by a config:

private func agentLoop(
    initialMessages: [Message],
    config: LoopConfig
) async throws -> (text: String, messages: [Message]) {
    var messages = initialMessages
    var turnsWithoutTodo = 0
    var iteration = 0
    var lastAssistantText = ""

    let allowedTools = Set(config.tools.map(\.name))

    while true {
        try Task.checkCancellation()

        iteration += 1
        if iteration > config.maxIterations {
            return (lastAssistantText + "\n(\(config.label) reached iteration limit)", messages)
        }

        let request = APIRequest(
            model: model, maxTokens: Limits.defaultMaxTokens,
            system: systemPrompt, messages: messages, tools: config.tools
        )

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

        guard response.stopReason == .toolUse else {
            return (response.content.textContent, messages)
        }

        let (results, didUseTodo) = await processToolUses(
            response: response, allowedTools: allowedTools, label: config.label
        )

        var toolResults = results
        if config.enableNag {
            turnsWithoutTodo = didUseTodo ? 0 : turnsWithoutTodo + 1
            if turnsWithoutTodo >= Self.todoReminderThreshold && todoManager.hasOpenItems() {
                toolResults.append(.text("Update your todos."))
            }
        }

        messages.append(Message(role: .user, content: toolResults))
    }
}

With that in place, we have an agent that can delegate. The parent dispatches a subtask, the child works autonomously with its own context, and only the summary comes back. One thing to keep in mind here is that lastAssistantText tracks the most recent assistant response at each iteration. When the subagent hits its 30-iteration limit, the method returns whatever the model last said — plus a note that the limit was reached. During development, this initially extracted text from messages.last, which was wrong: at the iteration-limit check point, the last message is a user message containing tool results, not the assistant’s response. Tracking it explicitly after each API call avoids that off-by-one.


Taking it for a spin

Let’s build and run:

swift build && swift run claude

Try a delegation-heavy task: Use a subagent to find what dependencies this project has, then tell me the list. Watch the terminal — tool calls prefixed with [subagent] show the child reading Package.swift and exploring the file tree, while the parent waits. When the subagent finishes, the parent receives a summary and continues in its clean context.

For something more interesting, try: Delegate a task to read all the Swift source files in Sources/ and summarize what each one does. The subagent might make five or six read_file calls, but the parent’s context only grows by one tool result — the summary. That’s the value of context isolation in action.


What we’ve built and where we’re going

We now have an agent that delegates. The agent tool spawns a subagent with a fresh messages array, the child works independently using the same loop and the same filesystem, and only a text summary returns to the parent. Context stays clean, and LoopConfig controls the behavioral differences — tool access, iteration limits, nag behavior — through static presets rather than scattered conditionals.

The deeper lesson is that none of this required changing the loop itself. The while true kernel — API call, check stop reason, process tools, append results — is identical to what we built in the first guide. We extracted it into a method, parameterized it with a config struct, and called it recursively. The loop is the invariant; tools and configuration are the variables. LoopConfig will continue to grow — when we add background tasks later in the series, it gains a drainBackground flag to prevent subagents from consuming the parent’s notifications. But the growth pattern is always the same: one new field, one new preset value. In the next guide, we’ll give the agent the ability to load skills on demand — knowledge files that expand its capabilities without bloating every request. Thanks for reading!


Suggest Changes
Share this post on:

Previous Post
Building a Coding Agent in Swift, Part 3: Self-Managed Task Tracking
Next Post
Building a Coding Agent in Swift, Part 5: Skill Loading