Tutorials
Ren Okabe9 min read7 views

generateText defaults to one step, so your agent returns an empty string

On ai 7.0.116, generateText and streamText run exactly one step by default. Pass tools without stopWhen and the model calls a tool, your tool runs, and you get an empty string with no error. The same package defaults ToolLoopAgent to twenty. Measured on the wire, with the bundle lines that cause it.

Updated on September 26, 2026

Flat printed schematic: two horizontal tracks both start from a filled node on the left, one short and one running through a long row of small squares, and each is terminated by a heavy amber bar. A large hollow rectangle sits alone on the right with no line reaching it.
Flat printed schematic: two horizontal tracks both start from a filled node on the left, one short and one running through a long row of small squares, and each is terminated by a heavy amber bar. A large hollow rectangle sits alone on the right with no line reaching it.
On this page

Vercel Anthropic Node.js Zod TypeScript

Quick answer

As of September 2026, on ai 7.0.116, generateText and streamText run exactly ONE step by default. If you pass tools and do not set stopWhen, the model calls a tool, your tool executes, the loop stops before the result is ever sent back, and you get result.text === '' with no error, no exception and no warning.

The same package ships a second, different default: the ToolLoopAgent class defaults to twenty steps. So there are two defaults in one library that differ by 20x, and they are widely reported the wrong way round. The one-line detection is result.finishReason === 'tool-calls'.

The reproduction, in about a minute

No API key is needed. A small local HTTP server stands in for the provider, so every number below comes off the wire rather than out of a docstring.

bash
mkdir ai-step-probe && cd ai-step-probe
npm init -y
npm install --save-exact ai@7.0.116 @ai-sdk/anthropic@4.0.65 zod@4.6.5

Measured on Node 24.8.0.

The stand-in provider

This server speaks just enough of the Anthropic Messages shape for the provider package to parse it. It is deliberately stateless: it decides what to return purely from the request in front of it, by counting how many tool_result blocks the caller has already sent. A harness that carries state between runs quietly invalidates every run after the first.

javascript
const http = require('http');
const fs = require('fs');

const PORT = 8811;
const LOG = 'requests.jsonl';

function countToolResults(messages) {
  let n = 0;
  for (const m of messages) {
    if (!Array.isArray(m.content)) continue;
    for (const block of m.content) {
      if (block.type === 'tool_result') n += 1;
    }
  }
  return n;
}

const server = http.createServer(function (req, res) {
  let raw = '';
  req.on('data', function (chunk) { raw += chunk; });
  req.on('end', function () {
    const body = JSON.parse(raw);
    const arm = body.system && body.system[0] ? body.system[0].text : 'unknown';
    const answered = countToolResults(body.messages || []);
    const willFinish = arm.indexOf('finishes') !== -1 && answered !== 0;

    fs.appendFileSync(LOG, JSON.stringify({ arm: arm, request_bytes: Buffer.byteLength(raw) }) + '\n');

    const content = willFinish
      ? [{ type: 'text', text: 'Your balance is 42 credits.' }]
      : [{ type: 'tool_use', id: 'toolu_' + answered, name: 'lookup', input: { q: 'step' + answered } }];

    res.writeHead(200, { 'content-type': 'application/json' });
    res.end(JSON.stringify({
      id: 'msg_' + answered,
      type: 'message',
      role: 'assistant',
      model: 'claude-probe',
      content: content,
      stop_reason: willFinish ? 'end_turn' : 'tool_use',
      stop_sequence: null,
      usage: { input_tokens: 100, output_tokens: 10 }
    }));
  });
});

server.listen(PORT, function () {
  fs.writeFileSync('server.pid', String(process.pid));
  console.log('capture server listening on ' + PORT);
});

Save it as capture-server.js. The tool_use and tool_result round trip it imitates is the one described in Anthropic's tool use overview: the model answers with a tool_use block, you send the outcome back in a tool_result block, and the model continues.

The four cases

javascript
import { generateText, stepCountIs, tool, ToolLoopAgent } from 'ai';
import { createAnthropic } from '@ai-sdk/anthropic';
import { readFileSync } from 'node:fs';
import { z } from 'zod';

const anthropic = createAnthropic({
  apiKey: 'not-a-real-key',
  baseURL: 'http://127.0.0.1:8811/v1'
});

const runs = [];

function makeTool(log) {
  return tool({
    description: 'Look up an account balance',
    inputSchema: z.object({ q: z.string() }),
    execute: async function (input) {
      log.push(input.q);
      return { found: true, q: input.q };
    }
  });
}

function report(label, arm, result, log) {
  runs.push({
    case: label,
    steps: result.steps.length,
    tool_executions: log.length,
    text: result.text,
    finishReason: result.finishReason
  });
}

