The Agent Loop: a First-Principles Design
The agent loop is 30 lines of Kotlin. Everything else is what happens when it goes wrong.

Strip away the frameworks, the streaming, the session persistence, and the plugin hooks. What’s left is a while loop with a when expression. Understanding that loop — really understanding it — is the prerequisite for every architectural decision that follows, including the module boundaries covered in article 02
This article stays inside sophi-core. No Spring, no web surface, no TUI. Just the loop, and the handful of problems that make it more than 30 lines in practice.
The loop in plain language
Here’s the whole thing, in a sentence: send the system prompt and the user’s message to the model; if it answers with text, you’re done; if it answers with tool calls, run them, append the results, and send the conversation back to the model again. Repeat until you get text back instead of another round of tool calls.
Turn by turn:
- Turn 0. System prompt plus user message go to the LLM.
- The response is either text — the model is done, return it — or tool calls — the model wants to act before it can answer.
- If it’s tool calls, execute them, append the results to the conversation, and start turn 1 with the updated conversation.
- Repeat. Turn 2, turn 3, however many it takes, until a turn produces a text response instead of tool calls.
That’s the entire loop. There’s no branch for “the agent is planning,” no branch for “the agent is reflecting,” no state machine with named phases. Every turn asks the same question — text or tool calls? — and does one of two things in response. Everything this article covers past this point is what has to exist around that loop to make it survive contact with real conversations: concurrent tool execution, context limits, mid-run interruptions. None of it changes the shape of the loop itself.
Modelling the response as a sealed class
The clearest way to keep the loop this simple in code is to make illegal states unrepresentable. An LLM response is one of exactly three things, and Kotlin’s sealed classes are built for exactly this:
sealed class LLMResponse {
data class Text(val content: String) : LLMResponse()
data class ToolUse(val calls: List<ToolCall>) : LLMResponse()
data class Error(val cause: Throwable) : LLMResponse()
}The loop body is a when over this type, and nothing else:
when (val response = provider.send(conversation)) {
is LLMResponse.Text -> return response.content
is LLMResponse.ToolUse -> {
val results = executeTools(response.calls)
conversation.append(response, results)
// continue to next turn
}
is LLMResponse.Error -> handle(response.cause)
}No mutable state flags. No var isDone = false checked at the top of a while. No boolean guard that has to be set correctly in three different places or the loop hangs. The sealed class is the state machine — the compiler enforces that every branch is handled, and there's no fourth case to accidentally fall through. This is the kind of design decision that pays for itself the first time someone adds a fourth response type six months from now and the compiler, not a code reviewer, catches every when that needs updating.
Parallel tool execution
Models frequently return more than one tool call in a single turn — read three files, then decide what to do next. Running those sequentially wastes time for no benefit; running them carelessly in parallel risks two tool calls racing over the same resource, or a hook that should have blocked one of them not getting the chance.
SoPhi splits tool execution into two phases with different concurrency rules.
Preflight runs sequentially. Before any tool call executes, it passes through preflight hooks — permission checks, validation, anything that might block or deny the call outright. This has to be sequential because hooks can have ordering dependencies and because a hook denying call two shouldn’t have to race against call one already running.
Execution runs concurrently, once every call has cleared preflight:
suspend fun executeTools(calls: List<ToolCall>): List<ToolResult> =
coroutineScope {
calls.map { call -> async { execute(call) } }
.awaitAll()
}coroutineScope here does the job that matters most: if any one tool call throws, the scope cancels the others and propagates the failure, rather than leaving orphaned coroutines running against a conversation that's already moved on. The concurrency is real — three tool calls that each take two seconds finish in about two seconds, not six — but it's concurrency with a single point of failure and a single point of completion, which is what keeps it safe to reason about.
The rule of thumb: anything that can say no has to go first and go one at a time. Anything that’s already been approved can run together.
The context window problem
Every conversation with an LLM has a hard ceiling — the context window — and a long-running agent session will eventually hit it. When it does, the provider returns something like ContextWindowExceededError, and the naive response — just retry the same request — fails identically every time, because the conversation that overflowed the window is still the same size on the second attempt.
The fix is compaction: summarize the conversation so far, replace the older messages with that summary, and keep the most recent turns intact. The model loses fine detail on what happened early in a long session, but keeps working memory of what just happened and a compressed record of everything before it.
The tricky part isn’t the summarization itself — it’s knowing exactly where “recent” starts. SoPhi tracks this with a firstKeptEntryId: an anchor pointing at the earliest session entry that survives compaction untouched. Everything before that anchor gets folded into the summary; everything from that anchor forward stays as-is. This anchor is what makes compaction replayable and auditable — given a session file, you can always tell which turns are original and which are summary, because the anchor is recorded alongside the summary itself, not inferred after the fact.
Compaction is a loop-adjacent concern rather than a loop-internal one: the loop doesn’t know it happened, it just sees a shorter conversation on the next turn. That separation is deliberate — it means compaction strategy can change without touching the when expression that defines the loop's actual behavior.
Steer and follow-up queues
Long-running agent sessions sometimes need a mid-run correction — a user typing something new while the agent is still working through a previous instruction. The naive approach is to inject it immediately, wherever the loop happens to be. That’s also the approach that produces the worst bugs: a steering message landing in the middle of a tool call’s results, corrupting the shape of the conversation the model expects to see next.
SoPhi queues steering and follow-up input rather than injecting it immediately, and delivers it only at turn boundaries — after a turn has fully completed (tool results appended, conversation updated) and before the next turn’s request goes out. Never mid-tool-call. This is a narrow, deliberate contract: steering input has to wait, at most, until the current turn finishes, but it never has to wait for the whole run to finish, because turn boundaries come up every few seconds in practice, not every few minutes.
The delivery timing contract is what makes this predictable rather than merely “usually fine.” A queue that gets flushed at turn boundaries is a queue whose behavior doesn’t depend on how much work happened to be in flight when the user typed.
What the loop doesn’t do
It’s worth being explicit about what’s deliberately absent, because the absence is the design decision.
The loop has no concept of sub-agents. No concept of a “plan mode” that reasons differently before acting. No built-in memory beyond the conversation itself. None of these are missing because they weren’t considered — they’re missing because they don’t belong in the loop. Sub-agent orchestration, planning strategies, and memory systems are all things that can be built as plugins against the tool contract and event model sophi-core exposes, without the loop itself needing to know they exist.
This is the same instinct from article 01, applied one level down. The loop stays a while and a when because every capability that could have been folded into it — parallelism, compaction, steering, and now sub-agents and planning — turned out to be expressible as something around the loop instead of inside it. That's not an accident of convenience. It's the test we kept applying: if a feature can live outside the loop as a plugin or a wrapper, it should, because the loop's value is in staying small enough that everyone building on sophi-core can hold the whole thing in their head at once.
What comes next
This article described the shape of the loop without shipping the loop itself — runOneTurnCycle, the finished LLMResponse and AgentEvent implementations, and a sequence diagram walking through two parallel tool calls end to end will land here once the corresponding build phases are complete. The design in this article is what that code will implement; nothing above changes shape between now and then, which is rather the point of writing it down first.
What’s next?
The next article in this series covers building Provider Abstraction Without Framework Lock-in
Find the full source on GitHub and connect with me on LinkedIn.