Agent frameworks
Ren Okabe12 min read205 views

How to test maxIter in AgentKit: 0 means one call, or no limit

On @inngest/agent-kit 0.13.2 the option maxIter exists on both Agent.run() and createNetwork(), both default to 0, and 0 means exactly one inference in the first and no limit at all in the second. A harness that measures both with no API key.

Flat printed schematic on a dark charcoal background, drawn in thin muted slate-blue lines. On the left a small square outline with a short arrow leaving it. On the right a large spiral of several turns carrying small square nodes, and beside it a vertical bar with one node. No text.
Flat printed schematic on a dark charcoal background, drawn in thin muted slate-blue lines. On the left a small square outline with a short arrow leaving it. On the right a large spiral of several turns carrying small square nodes, and beside it a vertical bar with one node. No text.
On this page

Quick Answer (October 2026). On @inngest/agent-kit 0.13.2 the option maxIter exists in two places and the value 0 means the opposite thing in each. On Agent.run() it defaults to 0, and 0 there means exactly one inference: your tools execute, their results are recorded, and the model is never asked again, so you get back a tool-call turn and no answer. On createNetwork() it also defaults to 0, and 0 there means no limit at all, so a network whose router keeps returning an agent will loop until something outside it intervenes. The AgentKit docs mention maxIter thirteen times, every one of them about networks, and never state either default. Below is a harness that measures both on your own install, with no API key and nothing calling a real model.

TypeScript Node.js Zod npm GitHub

Note on naming: this is Inngest's @inngest/agent-kit, not OpenAI's product of a similar name. Everything here is measured against the npm package.

The two loops

Both loops live in the compiled dist of the published package. Described rather than quoted, because this article contains no angle brackets anywhere and both predicates are comparisons:

  • The agent loop. Agent.run() destructures its options with maxIter = 0 as the default. Its body is a do-while: run the lifecycle hook, call performInference, work out whether there are more actions, increment iter, and repeat only while there are more actions and iter is still below maxIter. A do-while always executes once. After the first pass iter is 1, and 1 is not below 0, so with the default the loop ends there no matter what the model asked for.
  • The network loop. NetworkRun repeats while its stack is non-empty and either maxIter is exactly 0 or its _counter is still below maxIter. That first disjunct is the whole story: 0 is a sentinel meaning no cap.

Two more facts make the pairing deliberate rather than accidental. The network passes maxIter: 0 explicitly to every agent it runs, which is coherent, because in a network the network is the loop and the agent should do one turn at a time. And performInference really does invoke your tools before the loop condition is tested, attaching the results to result.toolCalls. So the tools run either way. The only question is whether anyone shows the model what they returned.

The type declarations tell the same story. Network.maxIter is a required number carrying a doc comment. Agent.RunOptions.maxIter is an optional number with no comment at all, sitting between state and streaming, both of which do have one.

What the docs say, and what they leave out

I fetched the whole AgentKit documentation corpus in one request, from llms-full.txt at agentkit.inngest.com, and counted. maxIter appears 13 times. All 13 are about networks. Zero sit in an agent-only context, and the createAgent reference lists no maxIter among its options.

What the docs do say is accurate:

On the purpose of the option, verbatim: A Network can specify an optional maxIter setting to limit the number of iterations.

On why you want it, verbatim: Specifying a maxIter option is useful when using a Default Routing Agent or a Hybrid Router to avoid infinite loops. And: A Routing Agent or Hybrid Router rely on LLM calls to make decisions, which means that they can sometimes fail to identify a final condition.

Both quotes are the words with inline-code and link markup removed; nothing else is changed.

That is a clear statement that maxIter is the thing standing between you and an unbounded loop. What it never says is that the option defaults to 0, or that 0 is the one value that turns the protection off. The words unlimited and no limit appear zero times in the whole corpus.

Other frameworks document the same idea the other way round. The OpenAI Agents SDK for JavaScript documents a maxTurns with a default of 10, describes it as a safety limit that throws MaxTurnsExceededError when reached, and reserves a separate sentinel for switching it off: Pass null to disable the limit. That is the same design decision made the other way. A cap whose off-switch is null cannot be disabled by forgetting to set it; a cap whose off-switch is 0 is disabled by its own default. I did not run that SDK for this article, so treat those as quotes from its documentation rather than as measurements.

Build the harness

Nothing here calls a model, so no API key is involved. A local HTTP server speaks just enough of the OpenAI chat-completions shape for AgentKit to talk to it, records every request, and decides what to answer based on what it was sent. That makes the measurement the exact traffic AgentKit would have produced.

