ai-ml

OpenAI Agents SDK maxTurns After Approval, Tested

October 12, 2026

OpenAI Agents SDK maxTurns After Approval, Tested

TL;DR: @openai/agents 0.20.0, published October 8, 2026, counts the model call that follows an approved or rejected tool call toward maxTurns. In my case A1, one approval and then the answer, maxTurns: 1 finished on 0.19.0 and threw MaxTurnsExceededError on 0.20.0, after the approved tool had already run.123

I ran both versions with a scripted model and no API key. The same change also caps chains of approvals, which 0.19.0 did not do in my runs.43

OpenAI Agents SDK maxTurns after approval: the short answer

maxTurns is the SDK's cap on turns in a run. The default is 10, and null turns the limit off.5

On 0.19.0, the first model call after you approved or rejected a tool call was free. In 0.20.0 it counts like any other call.243

If you resume paused runs with a small maxTurns, you can now get MaxTurnsExceededError on a run that used to finish.

What you'll learn

  • What changed in @openai/agents 0.20.0 and where the maintainers say it came from
  • A scripted-model test you can run with no API key
  • Which of my 21 cases changed between 0.19.0 and 0.20.0, and which did not
  • Why the error arrives after the approved tool already ran, and how to retry safely
  • How to set maxTurns and errorHandlers for approval flows

What changed in @openai/agents 0.20.0

The @openai/agents-core 0.20.0 release notes list this under "Minor Changes":2

fix: count model calls after approval resumes toward maxTurns; callers needing a follow-up model response at the limit must increase maxTurns.

The change is pull request #2032, merged on October 5, 2026 at 17:53 UTC, about an hour after 0.19.0 appeared on npm.14 Version 0.20.0 followed on October 8.1

The pull request describes the old behavior as "the released continuation exemption." It says applications that need a follow-up model response after approval "must supply enough turns or increase maxTurns on resume."4

It also says "Pending approvals and tool-only final output can still complete at the limit."4 I tested both of those below.

A comment in the diff puts the new rule in one line: "Resolving approvals does not consume a turn, but the next model call does."4 It replaced this comment from the 0.19.0 package: "Do not advance the turn when resuming from an interruption; the next model call is still part of the same logical turn."43

The same pull request says documentation for the new behavior "will be timed separately." On the docs source I checked (main branch, commit dated October 9), the human-in-the-loop guide does not mention maxTurns.46

Reproduce it with a scripted model

You do not need an API key. @openai/agents/testing ships a ScriptedModel that replays model responses you write.

Install each version in its own folder. The core package in both versions lists zod ^4.0.0 as a peer dependency.3

mkdir v019 v020
cd v019 && npm init -y && npm i @openai/agents@0.19.0 zod@4 && cd ..
cd v020 && npm init -y && npm i @openai/agents@0.20.0 zod@4 && cd ..

Save this as turns-test.mjs and copy it into both folders. Cases A to L build a fresh agent whose only tool, pay, always needs approval. The scripted model asks for pay a set number of times, then answers "done". Cases M1 and M2 add a second tool, echo, that needs no approval.

import { z } from 'zod';
import { Agent, run, tool, Usage, RunState, setTracingDisabled } from '@openai/agents';
import { ScriptedModel, assistantMessage, functionCall, modelResponse } from '@openai/agents/testing';

setTracingDisabled(true);

// A fresh agent per case. The model calls `pay` `calls` times (each call needs approval), then answers.
function build({ calls = 1, toolUseBehavior } = {}) {
  const seen = { execs: 0 };
  const pay = tool({
    name: 'pay', description: 'Pay a vendor',
    parameters: z.object({ amount: z.number() }),
    needsApproval: true,
    execute: async () => { seen.execs++; return 'paid'; },
  });
  const steps = [];
  for (let i = 1; i <= calls; i++) {
    steps.push(modelResponse({ output: [functionCall('pay', '{"amount":5}', { callId: `call_${i}` })], usage: new Usage() }));
  }
  steps.push(modelResponse({ output: [assistantMessage('done')], usage: new Usage() }));
  const model = new ScriptedModel(steps);
  seen.model = model;
  const agent = new Agent({ name: 'payer', model, tools: [pay], toolUseBehavior });
  return { agent, seen };
}
const name = (e) => `${e.constructor.name}: ${e.message}`;
const show = (label, text, seen) => console.log(`${label.padEnd(44)} ${text}${seen ? ` | execute ran ${seen.execs}x, model calls ${seen.model.calls.length}` : ''}`);

