OpenAI Agents SDK الحد الأقصى للدورات بعد الموافقة، تم اختباره
١٢ أكتوبر ٢٠٢٦

ملخص: @openai/agents 0.20.0، المنشور في 8 أكتوبر 2026، يحتسب استدعاء النموذج الذي يلي استدعاء أداة تمت الموافقة عليها أو رفضها ضمن maxTurns. في حالتي A1، موافقة واحدة ثم الإجابة، maxTurns: 1 كانت تنتهي في إصدار 0.19.0 وأصبحت تطلق MaxTurnsExceededError في إصدار 0.20.0، بعد أن تكون الأداة المعتمدة قد عملت بالفعل.123
لقد قمت بتشغيل كلا الإصدارين باستخدام نموذج مبرمج وبدون مفتاح API. هذا التغيير نفسه يضع حداً لسلاسل الموافقات، وهو ما لم يفعله إصدار 0.19.0 في تجاربي.43
OpenAI Agents SDK maxTurns بعد الموافقة: الإجابة المختصرة
maxTurns هو الحد الأقصى لعدد الأدوار (turns) في عملية التشغيل داخل الـ SDK. القيمة الافتراضية هي 10، وتؤدي قيمة null إلى إيقاف هذا الحد.5
في إصدار 0.19.0، كان استدعاء النموذج الأول بعد الموافقة على استدعاء أداة أو رفضه مجانياً. أما في إصدار 0.20.0، فهو يُحتسب مثل أي استدعاء آخر.243
إذا كنت تستأنف عمليات تشغيل متوقفة مع قيمة maxTurns صغيرة، فقد تواجه الآن خطأ MaxTurnsExceededError في عملية تشغيل كانت تنتهي بنجاح سابقاً.
ما ستتعلمه
- ما الذي تغير في
@openai/agents0.20.0 ومن أين جاء هذا التغيير حسب قول المطورين - اختبار نموذج مبرمج يمكنك تشغيله بدون مفتاح API
- أي من حالاتي الـ 21 تغيرت بين إصدار 0.19.0 و 0.20.0، وأيها لم يتغير
- لماذا يظهر الخطأ بعد أن تكون الأداة المعتمدة قد عملت بالفعل، وكيفية إعادة المحاولة بأمان
- كيفية ضبط
maxTurnsوerrorHandlersلتدفقات الموافقة
ما الذي تغير في @openai/agents 0.20.0
تدرج ملاحظات إصدار @openai/agents-core 0.20.0 هذا التغيير تحت بند "تغييرات بسيطة":2
إصلاح: احتساب استدعاءات النموذج بعد استئناف الموافقة ضمن maxTurns؛ يجب على المستدعين الذين يحتاجون إلى استجابة نموذج متابعة عند الحد الأقصى زيادة maxTurns.
هذا التغيير هو طلب السحب (pull request) رقم 2032، الذي تم دمجه في 5 أكتوبر 2026 الساعة 17:53 بالتوقيت العالمي المنسق، بعد حوالي ساعة من ظهور إصدار 0.19.0 على npm.14 ثم تلا ذلك إصدار 0.20.0 في 8 أكتوبر.1
يصف طلب السحب السلوك القديم بأنه "إعفاء الاستمرار الصادر". ويذكر أن التطبيقات التي تحتاج إلى استجابة نموذج متابعة بعد الموافقة "يجب أن توفر أدواراً كافية أو تزيد maxTurns عند الاستئناف".4
يقول أيضاً "الموافقات المعلقة والمخرجات النهائية المقتصرة على الأدوات يمكن أن تكتمل حتى عند الوصول إلى الحد الأقصى."4 لقد اختبرت كليهما أدناه.
يضع تعليق في الـ diff القاعدة الجديدة في سطر واحد: "حل الموافقات لا يستهلك دوراً، ولكن استدعاء النموذج التالي يفعل ذلك."4 وقد حل هذا التعليق محل تعليق من حزمة 0.19.0: "لا تقم بتقديم الدور عند الاستئناف من انقطاع؛ استدعاء النموذج التالي لا يزال جزءاً من نفس الدور المنطقي."43
ويذكر نفس طلب السحب (pull request) أن توثيق السلوك الجديد "سيتم توقيته بشكل منفصل." في مصدر التوثيق الذي راجعته (الفرع الرئيسي، التزام بتاريخ 9 أكتوبر)، لا يذكر دليل التدخل البشري (human-in-the-loop) maxTurns.46
إعادة إنتاج المشكلة باستخدام نموذج مكتوب (scripted model)
لا تحتاج إلى مفتاح API. توفر @openai/agents/testing أداة ScriptedModel تقوم بإعادة تشغيل استجابات النموذج التي تكتبها.
قم بتثبيت كل إصدار في مجلد خاص به. تدرج الحزمة الأساسية في كلا الإصدارين zod ^4.0.0 كاعتمادية نظيرة (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 ..
احفظ هذا الملف باسم turns-test.mjs وانسخه في كلا المجلدين. تقوم الحالات من A إلى L ببناء عميل جديد تكون أداته الوحيدة، pay، بحاجة دائماً إلى موافقة. يطلب النموذج المكتوب أداة pay لعدد محدد من المرات، ثم يجيب بـ "done". تضيف الحالات M1 و M2 أداة ثانية، echo، لا تحتاج إلى موافقة.
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); }
}
قم بتشغيل node turns-test.mjs في كل مجلد. لقد قمت بتشغيل كل إصدار ثلاث مرات وكانت المخرجات متطابقة في كل مرة.3
النتائج: 0.19.0 مقابل 0.20.0