bash
mkdir agentkit-maxiter && cd agentkit-maxiter
npm init -y
npm install --save-exact @inngest/agent-kit@0.13.2

Save this as server.js:

js
// Local capture server. Speaks just enough of the OpenAI chat-completions
// shape to drive @inngest/agent-kit. No API key, no egress, no model.
const http = require('http');

const PORT = Number(process.argv[2] || 8899);
// Hard circuit breaker. An UNCAPPED network run can only be stopped from
// outside, so the server is the thing that stops it.
const BREAK_AT = Number(process.argv[3] || 0);

const log = [];

function turn(n, kind) {
  const msg = kind === 'tool'
    ? {
        role: 'assistant',
        content: null,
        tool_calls: [{
          id: 'call_' + n,
          type: 'function',
          function: { name: 'get_build_id', arguments: '{}' }
        }]
      }
    : { role: 'assistant', content: 'FINAL ANSWER ' + n };
  return {
    id: 'chatcmpl-' + n,
    object: 'chat.completion',
    created: 1,
    model: 'stub-model',
    choices: [{
      index: 0,
      message: msg,
      finish_reason: kind === 'tool' ? 'tool_calls' : 'stop'
    }],
    usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }
  };
}

const server = http.createServer(function (req, res) {
  let raw = '';
  req.on('data', function (c) { raw += c; });
  req.on('end', function () {
    if (req.url === '/__log') {
      res.writeHead(200, { 'Content-Type': 'application/json' });
      res.end(JSON.stringify(log));
      return;
    }
    if (req.url === '/__reset') {
      log.length = 0;
      res.writeHead(200, { 'Content-Type': 'application/json' });
      res.end('{"ok":true}');
      return;
    }

    let body = null;
    try { body = JSON.parse(raw); } catch (e) { body = { parseError: String(e) }; }
    log.push({ auth: req.headers.authorization || null, body: body });
    const n = log.length;

    // Math.max(n, BREAK_AT) === n is true exactly when n has reached
    // BREAK_AT. Written this way so the file contains no angle brackets.
    if (BREAK_AT !== 0 && Math.max(n, BREAK_AT) === n) {
      res.writeHead(500, { 'Content-Type': 'application/json' });
      res.end(JSON.stringify({ error: { message: 'CIRCUIT_BREAKER after ' + n + ' model calls' } }));
      return;
    }

    const msgs = (body && body.messages) || [];
    const tools = (body && body.tools) || [];
    let sawToolResult = false;
    for (const m of msgs) {
      if (m && m.role === 'tool') { sawToolResult = true; }
    }

    // No tools offered, or the tool result came back: answer in words.
    // Otherwise ask for the tool. Whether the caller ever returns a tool
    // result is exactly what the probe measures.
    const kind = (tools.length === 0 || sawToolResult) ? 'text' : 'tool';
    res.writeHead(200, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify(turn(n, kind)));
  });
});

server.listen(PORT, '127.0.0.1', function () {
  console.log('CAPTURE_SERVER_READY port=' + PORT + ' break_at=' + BREAK_AT);
});

Two details in there are load-bearing. The stub answers in words when no tools were offered, or when it can see a tool result in the messages; otherwise it asks for the tool. And BREAK_AT is a circuit breaker, because an uncapped run cannot stop itself, so the server has to be the thing that stops it.

Save this as probe.js:

js
// What maxIter: 0 means in two different places in @inngest/agent-kit 0.13.2.
// Nothing here calls a real model. Every inference goes to a local capture
// server; the measurement is how many times it is called, and what each
// request body contained.
const fs = require('fs');
const path = require('path');
const {
  createAgent, createNetwork, createTool, openai
} = require('@inngest/agent-kit');
const { z } = require('zod');

// Deliberately NOT process.env.PORT. Plenty of environments already export
// PORT for their own web server, and reading it here silently sends every
// inference to that server instead. Namespace the variable.
const PORT = Number(process.env.AK_CAPTURE_PORT || 8899);
const BASE = 'http://127.0.0.1:' + PORT + '/v1/';
const ROOT = 'http://127.0.0.1:' + PORT;

const lines = [];
function say(s) { lines.push(s); }
function flush(code) {
  fs.writeSync(1, lines.join('\n') + '\n');
  process.exit(code);
}

async function serverLog() {
  const r = await fetch(ROOT + '/__log');
  return r.json();
}
async function serverReset() {
  const r = await fetch(ROOT + '/__reset');
  return r.text();
}