// Resolve every pending approval (or rejection), then resume once.
async function resumeOnce(agent, first, { maxTurns, reject = false, serialize = false }) {
  for (const i of first.interruptions) reject ? first.state.reject(i) : first.state.approve(i);
  const state = serialize ? await RunState.fromString(agent, first.state.toString()) : first.state;
  return run(agent, state, { maxTurns });
}

// A1, A2. One approval, then the follow-up model call. Vary the budget.
for (const maxTurns of [1, 2]) {
  const { agent, seen } = build();
  const first = await run(agent, 'pay acme', { maxTurns });
  const paused = first.interruptions.length;
  try {
    const r = await resumeOnce(agent, first, { maxTurns });
    show(`A${maxTurns}. approve, maxTurns ${maxTurns}`, `paused=${paused}, finished: "${r.finalOutput}"`, seen);
  } catch (e) { show(`A${maxTurns}. approve, maxTurns ${maxTurns}`, `paused=${paused}, THROWS ${name(e)}`, seen); }
}

// B. A rejection also needs a follow-up model call.
{
  const { agent, seen } = build();
  const first = await run(agent, 'pay acme', { maxTurns: 1 });
  try {
    const r = await resumeOnce(agent, first, { maxTurns: 1, reject: true });
    show('B. reject, maxTurns 1', `finished: "${r.finalOutput}"`, seen);
  } catch (e) { show('B. reject, maxTurns 1', `THROWS ${name(e)}`, seen); }
}

// C1, C2. Saved to a string and restored, then resumed with a budget of 1 or 2.
for (const resumeTurns of [1, 2]) {
  const { agent, seen } = build();
  const first = await run(agent, 'pay acme', { maxTurns: 1 });
  try {
    const r = await resumeOnce(agent, first, { maxTurns: resumeTurns, serialize: true });
    show(`C${resumeTurns}. serialized, resume maxTurns ${resumeTurns}`, `finished: "${r.finalOutput}"`, seen);
  } catch (e) { show(`C${resumeTurns}. serialized, resume maxTurns ${resumeTurns}`, `THROWS ${name(e)}`, seen); }
}

// D. A model that keeps asking for approval. How many rounds fit under maxTurns 3?
for (const calls of [2, 3, 8]) {
  const { agent, seen } = build({ calls });
  let r = await run(agent, 'pay acme', { maxTurns: 3 });
  let rounds = 0;
  try {
    while (r.interruptions?.length) {
      rounds++;
      for (const i of r.interruptions) r.state.approve(i);
      r = await run(agent, r.state, { maxTurns: 3 });
    }
    show(`D. ${calls} approvals in a row, maxTurns 3`, `finished after ${rounds} approval rounds: "${r.finalOutput}"`, seen);
  } catch (e) { show(`D. ${calls} approvals in a row, maxTurns 3`, `THROWS after ${rounds} approval rounds, ${name(e)}`, seen); }
}

// E0 to E3. The budget error arrives after the approved tool already ran. Ways to retry.
async function budgetError() {
  const { agent, seen } = build();
  const first = await run(agent, 'pay acme', { maxTurns: 1 });
  for (const i of first.interruptions) first.state.approve(i);
  const beforeResume = first.state.toString();
  let err;
  try { await run(agent, first.state, { maxTurns: 1 }); } catch (e) { err = e; }
  return { agent, seen, err, beforeResume };
}
{
  const { agent, seen, err } = await budgetError();
  show('E0. resume, maxTurns 1', err ? `THROWS ${name(err)}` : 'finished', seen);
  if (err?.state) {
    const r = await run(agent, err.state, { maxTurns: 2 });
    show('E1. retry from error.state, maxTurns 2', `finished: "${r.finalOutput}"`, seen);
  }
}
{
  const { agent, seen, err, beforeResume } = await budgetError();
  if (err) {
    const restored = await RunState.fromString(agent, beforeResume);
    const r = await run(agent, restored, { maxTurns: 2 });
    show('E2. retry from pre-resume string, maxTurns 2', `finished: "${r.finalOutput}"`, seen);
  }
}
{
  const { agent, seen, err } = await budgetError();
  if (err?.state) {
    const restored = await RunState.fromString(agent, err.state.toString());
    const r = await run(agent, restored, { maxTurns: 2 });
    show('E3. retry from serialized error.state', `finished: "${r.finalOutput}"`, seen);
  }
}

