The Agentic Loop Explained: Tool Calling in a Coding Agent

The 20-line idea every coding agent shares, and the guards that make it trustworthy

The Idea in Three Steps

Every coding agent, from Claude Code to a 200-line demo, runs the same cycle. iAssist calls it the CognitiveLoop, and its file opens with the whole idea:

// 1. Send the whole message history to the provider (the LLM).
// 2. If the model replies with plain text → we're done. Return it.
// 3. If the model asks to use tools → run them, append the results to the
//    history, and go back to step 1.

The model has no memory between calls. The entire message array is the context, and every iteration resends it in full. That single fact explains most of the engineering that follows: context limits, compaction, sub-agents and memory all exist because of it.

The Minimal Version

Stripped of everything else, the loop is short:

while true {
    let reply = try await provider.send(messages: messages, tools: toolSpecs)

    switch reply.response {
    case .text(let text):
        messages.append(AgentMessage(role: .assistant, content: .text(text)))
        return RunResult(finalText: text, messages: messages)

    case .toolCalls(let calls):
        messages.append(AgentMessage(role: .assistant, content: .toolCalls(calls)))
        for call in calls {
            let (output, isError) = await execute(call)
            messages.append(AgentMessage(
                role: .tool,
                content: .toolResult(callId: call.id, output: output, isError: isError)))
        }
    }
}

This is what we had working on day one, against a 3-billion-parameter model on a laptop. It worked. It also failed in interesting ways within the first hour.

Failure 1: The Loop That Never Ends

Small models get stuck. The classic case: the model calls glob ., gets a file list, then calls glob . again, and again. The loop never ends because the model never answers.

iAssist tracks a signature of each tool batch. If the same batch repeats three times, the loop stops with an explanation instead of an error:

let signature = calls.map { "\($0.name)(\($0.argumentsJSON))" }.joined(separator: ";")
if signature == lastCallSignature {
    identicalRepeats += 1
} else {
    lastCallSignature = signature
    identicalRepeats = 1
}

if identicalRepeats >= maxIdenticalRepeats {
    return stopGracefully("""
    I repeated the same action (\(brief)) \(identicalRepeats) times without \
    making progress, so I stopped. This usually means I don't have a tool for \
    this task or the approach is wrong — tell me how you'd like to proceed.
    """, reason: .repeatedAction)
}

Notice the message. It names the most likely real cause, which is usually “I don’t have a tool for this”. Often a model asked to write a file with only read-only tools will loop exactly like this.

There is also a plain step budget (maxIterations, default 20). Hitting it ends the run with a summary, not a crash, so the steps already taken stay in the context.

Failure 2: The Silent Truncation

One bug took a while to understand. Asking the agent to write a large file failed with “missing required argument: content”. The model kept retrying the same call until the repeat guard fired.

The real cause: the output-token limit was hardcoded to 4,096. The model ran out of tokens in the middle of the tool call, so write_file arrived as {"path": "…"} with no content.

Two fixes followed. The limit became per-model (up to 128K for capable models). And the provider now reports when a reply was truncated, so the loop stops honestly on the first turn:

if reply.truncated {
    return stopGracefully("""
    My response hit the model's output-token limit before I could finish, \
    so the tool call I was building was cut off. I stopped rather than run \
    a broken call. Ask me to produce it in smaller pieces and I'll continue.
    """, reason: .outputLimit)
}

Lesson: When an agent fails, the most valuable thing it can do is say why in words a human can act on.

Failure 3: Halts That Look Like Answers

Early on, a graceful stop returned the apology text as the final answer. A parent agent (see Part 6) couldn’t tell “I finished” from “I gave up”. So every result now carries an outcome:

return RunResult(
    agentID: id, finalText: summary,
    iterations: iteration, toolCallsExecuted: toolCallsExecuted,
    messages: messages,
    outcome: .halted(reason)   // .repeatedAction, .outputLimit, .cancelled, ...
)

The summary explains the halt to a human. The reason explains it to code.

Failure 4: Stopping Without Breaking Things

Users need a stop button (in iAssist, Esc). But stopping an agent is subtle:

The “every call gets a result” rule matters because providers reject a transcript where a tool call has no matching result. We will meet this rule again in Part 7.

Observability: The Event Stream

A loop you can’t see is a loop you can’t trust. Every CognitiveLoop exposes an AsyncStream<LoopEvent>:

public let events: AsyncStream<LoopEvent>
// .iterationStarted, .toolStarted, .toolExecuted, .narrated,
// .modelResponded(usage:duration:), .contextPressure, .finished, .failed ...

The terminal UI uses it for the live spinner (“reading Package.swift…”), the Mac app uses it for the agent tree, and the metrics package uses it for token counts.

One contract makes this robust: every run emits exactly one terminal event and then closes the stream. A defer guarantees it on every exit path:

defer { continuation.finish() }

Without this, an observer waiting on a stream that never finishes would hang forever, and keep the loop alive in memory.

Honest Metrics

The loop measures throughput (tokens per second) by timing only the model call. Tool execution and the time a human spends reading a permission prompt are excluded, otherwise the number would measure your reading speed, not the model.

And it only records a sample when the provider reports real token usage. An estimate is never shown as a measurement. This “never pretend” rule shows up again and again in iAssist.

Concurrency: Why Sendable Matters

CognitiveLoop is a final class marked Sendable: all stored properties are immutable. Each run builds its own local history and returns it. This means one loop can be handed across tasks and actors safely, which becomes essential once a parent agent spawns child loops.

Key Takeaways

In Part 3 we look at the seams that let the same loop talk to a local Ollama model, Anthropic’s API and Apple’s on-device model without changing a line of loop code.