let toolRuns = 0;
const buildIdTool = createTool({
  name: 'get_build_id',
  description: 'Return the current build id',
  parameters: z.object({}),
  handler: async function () {
    toolRuns = toolRuns + 1;
    return { build_id: 'BUILD-7781' };
  }
});

// This package declares only two export subpaths, so requiring its
// package.json throws ERR_PACKAGE_PATH_NOT_EXPORTED. Walk up from the
// resolved entry point instead, so the version is measured not asserted.
function pkgVersion(name) {
  let d = path.dirname(require.resolve(name));
  for (let i = 0; i !== 6; i = i + 1) {
    const c = path.join(d, 'package.json');
    if (fs.existsSync(c)) {
      const j = JSON.parse(fs.readFileSync(c, 'utf8'));
      if (j.name === name) { return j.version; }
    }
    d = path.dirname(d);
  }
  return 'unknown';
}

function model() {
  return openai({ model: 'stub-model', baseUrl: BASE, apiKey: 'not-a-real-key' });
}

function agentWithTool(name) {
  return createAgent({
    name: name,
    system: 'Call get_build_id, then report the build id.',
    model: model(),
    tools: [buildIdTool]
  });
}

function describe(result) {
  const out = (result && result.output) || [];
  const kinds = out.map(function (m) { return m.type + '/' + m.stop_reason; });
  let text = null;
  for (const m of out) {
    if (m.type === 'text') { text = String(m.content); }
  }
  const tc = (result && result.toolCalls) || [];
  return { outputKinds: kinds, finalText: text, toolResultRecords: tc.length };
}

// For each request the stub received: the message roles, whether an assistant
// tool_calls message was present, and whether a tool result message followed.
function wireLine(entry, i) {
  const msgs = (entry.body && entry.body.messages) || [];
  let hasToolMsg = false;
  let hasAsstToolCalls = false;
  for (const m of msgs) {
    if (m.role === 'tool') { hasToolMsg = true; }
    if (m.role === 'assistant' && m.tool_calls) { hasAsstToolCalls = true; }
  }
  const roles = msgs.map(function (m) { return m.role; }).join(',');
  return 'call ' + (i + 1) + ': [' + roles + ']'
    + '  assistant_tool_calls=' + (hasAsstToolCalls ? 'yes' : 'no')
    + '  tool_result_msg=' + (hasToolMsg ? 'yes' : 'no');
}

// Long runs are collapsed so the transcript stays readable.
function wireReport(log) {
  const out = [];
  if (log.length === 0) { return ['    (no requests reached the stub)']; }
  const show = function (i) { out.push('    ' + wireLine(log[i], i)); };
  if (Math.max(log.length, 7) === 7) {   // i.e. seven calls or fewer
    for (let i = 0; i !== log.length; i = i + 1) { show(i); }
  } else {
    show(0); show(1); show(2);
    out.push('    ... ' + (log.length - 4) + ' further calls, same shape ...');
    show(log.length - 1);
  }
  return out;
}

async function arm(label, fn) {
  let log = [];
  let err = null;
  let result = null;
  try {
    await serverReset();
    toolRuns = 0;
    result = await fn();
  } catch (e) {
    err = String((e && e.message) || e);
  }
  try { log = await serverLog(); } catch (e) { log = []; }
  say('===== ' + label + ' =====');
  say('  model calls:      ' + log.length);
  say('  tool handler ran: ' + toolRuns);
  if (err) { say('  threw:            ' + err.slice(0, 160)); }
  if (result && result.output) { say('  result:           ' + JSON.stringify(describe(result))); }
  say('  wire:');
  for (const l of wireReport(log)) { say(l); }
  say('');
}