// F. Tool output as the final answer: no follow-up model call is needed.
{
  const { agent, seen } = build({ toolUseBehavior: 'stop_on_first_tool' });
  const first = await run(agent, 'pay acme', { maxTurns: 1 });
  try {
    const r = await resumeOnce(agent, first, { maxTurns: 1 });
    show('F. stop_on_first_tool, maxTurns 1', `finished: "${r.finalOutput}"`, seen);
  } catch (e) { show('F. stop_on_first_tool, maxTurns 1', `THROWS ${name(e)}`, seen); }
}

// G. Streaming resume.
{
  const { agent, seen } = build();
  const first = await run(agent, 'pay acme', { maxTurns: 1 });
  for (const i of first.interruptions) first.state.approve(i);
  try {
    const stream = await run(agent, first.state, { maxTurns: 1, stream: true });
    for await (const _ of stream) { /* drain */ }
    await stream.completed;
    show('G. streaming resume, maxTurns 1', `finished: "${stream.finalOutput}"`, seen);
  } catch (e) { show('G. streaming resume, maxTurns 1', `THROWS ${name(e)}`, seen); }
}

// H. Turn the budget error into a final answer with errorHandlers.maxTurns.
{
  const { agent, seen } = build();
  const first = await run(agent, 'pay acme', { maxTurns: 1 });
  for (const i of first.interruptions) first.state.approve(i);
  let handled = 0;
  try {
    const r = await run(agent, first.state, {
      maxTurns: 1,
      errorHandlers: { maxTurns: () => { handled++; return { finalOutput: 'Paid. Turn budget used up.' }; } },
    });
    show('H. errorHandlers.maxTurns, maxTurns 1', `handler called ${handled}x, finished: "${r.finalOutput}"`, seen);
  } catch (e) { show('H. errorHandlers.maxTurns, maxTurns 1', `handler called ${handled}x, THROWS ${name(e)}`, seen); }
}

// I, J. Defaults: no maxTurns option, and maxTurns: null. The model asks for approval 12 times in a row.
for (const [label, opts] of [['I. 12 approvals, default maxTurns', {}], ['J. 12 approvals, maxTurns null', { maxTurns: null }]]) {
  const { agent, seen } = build({ calls: 12 });
  let r = await run(agent, 'pay acme', opts);
  let rounds = 0;
  try {
    while (r.interruptions?.length) {
      rounds++;
      for (const i of r.interruptions) r.state.approve(i);
      r = await run(agent, r.state, opts);
    }
    show(label, `finished after ${rounds} approval rounds`, seen);
  } catch (e) { show(label, `THROWS after ${rounds} approval rounds, ${name(e)}`, seen); }
}

// K, L. Resume without passing maxTurns. On 0.20.0 the limit saved in the state is reused, not the default of 10.
for (const [label, serialize] of [['K. resume, no maxTurns passed', false], ['L. serialized, resume, no maxTurns passed', true]]) {
  const { agent, seen } = build();
  const first = await run(agent, 'pay acme', { maxTurns: 1 });
  for (const i of first.interruptions) first.state.approve(i);
  const state = serialize ? await RunState.fromString(agent, first.state.toString()) : first.state;
  try {
    const r = await run(agent, state);
    show(label, `finished: "${r.finalOutput}"`, seen);
  } catch (e) { show(label, `THROWS ${name(e)}`, seen); }
}