الشكل 1. النتائج أدناه في شكل جدول. اللون الأحمر يعني MaxTurnsExceededError. "Tool ran" تحسب عدد مرات تشغيل execute. "Model calls" هي طول ScriptedModel.calls.
إليك المخرجات الحقيقية، 0.19.0 أولاً. الحالات من E1 إلى E3 تعمل فقط عندما يحدث خطأ عند الاستئناف، لذا يطبع 0.19.0 عدد 18 سطراً بينما يطبع 0.20.0 عدد 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
و 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
استدعاء النموذج بعد الموافقة أصبح الآن يستهلك دوراً
الحالة A1 هي أصغر نموذج لإعادة إنتاج المشكلة. أول run() مع maxTurns: 1 يتوقف لطلب الموافقة في كلا الإصدارين. هذا الاستدعاء هو الدور رقم 1.3
بعد state.approve()، يقوم التشغيل المستأنف بتنفيذ الأداة ثم يحتاج النموذج لكتابة الإجابة. في إصدار 0.19.0 لم يتم احتساب هذا الاستدعاء، وانتهى التشغيل بكلمة "done." أما في إصدار 0.20.0 فهو الدور رقم 2، وأدى التشغيل إلى ظهور خطأ MaxTurnsExceededError: Max turns (1) exceeded.3
مع maxTurns: 2 (A2) انتهت كلتا النسختين. لذا فإن الحل لتدفق معطل هو ميزانية أكبر، كما تذكر ملاحظات الإصدار.23
عدد الرفض أيضاً
الحالة B ترفض المكالمة باستخدام state.reject(). الأداة لم تعمل أبداً، في كلتا النسختين. في الإصدار 0.19.0، قامت عملية التشغيل بإجراء مكالمة نموذج متابعة بعد الرفض وانتهت، بإجمالي مكالمتين للنموذج. في الإصدار 0.20.0، رفض الحد تلك المكالمة عند maxTurns: 1، بعد مكالمة نموذج واحدة.43
لذا في عمليات التشغيل هذه، أدى الرفض إلى الوصول للحد بنفس الطريقة التي فعلتها الموافقة.
حفظ واستعادة الحالة لا يغيرها
الحالتان C1 و C2 تكتبان الحالة باستخدام toString() وتستعيدانها باستخدام RunState.fromString() قبل الاستئناف. تطابقت النتيجة مع عمليات التشغيل في الذاكرة: الإصدار 0.20.0 أظهر خطأ عند maxTurns: 1 وانتهى عند 2.3
يقول طلب السحب (pull request) أن العد الجديد ينطبق "بما في ذلك عبر عمليات الاستئناف المتسلسلة".4 في الإصدار 0.20.0، انتهت C2 عند maxTurns: 2 على الرغم من أن التشغيل الأول استخدم 1، لذا فإن القيمة التي مررتها إلى مكالمة run() المستأنفة قد طُبقت. توضح M2 أدناه الشيء نفسه في كلتا النسختين.
الاستئناف بدون maxTurns يعيد استخدام الحد المحفوظ
تتذكر الحالة الحد من التشغيل الأول. في الحالتين K و L، استخدم التشغيل الأول maxTurns: 1 ولم يمرر الاستئناف أي maxTurns على الإطلاق.
في الإصدار 0.20.0، أظهر كلاهما خطأ Max turns (1) exceeded. أعاد الاستئناف استخدام القيمة 1 من التشغيل الأول، سواء في الذاكرة (K) أو بعد toString() و fromString() (L). لم يرجع إلى القيمة الافتراضية وهي 10.
في الإصدار 0.19.0، انتهت K و L لأن مكالمة المتابعة كانت مجانية. لا يذكرون شيئاً عن الحد هناك. أما الحالتان M1 و M2 فيفعلان. يقوم النموذج باستدعاء pay، ثم echo، والتي لا تحتاج إلى موافقة، ثم يجيب، لذا يحتاج الاستئناف إلى مكالمتين للنموذج.
في M1، لم يمرر الاستئناف أي maxTurns، وأظهرت كلتا النسختين خطأ Max turns (1) exceeded، بعد مكالمتين للنموذج في 0.19.0 ومكالمة واحدة في 0.20.0. في M2، مرر الاستئناف maxTurns: 10، وانتهى كلاهما.3
لذا فإن الحد الصغير الذي تم تعيينه في المكالمة الأولى يمكن أن يتبع عملية تشغيل متوقفة إلى استئناف لا يذكره أبداً.
سلاسل الموافقات أصبحت الآن محدودة
تستخدم الحالات D و I و J نموذجاً يطلب الموافقة مراراً وتكراراً.
في الإصدار 0.19.0، انتهت ثماني موافقات متتالية تحت maxTurns: 3. واثنتا عشرة موافقة متتالية انتهت تحت الحد الافتراضي 10.3 في عمليات التشغيل هذه، فإن الحلقة التي استمرت في الاستئناف من خلال الموافقات لم تصل أبداً إلى الحد.
في الإصدار 0.20.0 تحت maxTurns: 3، انتهت موافقتان متسلسلتان. ومع ثلاث موافقات، ظهر الخطأ أثناء الاستئناف الذي تبع الموافقة الثالثة. أوقف الحد الافتراضي عملية التشغيل ذات الاثنتي عشرة موافقة بعد عشر عمليات تنفيذ أدوات معتمدة.3
في الإصدار 0.20.0، كانت كل دورة في هذه التشغيلات عبارة عن استدعاء واحد للنموذج. التشغيلات التي تسببت في حدوث خطأ قامت بإجراء عدد من استدعاءات النموذج يساوي الحد المسموح به بالضبط: 3 استدعاءات تحت maxTurns: 3، و10 استدعاءات تحت القيمة الافتراضية.3
سلسلة من k من الموافقات بالإضافة إلى الإجابة النهائية تتطلب k + 1 من استدعاءات النموذج، لذا مع maxTurns: 3، يمكن أن تكون k هي 2.
في الإصدار 0.19.0، قامت سلسلة من ثماني موافقات بإجراء تسعة استدعاءات للنموذج تحت maxTurns: 3 ولم تتسبب في حدوث خطأ أبداً.3
مع maxTurns: null، انتهت اثنتا عشرة موافقة في كلا الإصدارين.53
إذا كان بإمكان العميل (agent) الخاص بك طلب الموافقة بشكل متكرر، فإن الإصدار 0.20.0 يوقف الحلقة الآن عند الحد المسموح به، ما لم تقم بتمرير null. في الإصدار 0.19.0، كان عليك حساب الجولات بنفسك.
الخطأ يظهر بعد تشغيل الأداة
في A1 و C1 و E0 و G، تم تنفيذ الأداة مرة واحدة ثم ظهر الخطأ. في كل حالة أخرى حدث فيها خطأ، وهي جميع الحالات باستثناء الرفض في B، كانت الأداة قد عملت مرة واحدة على الأقل قبل ظهور الخطأ. في الإصدار 0.20.0، تم استدعاء النموذج بعدد مرات يساوي بالضبط الحد المسموح به عندما ظهر الخطأ، على سبيل المثال مرة واحدة عند maxTurns: 1 في A1، لذا لم يتم إجراء الاستدعاء الذي يتجاوز الميزانية أبداً.3
عندما يؤدي استدعاء تمت الموافقة عليه إلى هذا الخطأ، يكون تأثيره الجانبي قد تم بالفعل. لقد خرجت الدفعة المالية، أو تم إرسال البريد الإلكتروني، أو بدأ النشر.
توضح الحالة E ثلاث طرق لإعادة المحاولة:
- الاستئناف من
error.state(E1). معmaxTurns: 2، انتهى التشغيل وكانت الأداة قد عملت مرة واحدة في المجمل. - الاستئناف من
error.stateبعدtoString()وfromString()(E3). نفس النتيجة: انتهى التشغيل، وعملت الأداة مرة واحدة في المجمل. - الاستئناف من لقطة (snapshot) تم حفظها قبل أول استئناف (E2). انتهى التشغيل، ولكن الأداة عملت مرتين.3
تقول الوثائق أن الأخطاء تمتد من AgentsError، "والذي يمكن أن يوفر خاصية state للوصول إلى حالة التشغيل الحالية."5 في حالات E، كانت خاصية state للخطأ موجودة، بما أن E1 و E3 لا يعملان إلا في حال وجودها.
احفظ error.state واستأنف منه. لا تعُد إلى اللقطة الأقدم إلا إذا كانت الأداة idempotent (لا تتغير نتيجتها بتكرار التنفيذ).
البث (Streaming)، ومخرجات الأداة فقط، و errorHandlers
ثلاث حالات إضافية.
- البث (G). تسبب الاستئناف عبر البث في ظهور نفس الخطأ في الإصدار 0.20.0 وانتهى بنجاح في الإصدار 0.19.0.3
- مخرجات نهائية للأداة فقط (F). مع
toolUseBehavior: 'stop_on_first_tool'تكون نتيجة الأداة هي الإجابة، لذا لا حاجة لاستدعاء تالٍ للنموذج. انتهى التشغيل عندmaxTurns: 1في كلا الإصدارين، وهو ما يتوافق مع "المخرجات النهائية للأداة فقط يمكن أن تكتمل عند الحد المسموح به."43
errorHandlers.maxTurns (H). تقول الوثائق أن errorHandlers تقوم بتحويل الأخطاء المدعومة إلى مخرج نهائي بدلاً من إلقاء خطأ (throwing).5 في الإصدار 0.20.0، عمل المعالج (handler) الخاص بي مرة واحدة وأعاد التشغيل النص الخاص به. أما في الإصدار 0.19.0، فلم يعمل أبداً.3يعمل المعالج بعد انتهاء الأداة من عملها. يمكنه تزويد المستخدم برسالة واضحة، ولكنه لا يستطيع التراجع عن التأثير الجانبي (side effect).
كيفية ضبط maxTurns لتدفقات الموافقة (approval flows)
- في الإصدار 0.20.0، خصص دورة واحدة (turn) لكل جولة موافقة، بالإضافة إلى دورة واحدة للإجابة. هذا ما أعطته الحسابات أعلاه في تجاربي.3
- مرر
maxTurnsفي كل استدعاء للاستئناف (resume call). أي استئناف يتجاهلها سيعيد استخدام الحد المحفوظ في الحالة (K و L و M1).3 - ابحث في الكود الخاص بك عن القيم الصغيرة. في تدفق مثل اختباري، أي
maxTurnsأقل من عدد جولات الموافقة زائد واحد سيؤدي الآن إلى إلقاء خطأ. تحقق أيضاً من الاستدعاء الأول لتشغيل مستأنف، لأن حده قد ينتقل. - قم بحفظ
error.state. إعادة المحاولة منه، سواء في الذاكرة أو بشكل متسلسل (serialized)، لم تؤدِ إلى تشغيل الأداة مرة أخرى.3 - اجعل الأدوات "idempotent" (لا تتغير نتيجتها بتكرار التنفيذ). استئناف لقطة (snapshot) قديمة أدى إلى تشغيل الأداة مرتين.3
- استخدم
errorHandlers.maxTurnsفي التشغيلات الموجهة للمستخدم، حتى يرى الأشخاص رسالة بسيطة بدلاً من تتبع المكدس (stack trace).5 - في الإصدار 0.19.0، قم بحساب جولات الموافقة بنفسك. لم يقم الـ SDK بإيقاف الحلقة (loop) نيابة عنك في تجاربي.3
قراءات ذات صلة على هذا الموقع:
- OpenAI Agents SDK needsApproval: Null Bug Tested، بخصوص جانب الـ callback لنفس تدفق الموافقة.
- Add Human-in-the-Loop Approval to Claude Agent SDK (2026)، بخصوص طبقة موافقة لإطار عمل آخر.
- AI Agent Reliability 2026: Structure Beats Instructions، بخصوص ما قيسته ثلاث أوراق بحثية في 2026 حول حلقات التحقق والضوابط (guardrails).
ما لم أختبره
- استخدمت نموذجاً مبرمجاً (scripted model)، وليس نموذجاً حياً. النموذج الحقيقي قد يستغرق دورات أكثر أو أقل من نصوصي البرمجية.
- اختبرت أداة وظيفة واحدة مقيدة بالموافقة، بالإضافة إلى أداة
echoبدون موافقة في M1 و M2. لم أقم بتشغيل عمليات التسليم (handoffs)، أوagent.asTool()مع موافقات متداخلة، أو Realtime، أو أدوات MCP المستضافة أو أدوات الكمبيوتر. - لم أستخدم جلسة (session) أو
conversationId. - يصف طلب السحب (pull request) أيضاً نطاقات تتبع (trace spans) منفصلة وإجمالي استخدام لعمل الموافقة واستدعاءات النموذج اللاحقة. لم أفحص التتبع.4
- لم أختبر
includeInHistoryفيerrorHandlers. - استخدمت إصدار Node واحداً (22.23.2) وإصدار Zod واحداً (4.6.6).
- يحتوي إصدار 0.20.0 على تغييرات أخرى، مثل الافتراضي 64 صفحة للاكتشاف التلقائي لأدوات MCP. لم أختبر تلك التغييرات.2
الخلاصة
في إصدار @openai/agents 0.19.0، كانت أول مكالمة للموديل بعد استئناف الموافقة مجانية، لذا فإن قيمة maxTurns صغيرة لم تعق أيًا من حالاتي باستثناء M1. أما في الإصدار 0.20.0، فإن كل مكالمة متابعة للموديل يتم احتسابها، وتكون الأداة المعتمدة قد تم تشغيلها بالفعل عند الوصول إلى الحد الأقصى.
تحقق من قيمة maxTurns المطبقة على كل مكالمة تستأنف تشغيلاً متوقفًا مؤقتًا، واحتفظ بالحالة المحفوظة من الخطأ.
Footnotes
-
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 -
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 -
Author's measurement, October 12, 2026: Node 22.23.2 on Linux,
@openai/agents0.19.0 and 0.20.0 (each with the matching@openai/agents-core), Zod 4.6.6,ScriptedModelfrom@openai/agents/testing, tracing disabled. The core package in both versions declareszod^4.0.0as 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 ofScriptedModel.callsas the model-call count. A separate check printed the internalstate._currentTurnfield (not public API) after the first run and after a resume withmaxTurns: 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 showedJSON.parse(state.toString()).maxTurnswas 1 on both versions after a first run withmaxTurns: 1, and that passingmaxTurns: nullto a restored state finished on both. The 0.19.0 comment quoted above is indist/runner/turnPreparation.jsof the@openai/agents-core0.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 -
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 -
OpenAI Agents SDK for JavaScript, "Running agents" guide. Read from the source file
running-agents.mdxon main (commit 89a9ddf, 2026-10-09). It gives themaxTurnsdefault of 10 (alsoDEFAULT_MAX_TURNS = 10in the source),nullto disable, theerrorHandlerskeys, and theAgentsErrorstatesentence. The main branch can be ahead of the 0.20.0 package. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 -
OpenAI Agents SDK for JavaScript, "Human in the loop" guide, source file
human-in-the-loop.mdxon main (commit 89a9ddf). A case-insensitive search for "maxturns" in that file found no matches on October 12, 2026. ↩ -
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_functioninagents/tool.pyincludes 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. ↩