async function main() {
  say('@inngest/agent-kit ' + pkgVersion('@inngest/agent-kit')
      + ' | zod ' + pkgVersion('zod')
      + ' | node ' + process.version);
  say('');

  // A1: agent with a tool, run standalone, maxIter option OMITTED.
  await arm('A1 agent, maxIter omitted (the default)', function () {
    return agentWithTool('a1').run('What is the build id?');
  });

  // A2: same agent, maxIter: 2. The only documented knob.
  await arm('A2 agent, maxIter: 2', function () {
    return agentWithTool('a2').run('What is the build id?', { maxIter: 2 });
  });

  // A3 CONTROL: agent with NO tools. If this also made one call and returned
  // no text, the harness would be the explanation rather than maxIter.
  await arm('A3 CONTROL agent, no tools, maxIter omitted', function () {
    return createAgent({
      name: 'a3',
      system: 'Answer in one word.',
      model: model()
    }).run('Say done.');
  });

  // N1: network with maxIter OMITTED and a router that always returns the
  // agent. The capture server circuit breaker is the only thing that can
  // end this run.
  await arm('N1 network, maxIter omitted, router never stops', function () {
    const net = createNetwork({
      name: 'n1',
      agents: [agentWithTool('n1a')],
      defaultModel: model(),
      router: function (opts) { return [opts.network.agents.get('n1a')]; }
    });
    return net.run('go');
  });

  // N2: identical network with maxIter SET EXPLICITLY to 0. This separates
  // "the option was omitted" from "the value 0 means no limit".
  await arm('N2 network, maxIter: 0 set explicitly, same router', function () {
    const net = createNetwork({
      name: 'n2b',
      agents: [agentWithTool('n2ba')],
      defaultModel: model(),
      maxIter: 0,
      router: function (opts) { return [opts.network.agents.get('n2ba')]; }
    });
    return net.run('go');
  });

  // N3: identical network, maxIter: 3.
  await arm('N3 network, maxIter: 3, same router', function () {
    const net = createNetwork({
      name: 'n3',
      agents: [agentWithTool('n3a')],
      defaultModel: model(),
      maxIter: 3,
      router: function (opts) { return [opts.network.agents.get('n3a')]; }
    });
    return net.run('go');
  });
}

main().then(function () { flush(0); }, function (e) {
  say('PROBE FAILED: ' + ((e && e.stack) || e));
  flush(1);
});

Then run it:

bash
# terminal 1: the capture server, with a circuit breaker at 12 model calls
node server.js 8899 12

# terminal 2: the probe
node probe.js

What it prints

text
@inngest/agent-kit 0.13.2 | zod 4.6.5 | node v24.8.0

===== A1 agent, maxIter omitted (the default) =====
  model calls:      1
  tool handler ran: 1
  result:           {"outputKinds":["tool_call/tool"],"finalText":null,"toolResultRecords":1}
  wire:
    call 1: [system,user]  assistant_tool_calls=no  tool_result_msg=no

===== A2 agent, maxIter: 2 =====
  model calls:      2
  tool handler ran: 2
  result:           {"outputKinds":["tool_call/tool"],"finalText":null,"toolResultRecords":1}
  wire:
    call 1: [system,user]  assistant_tool_calls=no  tool_result_msg=no
    call 2: [system,user,assistant]  assistant_tool_calls=yes  tool_result_msg=no

===== A3 CONTROL agent, no tools, maxIter omitted =====
  model calls:      1
  tool handler ran: 0
  result:           {"outputKinds":["text/stop"],"finalText":"FINAL ANSWER 1","toolResultRecords":0}
  wire:
    call 1: [system,user]  assistant_tool_calls=no  tool_result_msg=no

===== N1 network, maxIter omitted, router never stops =====
  model calls:      12
  tool handler ran: 1
  threw:            CIRCUIT_BREAKER after 12 model calls
  wire:
    call 1: [system,user]  assistant_tool_calls=no  tool_result_msg=no
    call 2: [system,user,assistant,tool]  assistant_tool_calls=yes  tool_result_msg=yes
    call 3: [system,user,assistant,tool,assistant]  assistant_tool_calls=yes  tool_result_msg=yes
    ... 8 further calls, same shape ...
    call 12: [system,user,assistant,tool,assistant,assistant,assistant,assistant,assistant,assistant,assistant,assistant,assistant,assistant]  assistant_tool_calls=yes  tool_result_msg=yes

===== N2 network, maxIter: 0 set explicitly, same router =====
  model calls:      12
  tool handler ran: 1
  threw:            CIRCUIT_BREAKER after 12 model calls
  wire:
    call 1: [system,user]  assistant_tool_calls=no  tool_result_msg=no
    call 2: [system,user,assistant,tool]  assistant_tool_calls=yes  tool_result_msg=yes
    call 3: [system,user,assistant,tool,assistant]  assistant_tool_calls=yes  tool_result_msg=yes
    ... 8 further calls, same shape ...
    call 12: [system,user,assistant,tool,assistant,assistant,assistant,assistant,assistant,assistant,assistant,assistant,assistant,assistant]  assistant_tool_calls=yes  tool_result_msg=yes