// M1, M2. The saved limit also applies on 0.19.0, where the first follow-up call is free: pay, then an echo tool that needs no approval, then the answer.
for (const [label, opts] of [['M1. pay then echo, no maxTurns passed', {}], ['M2. pay then echo, maxTurns 10 passed', { maxTurns: 10 }]]) {
  const seen = { execs: 0 };
  const pay = tool({
    name: 'pay', description: 'Pay a vendor', parameters: z.object({ amount: z.number() }),
    needsApproval: true, execute: async () => { seen.execs++; return 'paid'; },
  });
  const echo = tool({ name: 'echo', description: 'Echo', parameters: z.object({}), execute: async () => 'ok' });
  const model = new ScriptedModel([
    modelResponse({ output: [functionCall('pay', '{"amount":5}', { callId: 'call_1' })], usage: new Usage() }),
    modelResponse({ output: [functionCall('echo', '{}', { callId: 'call_2' })], usage: new Usage() }),
    modelResponse({ output: [assistantMessage('done')], usage: new Usage() }),
  ]);
  seen.model = model;
  const agent = new Agent({ name: 'payer', model, tools: [pay, echo] });
  const first = await run(agent, 'pay acme', { maxTurns: 1 });
  for (const i of first.interruptions) first.state.approve(i);
  try {
    const r = await run(agent, first.state, opts);
    show(label, `finished: "${r.finalOutput}"`, seen);
  } catch (e) { show(label, `THROWS ${name(e)}`, seen); }
}

Run node turns-test.mjs in each folder. I ran each version three times and the output was identical every time.3

Results: 0.19.0 vs 0.20.0

Table comparing @openai/agents 0.19.0 and 0.20.0 across 21 maxTurns cases after an approval resume

Figure 1. The results below as a table. Red means MaxTurnsExceededError. "Tool ran" counts how many times execute ran. "Model calls" is the length of ScriptedModel.calls.

Here is the real output, 0.19.0 first. Cases E1 to E3 only run when the resume throws, so 0.19.0 prints 18 lines and 0.20.0 prints 21.

A1. approve, maxTurns 1                      paused=1, finished: "done" | execute ran 1x, model calls 2
A2. approve, maxTurns 2                      paused=1, finished: "done" | execute ran 1x, model calls 2
B. reject, maxTurns 1                        finished: "done" | execute ran 0x, model calls 2
C1. serialized, resume maxTurns 1            finished: "done" | execute ran 1x, model calls 2
C2. serialized, resume maxTurns 2            finished: "done" | execute ran 1x, model calls 2
D. 2 approvals in a row, maxTurns 3          finished after 2 approval rounds: "done" | execute ran 2x, model calls 3
D. 3 approvals in a row, maxTurns 3          finished after 3 approval rounds: "done" | execute ran 3x, model calls 4
D. 8 approvals in a row, maxTurns 3          finished after 8 approval rounds: "done" | execute ran 8x, model calls 9
E0. resume, maxTurns 1                       finished | execute ran 1x, model calls 2
F. stop_on_first_tool, maxTurns 1            finished: "paid" | execute ran 1x, model calls 1
G. streaming resume, maxTurns 1              finished: "done" | execute ran 1x, model calls 2
H. errorHandlers.maxTurns, maxTurns 1        handler called 0x, finished: "done" | execute ran 1x, model calls 2
I. 12 approvals, default maxTurns            finished after 12 approval rounds | execute ran 12x, model calls 13
J. 12 approvals, maxTurns null               finished after 12 approval rounds | execute ran 12x, model calls 13
K. resume, no maxTurns passed                finished: "done" | execute ran 1x, model calls 2
L. serialized, resume, no maxTurns passed    finished: "done" | execute ran 1x, model calls 2
M1. pay then echo, no maxTurns passed        THROWS MaxTurnsExceededError: Max turns (1) exceeded | execute ran 1x, model calls 2
M2. pay then echo, maxTurns 10 passed        finished: "done" | execute ran 1x, model calls 3

And 0.20.0:

A1. approve, maxTurns 1                      paused=1, THROWS MaxTurnsExceededError: Max turns (1) exceeded | execute ran 1x, model calls 1
A2. approve, maxTurns 2                      paused=1, finished: "done" | execute ran 1x, model calls 2
B. reject, maxTurns 1                        THROWS MaxTurnsExceededError: Max turns (1) exceeded | execute ran 0x, model calls 1
C1. serialized, resume maxTurns 1            THROWS MaxTurnsExceededError: Max turns (1) exceeded | execute ran 1x, model calls 1
C2. serialized, resume maxTurns 2            finished: "done" | execute ran 1x, model calls 2
D. 2 approvals in a row, maxTurns 3          finished after 2 approval rounds: "done" | execute ran 2x, model calls 3
D. 3 approvals in a row, maxTurns 3          THROWS after 3 approval rounds, MaxTurnsExceededError: Max turns (3) exceeded | execute ran 3x, model calls 3
D. 8 approvals in a row, maxTurns 3          THROWS after 3 approval rounds, MaxTurnsExceededError: Max turns (3) exceeded | execute ran 3x, model calls 3
E0. resume, maxTurns 1                       THROWS MaxTurnsExceededError: Max turns (1) exceeded | execute ran 1x, model calls 1
E1. retry from error.state, maxTurns 2       finished: "done" | execute ran 1x, model calls 2
E2. retry from pre-resume string, maxTurns 2 finished: "done" | execute ran 2x, model calls 2
E3. retry from serialized error.state        finished: "done" | execute ran 1x, model calls 2
F. stop_on_first_tool, maxTurns 1            finished: "paid" | execute ran 1x, model calls 1
G. streaming resume, maxTurns 1              THROWS MaxTurnsExceededError: Max turns (1) exceeded | execute ran 1x, model calls 1
H. errorHandlers.maxTurns, maxTurns 1        handler called 1x, finished: "Paid. Turn budget used up." | execute ran 1x, model calls 1
I. 12 approvals, default maxTurns            THROWS after 10 approval rounds, MaxTurnsExceededError: Max turns (10) exceeded | execute ran 10x, model calls 10
J. 12 approvals, maxTurns null               finished after 12 approval rounds | execute ran 12x, model calls 13
K. resume, no maxTurns passed                THROWS MaxTurnsExceededError: Max turns (1) exceeded | execute ran 1x, model calls 1
L. serialized, resume, no maxTurns passed    THROWS MaxTurnsExceededError: Max turns (1) exceeded | execute ran 1x, model calls 1
M1. pay then echo, no maxTurns passed        THROWS MaxTurnsExceededError: Max turns (1) exceeded | execute ran 1x, model calls 1
M2. pay then echo, maxTurns 10 passed        finished: "done" | execute ran 1x, model calls 3

The model call after an approval now costs a turn

Case A1 is the smallest reproduction. The first run() with maxTurns: 1 pauses for approval on both versions. That call is turn 1.3

After state.approve(), the resumed run executes the tool and then needs the model to write the answer. On 0.19.0 that call was not counted, and the run finished with "done." On 0.20.0 it is turn 2, and the run threw MaxTurnsExceededError: Max turns (1) exceeded.3

With maxTurns: 2 (A2) both versions finished. So the fix for a broken flow is a bigger budget, as the release note says.23

Rejections count too

Case B rejects the call with state.reject(). The tool never ran, in both versions. On 0.19.0 the run made a follow-up model call after the rejection and finished, with two model calls in total. On 0.20.0 the limit refused that call at maxTurns: 1, after one model call.43

So in these runs a rejection hit the limit the same way an approval did.

Saving and restoring the state does not change it

Cases C1 and C2 write the state with toString() and restore it with RunState.fromString() before resuming. The result matched the in-memory runs: 0.20.0 threw at maxTurns: 1 and finished at 2.3

The pull request says the new counting applies "including across serialized resumes."4 On 0.20.0, C2 finished at maxTurns: 2 even though the first run used 1, so the value I passed to the resumed run() call applied. M2 below shows the same on both versions.

A resume without maxTurns reuses the saved limit

The state remembers the limit from the first run. In cases K and L the first run used maxTurns: 1 and the resume passed no maxTurns at all.

On 0.20.0 both threw Max turns (1) exceeded. The resume reused the 1 from the first run, in memory (K) and after toString() and fromString() (L). It did not fall back to the default of 10.

On 0.19.0 K and L finished, because the follow-up call was free. They say nothing about the limit there. Cases M1 and M2 do. The model calls pay, then echo, which needs no approval, then answers, so the resume needs two model calls.