// A. generateText, tools, no stopWhen. The model is willing to answer on step 2.
const logA = [];
report('A generateText, no stopWhen', 'A-finishes',
  await generateText({
    model: anthropic('claude-probe'),
    system: 'A-finishes',
    prompt: 'What is my balance?',
    tools: { lookup: makeTool(logA) }
  }), logA);

// B. Identical, except stopWhen is set explicitly.
const logB = [];
report('B generateText, stopWhen stepCountIs(5)', 'B-finishes',
  await generateText({
    model: anthropic('claude-probe'),
    system: 'B-finishes',
    prompt: 'What is my balance?',
    tools: { lookup: makeTool(logB) },
    stopWhen: stepCountIs(5)
  }), logB);

// C. A model that never stops asking for tools, capped at 3.
const logC = [];
report('C generateText, stopWhen stepCountIs(3), model never stops', 'C-loops',
  await generateText({
    model: anthropic('claude-probe'),
    system: 'C-loops',
    prompt: 'What is my balance?',
    tools: { lookup: makeTool(logC) },
    stopWhen: stepCountIs(3)
  }), logC);

// D. The Agent class, no stopWhen, same never-stopping model.
const logD = [];
const agent = new ToolLoopAgent({
  model: anthropic('claude-probe'),
  system: 'D-loops',
  tools: { lookup: makeTool(logD) }
});
report('D ToolLoopAgent, no stopWhen', 'D-loops',
  await agent.generate({ prompt: 'What is my balance?' }), logD);

const requests = readFileSync('requests.jsonl', 'utf8').trim().split('\n').map(JSON.parse);
for (const r of runs) {
  const prefix = r.case.slice(0, 1);
  r.http_requests = requests.filter(function (q) { return q.arm.indexOf(prefix + '-') === 0; }).length;
}

console.log(JSON.stringify(runs, null, 2));

Save it as probe.mjs. Start the server, then run the probe:

bash
node capture-server.js &
node probe.mjs

What comes back

json
[
  {
    "case": "A generateText, no stopWhen",
    "steps": 1,
    "tool_executions": 1,
    "text": "",
    "finishReason": "tool-calls",
    "http_requests": 1
  },
  {
    "case": "B generateText, stopWhen stepCountIs(5)",
    "steps": 2,
    "tool_executions": 1,
    "text": "Your balance is 42 credits.",
    "finishReason": "stop",
    "http_requests": 2
  },
  {
    "case": "C generateText, stopWhen stepCountIs(3), model never stops",
    "steps": 3,
    "tool_executions": 3,
    "text": "",
    "finishReason": "tool-calls",
    "http_requests": 3
  },
  {
    "case": "D ToolLoopAgent, no stopWhen",
    "steps": 20,
    "tool_executions": 20,
    "text": "",
    "finishReason": "tool-calls",
    "http_requests": 20
  }
]

Case A and case B are the same call with the same model behaviour. The only difference is that B sets stopWhen. A returns an empty string; B returns the answer.

Finding 1: the default really is one step

The default is not a rounding error or a version quirk. It is written into the function signature. In ai@7.0.116, at node_modules/ai/dist/index.js:

javascript
// generateText is declared at line 5987; this default is line 6000
async function generateText({
  ...
  stopWhen = isStepCount(1),

// streamText is declared at line 9818; this default is line 9832
function streamText({
  ...
  stopWhen = isStepCount(1),

And isStepCount is a strict equality test on the number of completed steps. The function takes a count and returns a predicate that is true exactly when steps.length === stepCount. Note the strict equality rather than an at-least comparison: the condition is checked once per lap, so it matches on the lap where the count lands on the number, and never afterwards.

One step means one model generation. If that single generation is a tool call rather than text, the loop has already used its whole budget, so there is no second generation in which the model could read the tool result and answer.

Finding 2: there are two defaults, and they differ by 20x

The same bundle contains a third, different default. The class is declared at line 11826 and sets its own stop condition at line 11868:

javascript
var ToolLoopAgent = class {
  ...
      stopWhen: this.settings.stopWhen ?? isStepCount(20),

Grepping the whole bundle for a stopWhen default returns exactly three hits: two functions defaulting to one step, and the agent class defaulting to twenty.

This is not new, and it is not drifting. Unpacking the published tarballs and grepping each one gives the same split across the entire 7.x line:

Scroll to see more

versiongenerateText / streamTextToolLoopAgent
7.0.0isStepCount(1)isStepCount(20)
7.0.50isStepCount(1)isStepCount(20)
7.0.100isStepCount(1)isStepCount(20)
7.0.113isStepCount(1)isStepCount(20)
7.0.116isStepCount(1)isStepCount(20)

Only the transpilation style changed along the way. The behaviour did not.

That 20x gap is the reason this is confusing rather than merely undocumented. Both numbers are real, both are correct about something, and which one applies depends entirely on whether you called a function or constructed an agent.

Finding 3: the documented default is the other one

The vendor page that teaches this feature is Tool Calling in AI SDK Core. Its section is headed "Multi-Step Calls (using stopWhen)" and opens, verbatim:

Vercel's documentation, verbatim: "With the stopWhen setting, you can enable multi-step calls in generateText and streamText. When stopWhen is set and the model generates a tool call, the AI SDK will trigger a new generation passing in the tool result until there are no further tool calls or the stopping condition is met."

That is an accurate description of the intent. Four sentences later, in the list of built-in stopping conditions on that same page, comes:

Vercel's documentation, verbatim: "isStepCount(count) stops after a specified number of steps (default: isStepCount(20))"

Measured on that page as it stands in September 2026: 55,545 visible characters, stopWhen 9 times in the visible text, isStepCount 7 times, finishReason twice, isStepCount(1) zero times and the phrase "empty string" zero times. The stated default in the generateText and streamText section is the agent class's twenty, and the one-step default those two functions actually use is not printed anywhere on the page.

The vendor's separate agents overview does not resolve it either: stopWhen appears four times in the raw markup and once in the visible text, and the strings "20 steps", "By default" and "finishReason" appear zero times.

I am not claiming anyone was careless. I am pointing out that a reader who checks the documentation gets the wrong number for the function they are calling, which is exactly why the two defaults turn up swapped in community write-ups in both directions.

Finding 4: the tool still runs, you just never see the result

Look again at case C. Three steps, three tool executions, three HTTP requests.

The third execution produced a value. Sending that value to the model would have required a fourth request. There was no fourth request. So the tool ran, its side effect happened, its result was computed and stored, and the model never saw it.

The reason is visible in the loop itself. The while clause begins at line 6780 and the stop-condition call sits at line 6784:

javascript
} while (
  // Continue only after all client tool calls have been executed or denied,
  // and if there are client results or pending deferred provider results.
  ... two clauses omitted, both testing that this step produced tool work ...
  // continue until a stop condition is met:
  !await isStopConditionMet({ stopConditions, steps })
);

The two omitted clauses check that every client tool call in the step has produced an output or a denial, and that the step actually contained tool work. The comments are the vendor's own.

It is a do ... while loop, and the stop condition sits in the while clause. The body, which includes executing your tools, always runs to completion first. The condition is then evaluated to decide whether to go round again. So the final step's tool always executes, and whether anyone ever reads its output depends on whether the loop continues.

For a read-only lookup that is merely wasteful. For a tool that sends an email, charges a card, files a ticket or posts to a webhook, the action happens and the agent then returns an empty string as if nothing did.

This matters most in case D, where the default is twenty: the agent executed the tool twenty times and still returned ''. A larger default does not prevent this failure. It makes it twenty times more expensive.

How to detect it

There is no exception to catch. result.warnings is an empty array on a run that was cut off. The only signal is finishReason.

javascript
import { generateText, stepCountIs, isStepCount, tool } from 'ai';
import { createAnthropic } from '@ai-sdk/anthropic';
import { z } from 'zod';

const anthropic = createAnthropic({
  apiKey: 'not-a-real-key',
  baseURL: 'http://127.0.0.1:8811/v1'
});

const result = await generateText({
  model: anthropic('claude-probe'),
  system: 'E-loops',
  prompt: 'What is my balance?',
  tools: {
    lookup: tool({
      description: 'Look up an account balance',
      inputSchema: z.object({ q: z.string() }),
      execute: async function (input) { return { found: true, q: input.q }; }
    })
  },
  stopWhen: stepCountIs(2)
});

function wasCutOff(r) {
  return r.finishReason === 'tool-calls';
}

console.log(JSON.stringify({
  stepCountIs_is_the_same_function_as_isStepCount: stepCountIs === isStepCount,
  wasCutOff: wasCutOff(result),
  finishReason: result.finishReason,
  text_length: result.text.length,
  response_message_roles: result.response.messages.map(function (m) { return m.role; }),
  last_response_message: result.response.messages[result.response.messages.length - 1]
}, null, 2));

Save it as inspect.mjs and run it against the same server:

json
{
  "stepCountIs_is_the_same_function_as_isStepCount": true,
  "wasCutOff": true,
  "finishReason": "tool-calls",
  "text_length": 0,
  "response_message_roles": [
    "assistant",
    "tool"
  ],
  "last_response_message": {
    "role": "tool",
    "content": [
      {
        "type": "tool-result",
        "toolCallId": "toolu_1",
        "toolName": "lookup",
        "output": {
          "type": "json",
          "value": {
            "found": true,
            "q": "step1"
          }
        }
      }
    ]
  }
}

finishReason is 'stop' when the model chose to end its turn, and 'tool-calls' when the loop was cut off with a tool call outstanding. Treat 'tool-calls' as a failure in any code path that expects an answer.

What I checked and found was NOT a problem

Three things I expected to be wrong and measured as fine. They are worth recording so nobody chases them.

stepCountIs and isStepCount are the same function. Not two similar helpers with different semantics. The bundle exports one and aliases the other, and the identity check above returns true. If you have both names in one codebase, that is a style inconsistency and nothing more.

The result object is safe to resume from. After a cut-off run, result.response.messages ends with an assistant message carrying the tool call and a tool message carrying its result, correctly paired. Nothing is orphaned. You can append those to your history and call again to pick up exactly where the budget ran out, which is the cheapest fix when a run legitimately needs more steps than you allowed.

It is not an Anthropic-provider quirk. The step budget is enforced in core ai, in the loop shown above, before any provider is involved. Changing providers will not change this, and there is no provider option that adjusts it.

What to do

Always set stopWhen explicitly whenever you pass tools. Not because twenty is better than one, but because neither number should be arriving by accident:

javascript
const result = await generateText({
  model: anthropic('claude-sonnet-4-5'),
  prompt: 'What is my balance?',
  tools: { lookup: lookupTool },
  stopWhen: stepCountIs(8)
});

Then check finishReason before you trust result.text. An empty string is a valid answer to almost nothing:

javascript
if (result.finishReason === 'tool-calls') {
  // The budget ran out with work outstanding. Resume, raise the cap,
  // or surface a real failure. Do not return result.text to a user.
}

Make tools with side effects idempotent, or gate them. The final step's tool executes whether or not anything will ever read its result. If a tool sends, charges or writes, it needs to tolerate being called on a step whose output is discarded.

Pick the cap from the work, not from a default. Count the tool calls your task genuinely needs, add headroom, and set that number. A cap of one is almost never right when tools are present; a cap of twenty is a cost ceiling, not a plan.

If you want to see requests on the wire yourself, the capture-server technique used here is written up in full in how to see the exact request your AI agent sends, without an API key. Two other measured surprises in the same package: toModelOutput is not called when your tool throws, and @ai-sdk/anthropic drops your temperature when the model id contains claude-.

The loop itself, including the do ... while quoted above, is open source and readable in the vercel/ai repository.

Ren Okabe

Written by

Ren Okabe

Ren builds and breaks agent runtimes. He writes about what SDKs actually put on the wire, measured rather than assumed.

Frequently asked questions

Why is result.text an empty string when my AI SDK tool works fine?

Because generateText and streamText default to stopWhen: isStepCount(1), which allows exactly one model generation. If that generation is a tool call, the loop has spent its whole budget before the model ever sees the tool result, so there is no second generation in which it could write an answer. Set stopWhen explicitly, for example stepCountIs(8).

Does the AI SDK throw an error or log a warning when the step budget runs out?

No. Measured on ai 7.0.116, a run that is cut off mid tool loop returns normally, result.warnings is an empty array, and nothing is written to the console. The only signal is result.finishReason, which is 'tool-calls' when the loop was cut off and 'stop' when the model chose to end its turn.

What is the difference between stepCountIs and isStepCount?

Nothing. They are the same function. The ai bundle defines isStepCount and exports it under both names, and a strict identity check of stepCountIs === isStepCount returns true at runtime. Using both names in one codebase is a style inconsistency, not a behaviour difference.

Does ToolLoopAgent default to 20 steps or 1 step?

Twenty. ToolLoopAgent sets stopWhen to isStepCount(20) when you do not supply one, while generateText and streamText default to isStepCount(1). That split has been stable across the whole 7.x line, from 7.0.0 through 7.0.116. The two numbers are frequently reported the wrong way round.

Does my tool still execute on the final step if the loop is about to stop?

Yes. The loop is a do while, and the stop condition is evaluated in the while clause after the body has already run. So the last step's tool executes, its result is computed and stored in result.toolResults, and then the loop ends without ever sending that result to the model. Any side effect the tool performs still happens.

Can I resume a run that was cut off by the step budget?

Yes. result.response.messages ends with an assistant message carrying the outstanding tool call and a tool message carrying its result, and the toolCallId matches across the pair. Nothing is orphaned, so you can append those messages to your history and call the model again to continue from where the budget ran out.

Is this specific to the Anthropic provider?

No. The step budget is enforced inside the core ai package, in the generation loop, before any provider package is involved. Switching providers does not change it and no provider option adjusts it.