{"slug": "vercel-ai-sdk-6-an-agent-is-just-a-while-loop", "title": "Vercel AI SDK 6: An Agent Is Just a while Loop", "summary": "Vercel AI SDK 6 introduces an agent framework where an agent is essentially a while loop that repeatedly calls a model and runs tools until a stopping condition is met. A developer demonstrated that without the `stopWhen` option, `generateText` only makes one model call and returns an empty string, but with `stopWhen`, it loops until the model produces a final answer. The SDK abstracts vendor-specific LLM APIs behind a unified interface, and the agent behavior is controlled by a simple loop rather than complex logic.", "body_md": "I wired the same model and the same single tool into two functions in the exact same way. One of them ran the tool once and then **gave back no answer at all**; the other **called that same model twenty times over.** The only thing that changed in the code was one line — whether or not I flipped on an option called `stopWhen`\n\n.\n\nThat was the scene I ran into the first time I actually installed Vercel AI SDK 6 and ran something on top of `ai@6.0.212`\n\n. These days the library leads with the banner of an \"agent framework.\" Yet what that word \"agent\" points to at the level of real code turns out to be surprisingly fuzzy. So this post strips away the marketing copy and takes apart, by hand, what the AI SDK 6 agent actually is as a control flow — **with no model API key, no GPU, and not a single external call.**\n\n[!NOTE]\n\nEvery number and behavior in this post was verified by running it myself against amock modelon Windows 11 · Node v24.15.0 ·`ai@6.0.212`\n\n·`zod@4.4.3`\n\n. Because no real LLM is ever called, the results reproduce identically every time. Why the \"mock model\" is the crux is something I explain separately halfway through.\n\nLet me lay the groundwork before the main event, so that even a first-time reader can follow all the way through.\n\nIn web development, when we send an HTTP request to a server, we don't hand-write the low-level, browser-specific code ourselves. An **abstraction layer** like `fetch`\n\nor `axios`\n\npapers over the differences. **The AI SDK is fetch for calling LLMs (large language models).** It wraps the vendor-by-vendor APIs — OpenAI, Anthropic, Google — behind a single function like\n\n`generateText`\n\n, so you can swap models like changing a plug in a socket while your app code stays put. Think of it as an adapter that turns every vendor's differently shaped plug into one power strip.As of 2026, AI SDK 6 has moved up to a [general availability (GA) release](https://vercel.com/blog/ai-sdk-6) (not a beta), and the `ai@6.0.x`\n\npackage is still being updated as of late June. Four things stand out as the headline changes from 5 to 6.\n\n`ToolLoopAgent`\n\nLet me unpack just two terms first. A **tool call** is the model asking, \"instead of answering directly, please call the `getWeather('Seoul')`\n\nfunction for me.\" The model can't run functions itself, so our code does the running and hands the result back to the model. An **agent** is exactly this round trip — model calls a tool → we run it and return the result → the model looks at that and decides what's next — repeated until the job is done. It sounds grand, but as we'll see shortly, the reality of it is one ordinary loop.\n\nThe first thing I wanted to confirm was this: if you hand tools to `generateText`\n\n, does that by itself make it an agent?\n\nI scripted the mock model to move along like this. On **step 1** it returns a tool call saying \"call the `weather`\n\ntool with `{city:'Seoul'}`\n\n,\" and on **step 2** it returns the final text \"Seoul is 21C.\" Then I called that same model two different ways.\n\n**First, plainly, with no options** — `generateText({ model, tools, prompt })`\n\n:\n\n| Observation | Value |\n|---|---|\n| Step count | 1 |\nModel calls (`doGenerate` ) |\n1 |\n| Final text | \"\" (empty string) |\n| Did the tool run? |\nYes — `{city:'Seoul', tempC:21}`\n|\n\nThe tool clearly ran. And yet the final answer is empty. Why? Because the model called the tool once and **stopped** right there. The tool's result **never went back** to the model, and the model never got a second turn. By default, `generateText`\n\nis a **one-shot function** that ends the moment a tool is called once. The automatic round trip we casually call \"an agent\" simply **isn't here.**\n\n**Now I added a single line** — `stopWhen: stepCountIs(5)`\n\n:\n\n| Observation | Value |\n|---|---|\n| Step count | 2 |\n| Model calls | 2 |\n| Final text | \"Seoul is 21C.\" |\n\nSame model, same tool — but this time the model was called twice. When the first call requested the tool, the SDK ran it, appended the result to the conversation, and **called the model again**; on that second call the model finally produced its closing sentence. That is exactly the contradictory scene from the opening.\n\n`stopWhen`\n\n, true to its name, is the condition that decides **when to stop.** `stepCountIs(5)`\n\nmeans \"run for at most 5 steps,\" `hasToolCall('weather')`\n\nmeans \"stop the moment that tool is called,\" and you can supply your own function for a custom condition too. Sure enough, when I set `hasToolCall('weather')`\n\n, it stopped at exactly step 1, right after the tool was called.\n\nSo in AI SDK 6, **what an \"agent\" really is is a while loop that keeps calling the model until stopWhen says stop.** Not magic — a loop with a termination condition, and the switch that turns that termination condition on is\n\n`stopWhen`\n\n.\n\nDiagram:this section is illustrated with a flowchart —[view it in the original article on var.gg].\n\nThis is where a class called [ ToolLoopAgent](https://ai-sdk.dev/docs/reference/ai-sdk-core/tool-loop-agent) comes in. Its official name is\n\n`ToolLoopAgent`\n\n(the older beta's `Experimental_Agent`\n\nremains as an alias pointing at the same class — in my install, `ToolLoopAgent === Experimental_Agent`\n\nwas `true`\n\n), and what it does is bundle the model, tools, and `stopWhen`\n\ninto one object: a `generateText`\n\n.There is one decisive difference, though. **Even if you don't give ToolLoopAgent a stopWhen, it ships with stepCountIs(20) as the default.** I fed a mock model that endlessly re-requests the tool into both approaches.\n\n| Call style | Step count with no stopWhen |\n|---|---|\n`generateText` |\n1 (no loop) |\n`ToolLoopAgent` |\n20 (stops at the default cap) |\n\nSame model, but one runs once and the other twenty times. That number 20 is the whole identity of \"the agent.\" `generateText`\n\nstarts out with its loop **switched off**, and `ToolLoopAgent`\n\nstarts out with its loop **switched on at 20 steps.** That is the entirety of the essential difference between the two APIs.\n\n[!WARNING]\n\nSo here's the first real-world trap. If the model, for whatever reason, never stops calling tools,`ToolLoopAgent`\n\nwillquietly call the model 20 times — meaning you're billed 20 times.It saves you from an infinite loop, but that ceiling is also your cost ceiling.`stopWhen`\n\nis both a safety catch and a budget dial.\n\nTools don't always succeed cleanly. So I broke one on purpose. I built a tool whose `execute()`\n\nimmediately does `throw new Error('TOOL_BOOM')`\n\nand dropped it into the loop. My expectation was \"the loop blows up on the exception and dies.\"\n\nWrong. The loop **did not throw.** Instead it went like this:\n\n`[\"tool-call\", \"tool-error\"]`\n\n,`tool-error`\n\nwas The same held when the model called a tool that doesn't even exist. Instead of a crash, it surfaced as a `tool-error`\n\nand the loop carried on. In other words, **AI SDK 6 is designed to catch tool-execution failures by default and, rather than throwing an exception, tell the model \"this tool failed in this way.\"** It's a self-heal structure that leaves the model room to route around the failure or pick a different tool on its own.\n\nThis isn't all upside. Because the error is quietly absorbed into the conversation, those of us outside the code may only notice later — \"the tool failed, so why did I just pay more?\" This automatic feedback is the **default behavior**, and using it while knowing you can change it (via tool options or an error handler) is different from using it unaware. It rhymes with cooperative cancellation in general: when you abort a request midway, what actually stops and what keeps spinning on as \"orphan work\" always comes down to someone's cooperation — control is never something you hold unilaterally.\n\nThe next thing I looked at was **structured output.** A model, left to itself, spits out free text. But an app often needs an **object of a fixed shape** like `{ city: string, tempC: number }`\n\n. For this, the AI SDK provides — separate from the free-text `generateText`\n\n— a path that forces the result to conform to a schema. The dedicated function `generateObject`\n\nshows the boundary most sharply, so I experimented with it, but one thing is worth flagging up front: **in v6, generateObject is already marked @deprecated.** The installed type definitions themselves steer you to \"use the\n\n`output`\n\nsetting on `generateText`\n\ninstead.\" For new code, `generateText({ output: Output.object({ schema }) })`\n\nis the recommended path (though this result still comes out under the name `experimental_output`\n\n— i.e., the surface isn't fully set yet). Either way, `generateObject`\n\n, where that boundary is crispest.I gave it the `zod`\n\n(a runtime schema-validation library) schema `z.object({ city: z.string(), tempC: z.number() })`\n\nand ran three cases.\n\n| What the model emitted |\n`generateObject` result |\n|---|---|\n`{\"city\":\"Seoul\",\"tempC\":21}` |\nValid — typed object. At runtime `object.tempC * 2 = 42` works |\n`{\"city\":\"Seoul\",\"tempC\":\"hot\"}` (type violation) |\n`AI_NoObjectGeneratedError` thrown |\n`totally not json` |\n`AI_NoObjectGeneratedError` thrown |\n\nLet me clear up a common misconception here. \"Give it a TypeScript type and the model will honor that type\" is a **half-truth.** The type is a **compile-time promise**, and whether the model actually kept that promise is **checked by zod at runtime.** They're different layers. The compiler guarantees the type, but the model can break the promise at runtime however it likes, and when it does the SDK **throws an exception** rather than quietly forcing a fit.\n\nThe type layer itself is solid. Checking with a separate type pass (`tsc --strict`\n\n), `generateText`\n\n's result `.text`\n\nis `string`\n\n(no structure at all), while `generateObject({ schema })`\n\n's `.object.tempC`\n\nwas a `number`\n\ninferred from the schema. Trying to assign a `string`\n\nwhere a `number`\n\nbelongs produced a compile error. **What the compiler catches is \"does my code use the object correctly,\" and what zod catches is \"did the model produce a correct object.\"** Mistake the two for the same thing and you'll get the experience where the types pass but production blows up with a `NoObjectGeneratedError`\n\n.\n\nWhen you stream a response with `streamText`\n\n, characters trickle onto the screen — but what's happening underneath? With a mock model I built a stream mixing text fragments and tool calls, and transcribed the order of the pieces that land on `fullStream`\n\nexactly as they came.\n\n```\nstart → start-step → text-start → text-delta → text-end\n  → tool-input-start → tool-input-delta → tool-input-end\n  → tool-call → tool-result → finish-step → finish\n```\n\nTwo things matter here. First, a stream is not a blob of characters but a **sequence of typed events** — text-start, text-delta, text-end; tool-input start, delta, end — each stage split into its own part, so the UI can render exactly whether \"the model is calling a tool right now\" or \"is writing text.\" Second, `tool-result`\n\nshows up **in the middle of the stream**, right after `tool-call`\n\n. That means the SDK doesn't wait for streaming to finish before running the tool; it runs the tool mid-flow and splices the result back into the stream.\n\nOne note for anyone considering a migration. In AI SDK 5, the option that turned on the multi-step loop was `maxSteps`\n\n. **In v6 this option was removed.** The problem is that it disappears without making any noise.\n\nI passed `generateText({ ..., maxSteps: 5 })`\n\nthrough just like v5 code. Type checking did flag that `maxSteps`\n\nis no longer a valid option (confirmed with `@ts-expect-error`\n\n), but **at runtime it was silently ignored** — step count 1, one model call, final text an empty string. In other words, an agent loop that ran fine on `maxSteps`\n\nin v5 will, in any environment that skips type checking (pure JS, a loose build), **degrade into a one-shot function without a single line of error** the moment you bump to v6, and the answer goes empty. In v6 you have to turn the loop on with `stopWhen`\n\n.\n\n[!NOTE]\n\nTo sum up, there are two ways to turn on multi-step in v6 — the functional way gives`stopWhen`\n\ndirectly to`generateText`\n\n/`streamText`\n\n, and the object way uses`ToolLoopAgent`\n\nand decides whether to keep or lower the default`stepCountIs(20)`\n\n. The v5`maxSteps`\n\nand`system`\n\nparameters were renamed to`stopWhen`\n\nand`instructions`\n\n, respectively.\n\nAfter running it myself, the most honest thing I can say is that AI SDK 6's **boundary between what it gives you and what it doesn't is sharper than you'd expect.**\n\n**What it gives**\n\n`mock model`\n\nI'll cover separately below, you can reproduce the entire control flow deterministically, no model required.**What it doesn't give**\n\n`stopWhen`\n\n.This last item matters. Every conclusion in this post is **the \"contract and control flow\" seen on top of a mock model** — it does not reproduce a real LLM's caprice (nonsensical tool arguments, an unquenchable appetite for calling tools). The mock model clearly shows one half of the truth — how the SDK *promised* to behave — and the other half (how the model breaks that promise) only surfaces with a real provider.\n\nA `mock model`\n\nis a model implementation whose script we write in ourselves, in place of a real LLM. AI SDK 6 officially ships `MockLanguageModelV3`\n\nin `ai/test`\n\n. Because you can nail down in code \"return this tool call as the response, then return this text next,\" you can reproduce the agent loop, the stop condition, error handling, and streaming order in full — **with no API key, no cost, and no network.** This is exactly why this post could fill in every table above while calling the model zero times.\n\nPlenty of \"build a chatbot with the AI SDK\" posts are out there, but ones that tackle **\"how to test AI features deterministically, at zero cost\"** head-on are rare. From a product-code standpoint the latter is actually the more urgent need, because you can't validate the loop and failure paths of an agent bound for production against a real model that burns money and wobbles on every run. Set this next to how the Python world pins an agent's inputs and outputs down with types to solve the same problem, and the contrast gets sharp.\n\nThe AI SDK isn't the only road to building an agent. Here's how it shakes out, based on hands-on experience and each tool's official positioning.\n\n| Option | Abstraction level | Type safety | Testability | Lock-in risk |\n|---|---|---|---|---|\nAI SDK 6 |\nMedium — provider abstraction + loop | High (TS + zod) | High (official mock model) | Low (multi-provider) |\nLangChain.js |\nHigh — up to chains/graphs | Medium | Medium | Medium (heavy ecosystem dependence) |\nMastra |\nHigh — workflow-centric | High (TS-first) | Medium | Medium |\nOpenAI Agents SDK (JS) |\nMedium — agent-specialized | Medium | Medium | High (OpenAI-flavored) |\nRaw fetch |\nNone | By hand | By hand | None |\n\nThe point isn't \"which is best.\" AI SDK 6's place is the spot that **keeps the abstraction thin (only as far as the loop and the provider) while keeping types and testing solid.** If you need higher-level orchestration (complex branching, long-running workflows), LangChain or Mastra may fit better; if you're fine binding deeply to one provider, that vendor's SDK might. The AI SDK's strength isn't a flashy graph — it's that, just as we did a moment ago, you can **inspect every corner of the loop without a model.**\n\nThis tool shines brightest when you want to add \"an AI feature with tool calls, in a way that's less bound to a provider and solid on types and testing.\" Conversely, if complex long-running workflow orchestration is the essence of what you're building, look to a heavier framework. After taking it apart myself, the sentences that stick are just a few.\n\n`while`\n\nloop that `stopWhen`\n\nturns on.`generateText`\n\nstarts with the loop off, `ToolLoopAgent`\n\nwith it on at a default of 20 steps.`stopWhen`\n\nis both a safety catch and a budget dial.`generateObject`\n\nanswers a promise-breaking model with an exception.`maxSteps`\n\nis gone in v6.`stopWhen`\n\n.In an era where \"agents write the code,\" using an agent without knowing what loop it actually is on the inside is precarious. (I reached the same conclusion writing about [git tooling in the agent era](https://var.gg/en/blog/git-tools-in-the-agent-era) — the smarter an abstraction gets, the more you owe it to yourself to run the thing underneath at least once and see what actually turns.) The AI SDK 6 \"agent,\" fortunately, was open enough to inspect all the way through without calling a model even once.\n\n*Originally published at var.gg.*", "url": "https://wpnews.pro/news/vercel-ai-sdk-6-an-agent-is-just-a-while-loop", "canonical_source": "https://dev.to/curioustore_48788631d0e2e/vercel-ai-sdk-6-an-agent-is-just-a-while-loop-4kf3", "published_at": "2026-07-10 14:38:28+00:00", "updated_at": "2026-07-10 14:43:54.484282+00:00", "lang": "en", "topics": ["ai-agents", "developer-tools", "large-language-models", "ai-tools", "ai-infrastructure"], "entities": ["Vercel", "AI SDK 6", "OpenAI", "Anthropic", "Google"], "alternates": {"html": "https://wpnews.pro/news/vercel-ai-sdk-6-an-agent-is-just-a-while-loop", "markdown": "https://wpnews.pro/news/vercel-ai-sdk-6-an-agent-is-just-a-while-loop.md", "text": "https://wpnews.pro/news/vercel-ai-sdk-6-an-agent-is-just-a-while-loop.txt", "jsonld": "https://wpnews.pro/news/vercel-ai-sdk-6-an-agent-is-just-a-while-loop.jsonld"}}