In M1 the resume passed no maxTurns, and both versions threw Max turns (1) exceeded, after two model calls on 0.19.0 and one on 0.20.0. In M2 the resume passed maxTurns: 10, and both finished.3

So a small limit set on the first call can follow a paused run into a resume that never mentions it.

Chains of approvals are now capped

Cases D, I and J use a model that asks for approval again and again.

On 0.19.0, eight approvals in a row finished under maxTurns: 3. Twelve in a row finished under the default limit of 10.3 In these runs, a loop that kept resuming through approvals never hit the limit.

On 0.20.0 under maxTurns: 3, two chained approvals finished. With three, the error came during the resume that followed the third approval. The default limit stopped the twelve-approval run after ten approved tool executions.3

On 0.20.0, each turn in these runs was one model call. The runs that threw had made exactly as many model calls as the limit: 3 under maxTurns: 3, 10 under the default.3

A chain of k approvals plus the final answer needs k + 1 model calls, so with maxTurns: 3, k can be 2.

On 0.19.0 the eight-approval chain made nine model calls under maxTurns: 3 and never threw.3

With maxTurns: null, twelve approvals finished on both versions.53

If your agent can ask for approval repeatedly, 0.20.0 now stops the loop at the limit, unless you pass null. On 0.19.0 you had to count rounds yourself.

The error arrives after the tool ran

In A1, C1, E0 and G the tool executed once and then the error was thrown. In every other throwing case, which is all of them except the rejection in B, the tool had run at least once before the error. On 0.20.0 the model had been called exactly as often as the limit allowed when the error came, for example once at maxTurns: 1 in A1, so the over-budget call was never made.3

When an approved call leads to this error, its side effect is already done. The payment has gone out, the email is sent, the deployment has started.

Case E shows three ways to retry:

  • Resume from error.state (E1). With maxTurns: 2, the run finished and the tool had run once in total.
  • Resume from error.state after toString() and fromString() (E3). Same result: finished, tool run once in total.
  • Resume from a snapshot saved before the first resume (E2). The run finished, but the tool had run twice.3

The docs say errors extend AgentsError, "which could provide the state property to access the current run state."5 In the E cases the error's state was present, since E1 and E3 only run when it is.

Save error.state and resume from it. Do not go back to the older snapshot unless the tool is idempotent.

Streaming, tool-only output and errorHandlers

Three more cases.

  • Streaming (G). The streamed resume threw the same error on 0.20.0 and finished on 0.19.0.3
  • Tool-only final output (F). With toolUseBehavior: 'stop_on_first_tool' the tool result is the answer, so no follow-up model call is needed. The run finished at maxTurns: 1 on both versions, which matches "tool-only final output can still complete at the limit."43
  • errorHandlers.maxTurns (H). The docs say errorHandlers convert supported errors into a final output instead of throwing.5 On 0.20.0 my handler ran once and the run returned its text. On 0.19.0 it never ran.3

The handler runs after the tool did. It can give the user a clean message, and it cannot undo the side effect.

How to set maxTurns for approval flows

  • On 0.20.0, budget one turn per approval round, plus one for the answer. That is what the arithmetic above gave in my runs.3
  • Pass maxTurns on every resume call. A resume that leaves it out reuses the limit saved in the state (K, L and M1).3
  • Search your code for small values. In a flow like my test, any maxTurns below the number of approval rounds plus one will now throw. Check the first call of a resumed run too, since its limit can carry over.
  • Persist error.state. Retrying from it, in memory or serialized, did not run the tool again.3
  • Make tools idempotent. Resuming an older snapshot ran the tool twice.3
  • Use errorHandlers.maxTurns for user-facing runs, so people see a plain message instead of a stack trace.5
  • On 0.19.0, count approval rounds yourself. The SDK did not stop a loop for you in my tests.3

Related reads on this site:

What I did not test

  • I used a scripted model, not a live one. A real model can take more or fewer turns than my scripts.
  • I tested one approval-gated function tool, plus a no-approval echo tool in M1 and M2. I did not run handoffs, agent.asTool() with nested approvals, Realtime, hosted MCP tools or computer tools.
  • I did not use a session or a conversationId.
  • The pull request also describes separate trace spans and usage totals for approval work and follow-up model calls. I did not inspect tracing.4
  • I did not test includeInHistory in errorHandlers.
  • I used one Node version (22.23.2) and one Zod version (4.6.6).
  • The 0.20.0 release has other changes, such as a default of 64 pages for automatic MCP tool discovery. I did not test those.2