===== N3 network, maxIter: 3, same router =====
  model calls:      3
  tool handler ran: 1
  wire:
    call 1: [system,user]  assistant_tool_calls=no  tool_result_msg=no
    call 2: [system,user,assistant,tool]  assistant_tool_calls=yes  tool_result_msg=yes
    call 3: [system,user,assistant,tool,assistant]  assistant_tool_calls=yes  tool_result_msg=yes

Reading the arms

A1, the default. One model call. The tool handler ran. The result carries one tool-result record and finalText is null, so the tool executed and nothing was done with what it returned. The one request the model stub saw contained only system and user. This is the default path for any standalone agent.run(), which is what most first scripts are.

A3, the control, is the arm that makes A1 mean anything. An agent with no tools, same default, also makes exactly one call, and that call comes back text/stop with a real answer. So one call is not a broken harness, a hung request or a misconfigured base URL. One call is correct behaviour; it is only a problem when a tool call needed a second one.

A2, the documented knob, does not rescue it. With maxIter: 2 there are two calls, and the second one is the interesting line: assistant_tool_calls=yes tool_result_msg=no. The follow-up request carries the assistant message that asked for the tool, and no message carrying the result. That is a malformed conversation. My stub is lenient and answers anyway, which is the only reason the request body is visible at all; a real provider rejects it, which is exactly what makes this worth measuring locally. Note also that the tool handler ran twice for one user question, so a tool with side effects fires twice.

N1 and N2 are the inversion. A network whose router always returns the agent runs to the circuit breaker at 12 calls, and it does so identically whether maxIter is omitted or set explicitly to 0. That pairing matters: it separates the option was not supplied from the value 0 means no cap, and it shows the second is true. Without N2 you could reasonably believe omission fell through to some other default.

N3 is the same network with maxIter: 3. Three calls, then it stops on its own, with no circuit breaker and no exception.

One incidental thing the wire shows: on the network path the tool result is carried forward, which is why the handler runs once rather than every iteration. The agent's own internal loop is the part that loses it.

What to do about it

The useful conclusion is not raise maxIter on your agent. It is:

  1. For a single agent that needs to use a tool and then speak, let a network do the looping. The network path preserves tool results across turns, which the agent's internal loop does not.
  2. Set maxIter explicitly on every network you create. Not because the default is a low number, but because it is no number. Pick one you are willing to pay for.
  3. If you run a standalone agent on the default, treat the result as a tool-call turn, not an answer. Read result.toolCalls yourself. That is a legitimate way to use the library and it is what the network does internally.

Prior art, because most of this is already reported

I want to be precise about what is new here and what is not.

The one-inference behaviour is already written down by someone else. Issue #276 says of maxIter: 0, verbatim: This works - tool executes, but no follow-up response. The same issue reports that raising maxIter fails on both Anthropic and OpenAI, with the OpenAI error quoted as An assistant message with 'tool_calls' must be followed by tool messages responding to each 'tool_call_id'. and the Anthropic one as messages.2: tool_use blocks can only be in assistant messages. Issue #159 separately uses maxIter: 0 as a deliberate stop-after-one-turn workaround, so this is known to experienced users.

The mechanism is also already diagnosed. Pull request #335 states: Agent.run() with maxIter greater than 0 replaced its history with only the latest model output after each iteration. and The follow-up inference request lost the earlier conversation and the results of the tools that the model had just called. Its fix is to carry the previous history, the latest output, and the latest tool results into the next iteration.

That pull request was open when I measured, and the published 0.13.2 on npm does not contain it: the history assignment in both the ESM and CJS builds still replaces history with the latest model output only, and inference.toolCalls never enters history anywhere in either build. So if you install the current release today, A2 is what you get.

What I have not found stated anywhere, in the documentation or in those issues, is the pairing: that the same option name carries opposite meanings for 0 in the two scopes, that a network created without maxIter therefore has no cap, and that this is the same option the docs present as the defence against infinite loops. That is the part worth knowing, and it is the part that survives the pull request landing, because #335 changes what the agent loop carries forward and does not change what 0 means on a network.

Limits of this measurement

Worth stating plainly:

  • The stub is lenient where real providers are strict. That is a deliberate choice, and it is why the malformed second request is observable rather than just a 400.
  • I used the OpenAI chat-completions adapter. I did not repeat the arms against the Anthropic or Gemini adapters, so I am not claiming anything about them beyond what #276 reports.
  • The circuit breaker at 12 is arbitrary. It demonstrates the absence of a cap at 12; it does not prove the absence of a cap at some much larger number. The source predicate is what supports the general claim, and the run is what confirms the predicate behaves as read.
  • Version numbers matter here. Everything is @inngest/agent-kit 0.13.2, zod 4.6.5, Node v24.8.0, measured 8 October 2026.

One harness defect is worth passing on, because it cost me a wrong answer first. My first probe read process.env.PORT. That environment already exported PORT=80 for an unrelated web server, so every inference went there and came back as an HTML login page. All five arms failed identically, including the control, which is the signature of a broken harness rather than a library bug. If your control arm is the thing that breaks, suspect yourself before the library, and never name a harness variable something the environment already uses.

If you want the same class of surprise one layer up, the AI SDK has a step budget with a default that ends the run before the model ever sees the tool result, and we measured that one separately in generateText defaults to one step.

Ren Okabe

Written by

Ren Okabe

Ren builds agent infrastructure and writes copy-paste tutorials for engineers shipping LLM tool-use systems.

Frequently asked questions

What does maxIter do in @inngest/agent-kit?

It limits iterations, but it exists in two places with different meanings for the value 0. On createNetwork() it caps how many times the network may call an agent, and 0 means no cap at all. On Agent.run() it caps the agent's own inference loop, and 0 means exactly one inference. Both default to 0. The AgentKit documentation mentions maxIter 13 times and every one of them is about networks.

Why does my AgentKit agent run the tool but never answer?

Because Agent.run() defaults to maxIter 0, which permits exactly one inference. The loop is a do-while whose condition requires the iteration counter to be below maxIter, so after the first pass the counter is 1 and 1 is not below 0. Your tool handler does execute and its output is recorded on result.toolCalls, but the model is never called a second time, so there is no natural-language answer. Measured on 0.13.2: one model call, tool handler ran once, finalText null.

Does setting maxIter: 2 on the agent fix it?

Not on 0.13.2. It produces a second model call whose request carries the assistant message that asked for the tool and no message carrying the tool result. Real providers reject that: OpenAI returns an error stating that an assistant message with tool_calls must be followed by tool messages responding to each tool_call_id, and Anthropic reports that tool_use blocks can only be in assistant messages. This is reported in issue 276 and the fix in pull request 335 was still open when this was measured.

Does a network without maxIter have a default limit?

No. The network loop continues while its stack is non-empty and either maxIter is exactly 0 or its counter is still below maxIter, so 0 is a sentinel meaning unlimited. Measured with a router that always returns the same agent, a network with maxIter omitted and a network with maxIter set explicitly to 0 both ran until an external circuit breaker stopped them at 12 model calls. Setting maxIter to 3 stopped at 3 on its own.

Is this the same as the OpenAI Agents SDK maxTurns?

It is the same idea with the opposite default. The OpenAI Agents SDK for JavaScript documents maxTurns with a default of 10, describes it as a safety limit that throws MaxTurnsExceededError when reached, and uses a separate sentinel for switching it off, instructing you to pass null to disable the limit. A cap whose off-switch is null cannot be disabled by forgetting to set it. That SDK was not run for this article; those are quotes from its documentation.

How do I test this without an API key?

Point the openai() adapter at a local HTTP server with baseUrl set to a loopback address and any placeholder apiKey, then count the requests that server receives and inspect their message arrays. Nothing calls a real model. Include a control arm with an agent that has no tools: if that arm also makes one call and returns no text, the harness is the explanation rather than maxIter. Include a circuit breaker too, because an uncapped run cannot stop itself.

What should I actually do about it?

Let a network do the looping rather than raising maxIter on a standalone agent, because the network path carries tool results forward across turns while the agent's internal loop does not. Set maxIter explicitly on every network you create, since the default is not a low number but no number. If you do run a standalone agent on the default, treat the return value as a tool-call turn rather than an answer and read result.toolCalls yourself.

Will this change when pull request 335 lands?

Partly. That pull request rebuilds the agent loop's history from the previous history, the latest output and the latest tool results, which addresses the agent-side failure when maxIter is raised. It does not change what 0 means on a network, so a network created without maxIter will still have no cap. The published 0.13.2 does not contain the fix: in both the ESM and CJS builds the history assignment still replaces history with the latest model output only.

Tutorials

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.

9 min read159
Tutorials

Your Zod .refine() never reaches the model

Measured on ai 7.0.118 and zod 4.6.5: a .refine() on a tool input schema is stripped before the schema reaches the model, with no warning. z.string().startsWith('acct_') arrives as a pattern; the same rule written as a refinement arrives as nothing.

11 min read161