Bottom line

On @openai/agents 0.19.0, the first model call after an approval resume was free, so a small maxTurns did not get in the way in any of my cases except M1. On 0.20.0 each follow-up model call counts, and the approved tool has already run when the limit is hit.

Check which maxTurns applies on every call that resumes a paused run, and keep the saved state from the error.

Footnotes

  1. npm registry metadata for @openai/agents (npm view @openai/agents time), read October 12, 2026: version 0.19.0 published 2026-10-05T16:48:07Z and 0.20.0 published 2026-10-08T23:04:37Z. Package page: @openai/agents on npm. ↩ ↩2 ↩3

  2. openai/openai-agents-js GitHub releases, @openai/agents-core@0.20.0, published 2026-10-08. The quoted line is change 8567e09 under "Minor Changes". The same release lists the 64-page MCP discovery default (836cb50). ↩ ↩2 ↩3 ↩4 ↩5 ↩6

  3. Author's measurement, October 12, 2026: Node 22.23.2 on Linux, @openai/agents 0.19.0 and 0.20.0 (each with the matching @openai/agents-core), Zod 4.6.6, ScriptedModel from @openai/agents/testing, tracing disabled. The core package in both versions declares zod ^4.0.0 as a peer dependency. The published script was run unchanged in fresh folders. Each version was run three times with identical output. The script prints the length of ScriptedModel.calls as the model-call count. A separate check printed the internal state._currentTurn field (not public API) after the first run and after a resume with maxTurns: 2: 1 and 1 on 0.19.0, 1 and 2 on 0.20.0, with two model responses recorded in both. Another separate check showed JSON.parse(state.toString()).maxTurns was 1 on both versions after a first run with maxTurns: 1, and that passing maxTurns: null to a restored state finished on both. The 0.19.0 comment quoted above is in dist/runner/turnPreparation.js of the @openai/agents-core 0.19.0 package. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10 ↩11 ↩12 ↩13 ↩14 ↩15 ↩16 ↩17 ↩18 ↩19 ↩20 ↩21 ↩22 ↩23 ↩24 ↩25 ↩26 ↩27 ↩28 ↩29 ↩30 ↩31 ↩32 ↩33 ↩34

  4. openai/openai-agents-js, pull request #2032, "fix: count resumed model calls toward maxTurns", opened 2026-10-05T16:45:40Z and merged 2026-10-05T17:53:51Z as commit 8567e09. Quotes are from the pull request description and from the diff (a comment in turnPreparation.ts). ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10 ↩11 ↩12 ↩13

  5. OpenAI Agents SDK for JavaScript, "Running agents" guide. Read from the source file running-agents.mdx on main (commit 89a9ddf, 2026-10-09). It gives the maxTurns default of 10 (also DEFAULT_MAX_TURNS = 10 in the source), null to disable, the errorHandlers keys, and the AgentsError state sentence. The main branch can be ahead of the 0.20.0 package. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7

  6. OpenAI Agents SDK for JavaScript, "Human in the loop" guide, source file human-in-the-loop.mdx on main (commit 89a9ddf). A case-insensitive search for "maxturns" in that file found no matches on October 12, 2026. ↩

  7. PyPI, openai-agents 0.23.1, uploaded 2026-10-02. PyPI's release list has no 0.23.0, so 0.23.1 is the first PyPI release with the change. In the package files, default_tool_error_function in agents/tool.py includes the exception text in 0.22.3 and returns a fixed message in 0.23.1. On GitHub the change is pull request #5112, "fix(tools): redact default tool failure details", merged 2026-09-20 and listed in the v0.23.0 release. The linked post measures it by comparing 0.22.3 and 0.23.1. ↩

Frequently Asked Questions

Since @openai/agents 0.20.0, the model call after an approval or rejection counts toward maxTurns. On 0.20.0, a run that paused at maxTurns: 1 and then resumed threw the error in my test, after the tool had run.23