OpenAI Agents SDK needsApproval: Null Bug Tested
٧ أكتوبر ٢٠٢٦

ملخص: في @openai/agents 0.18.0، كانت دالة الاستدعاء needsApproval تستقبل null لحقل اختياري تركه النموذج فارغاً، بينما كانت دالة execute تستقبل الحقل كغير موجود. الإصدار 0.19.0، الذي نُشر في 5 أكتوبر 2026، يقوم بإصلاح ذلك.12
لقد قمت بتشغيل كلا الإصدارين باستخدام نموذج مبرمج (scripted model). في الإصدار 0.18.0، تسببت دالة استدعاء تتحقق من cc.length في تعطل التشغيل، بينما لم يحدث ذلك في الإصدار 0.19.0.3 كما أن هذا الإصدار يجعل الـ SDK يعيد تشغيل needsApproval عند استئناف تشغيل محفوظ، مما يغير ما يحدث عندما يفشل البحث في السياسات (policy lookup).23
OpenAI Agents SDK needsApproval: الإجابة المختصرة
needsApproval هو الخيار الذي يجعل أداة الدالة تتوقف لانتظار موافقة بشرية. يمكنك ضبطه على true أو تعيينه كدالة غير متزامنة (async function) تعيد قيمة منطقية (boolean).4
حتى الإصدار 0.19.0، كان من الممكن أن ترى هذه الدالة وسائط (arguments) مختلفة عن تلك التي تراها execute. بالنسبة لحقل Zod .optional() تركه النموذج فارغاً، كانت دالة الاستدعاء تستقبل null بينما لا تستقبل execute شيئاً.53 في الإصدار 0.19.0، يرى كلاهما نفس المدخلات الموحدة.63
ما ستتعلمه
- ما الذي تستقبله
needsApproval، ولماذا يحول الوضع الصارم (strict mode) الحقول الاختيارية إلىnull - كيفية إعادة إنتاج خطأ الإصدار 0.18.0 باستخدام نموذج مبرمج وبدون مفتاح API
- ما الذي تغير في الإصدار 0.19.0 بالنسبة لعمليات التشغيل المستأنفة والمدخلات غير البسيطة
- ما الذي قمت بقياسه ولم يتغير
- قائمة مراجعة قصيرة لكتابة دالة استدعاء موافقة آمنة
لماذا تصل الحقول الاختيارية كـ null
مخططات الأدوات (Tool schemas) في هذا الـ SDK تكون صارمة افتراضياً. يذكر دليل الأدوات أن مخططات التحقق تفعّل الوضع الصارم تلقائياً.7
في الوضع الصارم، يتم إرسال حقل Zod .optional() إلى النموذج كحقل مطلوب وقابل لأن يكون null. لقد قمت بطباعة مخطط JSON الذي يبنيه الـ SDK لـ cc: z.array(z.string()).optional() ووجدت cc ضمن قائمة required، ومصنفاً كمصفوفة أو null.3
لذا، عندما لا يجد النموذج شيئاً ليضعه في cc، يمكنه إرسال "cc": null. وهذا هو ما وصفته المشكلة رقم 1914، وقد قمت بإعادة إنتاج جانب دالة الاستدعاء منها أدناه.53
ما الذي تغير في @openai/agents 0.19.0
تذكر ملاحظات إصدار @openai/agents-core 0.19.0، المنشورة في 5 أكتوبر 2026، هذا التغيير:2
ربط موافقات الأدوات الشرطية بمدخلات تنفيذ معيارية معزولة في core و Realtime، وإعادة تقييم السياسات الحالية عند استئناف الموافقات المستمرة، ورفض القيم المعيارية غير القابلة للنسخ قبل الموافقة الشرطية، والحفاظ على معالجة المدخلات غير الصالحة (#1914, #1915).
تم دمج الإصلاح نفسه في 21 سبتمبر في طلب السحب #1948، والذي ينص على أنه يحل محل #1915.6 لا يظهر سجل npm أي إصدار بين 0.18.0 (10 سبتمبر) و 0.19.0 (5 أكتوبر)، لذا فإن 0.19.0 هو أول إصدار يحتوي عليه.1
تم فتح المشكلة #1914 في 14 سبتمبر وأغلقت في 21 سبتمبر.5
إعادة إنتاج خطأ needsApproval باستخدام نموذج مبرمج
لا تحتاج إلى مفتاح API. يوفر الـ SDK نموذج ScriptedModel في @openai/agents/testing يقوم بإعادة تشغيل استجابات النموذج التي تكتبها.
قم بتثبيت كل إصدار في مجلد خاص به. يدرج الإصدار 0.19.0 مكتبة zod ^4.0.0 كاعتمادية نظيرة (peer dependency)، لذا استخدمت Zod 4.6.5 لكليهما.3
mkdir v018 v019
cd v018 && npm init -y && npm i @openai/agents@0.18.0 zod@4 && cd ..
cd v019 && npm init -y && npm i @openai/agents@0.19.0 zod@4 && cd ..
احفظ هذا الملف باسم approval-test.mjs وانسخه في كلا المجلدين. يحتوي الملف على أربع حالات.
import { z } from 'zod';
import { Agent, run, tool, Usage, RunState } from '@openai/agents';
import { ScriptedModel, assistantMessage, functionCall, modelResponse } from '@openai/agents/testing';
const script = (name, args) => new ScriptedModel([
modelResponse({ output: [functionCall(name, args, { callId: 'call_1' })], usage: new Usage() }),
modelResponse({ output: [assistantMessage('done')], usage: new Usage() }),
]);
const short = (e) => `${e.constructor.name}: ${String(e.message).split('\n')[0].slice(0, 260)}`;
// 1. Optional field the model leaves out. Strict mode makes the model send null.
const emailParams = z.object({ to: z.string(), cc: z.array(z.string()).optional() });
async function nullCase(label, needs) {
const seen = { needs: '-', exec: '-' };
const sendEmail = tool({
name: 'send_email', description: 'Send an email', parameters: emailParams,
needsApproval: async (_ctx, input) => { seen.needs = JSON.stringify(input); return needs(input); },
execute: async (input) => { seen.exec = JSON.stringify(input); return 'sent'; },
});
const agent = new Agent({ name: 'mailer', model: script('send_email', '{"to":"bob@example.com","cc":null}'), tools: [sendEmail] });
let outcome;
try {
let r = await run(agent, 'send it');
const asked = r.interruptions?.length ?? 0;
if (asked) { r.state.approve(r.interruptions[0]); r = await run(agent, r.state); }
outcome = `approvals asked=${asked}`;
} catch (e) { outcome = `THROWS ${short(e)}`; }
console.log(`${label}\n needsApproval saw ${seen.needs} | execute saw ${seen.exec}\n ${outcome}`);
}
await nullCase('A. guarded: cc !== undefined && cc.length > 0', ({ cc }) => cc !== undefined && cc.length > 0);
await nullCase('B. loose: cc !== undefined', ({ cc }) => cc !== undefined);
// 2. Approve, park the run, resume it in a "new process" while the policy lookup is down.
async function resumeCase(policyDown) {
const s = { down: false, needsCalls: 0, execCalls: 0 };
const pay = tool({
name: 'pay', description: 'Pay a vendor', parameters: z.object({ payee: z.string(), amount: z.number() }),
needsApproval: async (_ctx, i) => { s.needsCalls++; if (s.down) throw new Error('policy service unavailable'); return i.amount > 100; },
execute: async () => { s.execCalls++; return 'paid'; },
});
const agent = new Agent({ name: 'payer', model: script('pay', '{"payee":"acme","amount":500}'), tools: [pay] });
const first = await run(agent, 'pay acme');
first.state.approve(first.interruptions[0]);
const saved = first.state.toString();
s.down = policyDown;
const before = s.needsCalls;
try {
await run(agent, await RunState.fromString(agent, saved));
console.log(`C. resume, policy ${policyDown ? 'DOWN' : 'up '}: needsApproval re-run=${s.needsCalls - before}x, execute ran=${s.execCalls}x`);
} catch (e) { console.log(`C. resume, policy ${policyDown ? 'DOWN' : 'up '}: THROWS ${short(e)}; needsApproval re-run=${s.needsCalls - before}x, execute ran=${s.execCalls}x`); }
}
await resumeCase(false);
await resumeCase(true);
// 3. A schema that returns something that is not plain data (a function).
const weird = z.object({ to: z.string() }).transform((v) => ({ ...v, audit: () => 'x' }));
let execCalls = 0;
const notify = tool({
name: 'notify', description: 'Notify', parameters: weird,
needsApproval: async () => false,
execute: async () => { execCalls++; return 'ok'; },
});
try {
await run(new Agent({ name: 'n', model: script('notify', '{"to":"bob"}'), tools: [notify] }), 'go');
console.log(`D. non-plain parsed input: run completed, execute ran=${execCalls}x`);
} catch (e) { console.log(`D. non-plain parsed input: THROWS ${short(e)}\n execute ran=${execCalls}x`); }
قم بتشغيل node approval-test.mjs في كل مجلد. قمت بتشغيل كل إصدار ثلاث مرات وكانت المخرجات متطابقة في كل مرة.3
النتائج: 0.18.0 مقابل 0.19.0

الشكل 1. المخرجات الفعلية للسكربت أعلاه على كلا الإصدارين (Node 22.22.0, Zod 4.6.5). الخطوط الحمراء هي الأخطاء التي تم إلقاؤها. الخطوط الطويلة تم لفها لتناسب العرض.
إليك نفس المخرجات في شكل نصي، 0.18.0 أولاً:
A. guarded: cc !== undefined && cc.length > 0
needsApproval saw {"to":"bob@example.com","cc":null} | execute saw -
THROWS ToolCallError: Failed to run function tools: TypeError: Cannot read properties of null (reading 'length')
B. loose: cc !== undefined
needsApproval saw {"to":"bob@example.com","cc":null} | execute saw {"to":"bob@example.com"}
approvals asked=1
C. resume, policy up : needsApproval re-run=0x, execute ran=1x
C. resume, policy DOWN: needsApproval re-run=0x, execute ran=1x
D. non-plain parsed input: run completed, execute ran=1x
و 0.19.0:
A. guarded: cc !== undefined && cc.length > 0
needsApproval saw {"to":"bob@example.com"} | execute saw {"to":"bob@example.com"}
approvals asked=0
B. loose: cc !== undefined
needsApproval saw {"to":"bob@example.com"} | execute saw {"to":"bob@example.com"}
approvals asked=0
C. resume, policy up : needsApproval re-run=1x, execute ran=1x
C. resume, policy DOWN: THROWS ToolCallError: Failed to run function tools: Error: policy service unavailable; needsApproval re-run=1x, execute ran=0x
D. non-plain parsed input: THROWS ToolCallError: Failed to run function tools: FunctionToolApprovalInputError: Conditional tool approval requires copyable plain normalized input. Return plain data from the schema and resolve application objects inside execute.
execute ran=0x
استدعاء عكسي محمي تسبب في تعطل التشغيل في 0.18.0
الحالة A هي استدعاء عكسي (callback) يبدو صحيحاً بالنسبة لنوعه المعلن. تقول المشكلة #1914 أن نوع مدخل الاستدعاء العكسي لـ cc هو string[] | undefined، لذا يجب أن يكون cc !== undefined && cc.length > 0 آمناً.5
في الإصدار 0.18.0 لم يكن الأمر كذلك، لأن الاستدعاء العكسي رأى cc: null. تم رفض run() باستخدام ToolCallError يغلف TypeError، ولم يتم تشغيل execute أبداً.3
في الإصدار 0.19.0 رأى الاستدعاء العكسي نفس الكائن الذي رآه execute، بدون مفتاح cc، وانتهى التشغيل دون الحاجة إلى موافقة.3
استدعاء عكسي غير دقيق طلب موافقة لم يكن بحاجة إليها
الحالة B هي طريقة أخرى يظهر بها المدخل الخاطئ. cc !== undefined تكون صحيحة (true) بالنسبة لـ null، لذا في الإصدار 0.18.0 قام الـ SDK بإيقاف التشغيل مؤقتاً وطلب الموافقة على بريد إلكتروني لا يحتوي على cc على الإطلاق.3
في الإصدار 0.19.0، كانت نفس الـ callback تعيد قيمة false وكان التشغيل يستمر مباشرة. في كلتا حالتيّ، كان القرار الخاطئ إما توقفاً غير ضروري أو تعطلاً (crash).
لم أقم ببناء callback يسمح للسلوك القديم بتخطي الموافقة على مكالمة خطيرة. أنا لا أدعي أن هذا الأمر مستحيل الحدوث.
عمليات التشغيل المستأنفة الآن تعيد تشغيل needsApproval للمكالمات المعتمدة
تقوم الحالة C بإيقاف عملية تشغيل معتمدة، وتحويلها إلى سلسلة نصية باستخدام state.toString()، ثم استئنافها باستخدام RunState.fromString(). تصف وثائق SDK هذا النمط لعمليات التشغيل التي تحتاج إلى التوقف لفترة طويلة دون الحاجة لإبقاء الخادم الخاص بك قيد التشغيل.4
في الإصدار 0.18.0، كانت needsApproval تعمل صفر من المرات عند الاستئناف. أما في الإصدار 0.19.0، فقد عملت مرة واحدة، على الرغم من أن المكالمة كانت معتمدة بالفعل.3
كانت المكالمة التي لا تزال تنتظر قراراً مختلفة. في فحص منفصل، عملت needsApproval مرة واحدة عند الاستئناف في كلا الإصدارين.3
هذا يتطابق مع ملاحظات الإصدار بشأن إعادة تقييم "السياسات الحالية عند استئناف الموافقات المستديمة (durable approval resumes)."2 في اختباري، كانت النتيجة هي نفسها عندما كانت السياسة سليمة: تم تنفيذ المكالمة المعتمدة مرة واحدة.
يظهر الفرق عندما يفشل البحث عن السياسة. مع تعطل خدمة السياسات وقت الاستئناف، تسبب الإصدار 0.19.0 في حدوث خطأ (threw) وعملت execute صفر من المرات، بينما قام الإصدار 0.18.0 بتنفيذ المكالمة المعتمدة.3
إذا كانت الـ callback الخاصة بك تستدعي قاعدة بيانات أو خدمة سياسات، فإنها الآن تعمل مرة أخرى عند الاستئناف. اجعلها idempotent وحدد ماذا يجب أن يعني الفشل.
المدخلات غير البسيطة (Non-plain) تسبب خطأ الآن قبل الموافقة
تستخدم الحالة D وظيفة Zod .transform() تعيد كائناً (object) يحتوي على دالة. في الإصدار 0.18.0، اكتمل التشغيل وعملت execute مرة واحدة.3
في الإصدار 0.19.0، تسبب التشغيل في خطأ FunctionToolApprovalInputError مع الرسالة "Conditional tool approval requires copyable plain normalized input," وعملت execute صفر من المرات.3
ينص نص طلب السحب (pull request) على أن المخرجات التي لا يمكن فحصها كبيانات بسيطة مستقلة "تتطلب الآن موافقة يدوية."6 في تجربتي مع callback تعيد false، حصلت على خطأ (thrown error) وليس توقفاً للموافقة. لم أختبر إعدادات أخرى.
ما الذي لم يتغير
أعطت ثلاثة فحوصات نفس النتيجة في كلا الإصدارين. لقد قمت بتشغيلها في سكربتات صغيرة منفصلة غير معروضة هنا.3
- حقل
.nullable()الذي يضبطه النموذج علىnullظلnullفي كل منneedsApprovalوexecute. لذا فإن الإصلاح يزيل الـnullالتي يضيفها الوضع الصارم (strict mode) لـ.optional()، وليس كلnull. - الـ callback التي تغير مدخلاتها (قمت بضبط
input.amount = 1) لم تغير ما رأتهexecuteفي أي من الإصدارين. ينص طلب السحب على أن تغييرات السياسة لا يمكن أن تغير مدخلات التنفيذ، ولم يظهر اختباري أي تسريب في الإصدار 0.18.0 أيضاً.6
.optional() مع حذف المفتاح بالكامل وصل كقيمة غائبة (absent) في كليهما.جربت أيضاً حقل .default("none") مع مدخل null. لم يتم تشغيل execute في أي من الإصدارين، ولم أتعمق في معرفة السبب.
قائمة مراجعة لـ callback آمن لـ needsApproval
- تحقق من الحالات الغائبة (absent) والـ null في إصدار 0.18.x. استخدم
cc == nullأو(cc ?? [])حتى تتمكن من الترقية. - قم بالترقية إلى 0.19.0 وتخلص من الحل المؤقت. في اختباراتي، رأى الـ callback حينها نفس المدخلات التي تراها
execute.23 - أرجع بيانات بسيطة (plain data) من الـ schema الخاصة بك. قم بمعالجة صفوف قاعدة البيانات والعملاء داخل
execute، وليس في.transform(). - اجعل الـ callback idempotent. يمكن أن يتم تشغيله مرة أخرى عند استئناف عملية تشغيل محفوظة.3
- حدد ماذا يعني تعطل السياسة (policy outage). في إصدار 0.19.0، يؤدي الـ callback الذي يرمي خطأ (throwing callback) إلى حظر المكالمة المستأنفة. بالنسبة لأشياء مثل المدفوعات، يكون الحظر عادةً هو الفشل الأكثر أماناً.3
- خزن الحالة المحفوظة على خادمك. تقول الوثائق أن
RunState.fromString()"لا يقوم بمصادقة اللقطة (snapshot) أو الشخص الذي يقدمها."4
لتطبيق نفس نمط الموافقة في إطار عمل آخر، انظر human-in-the-loop باستخدام Claude Agent SDK في TypeScript. وللحصول على صورة أشمل حول إبقاء حلقات العميل (agent loops) تحت السيطرة، انظر موثوقية عملاء الذكاء الاصطناعي باستخدام حلقات التحقق والضوابط (guardrails).
ما لم أختبره
- استخدمت نموذجاً مكتوباً (scripted model)، وليس نموذجاً حياً. ادعاء مُبلغ عن المشكلة هو أن النموذج يرسل
nullللحقل الاختياري المحذوف، ودليلي هو الـ schema المُنشأة، والتي تجعل الحقل مطلوباً وقابلاً لأن يكون null.53 - اختبرت أدوات الدوال البسيطة (plain function tools) فقط. تقول ملاحظات الإصدار أن التغيير يشمل أيضاً أدوات Realtime، ولم أقم بتشغيل تلك الأدوات أو
agent.asTool().2 - استخدمت إصداراً واحداً من Node (22.22.0) وإصداراً واحداً من Zod (4.6.5).
- لم أختبر عمليات التشغيل المتدفقة (streaming runs) أو خيارات
alwaysApproveوalwaysReject.
الخلاصة
في إصدار @openai/agents 0.18.0، كان بإمكان needsApproval رؤية null في حين لم يرَ execute شيئاً، وأي callback يثق في أنواعه (types) كان يتسبب في انهيار التشغيل. في الإصدار 0.19.0، يرى كلاهما نفس المدخلات الموحدة.
كما ينقل التحديث فحوصات السياسة (policy checks) إلى مسار الاستئناف. تأكد من أن الـ callback الخاص بك idempotent (لا يتغير تأثيره بتكرار التشغيل) ويفشل بالطريقة التي تريدها عند تعطل عملية البحث.
Footnotes
-
npm registry metadata for
@openai/agents(npm view @openai/agents time), read October 7, 2026: version 0.18.0 published 2026-09-10 and 0.19.0 published 2026-10-05, with no release in between. Package page: @openai/agents on npm. ↩ ↩2 ↩3 -
openai/openai-agents-js GitHub releases,
@openai/agents-core@0.19.0, published 2026-10-05. The quoted line is change 39decd7 in the "Minor Changes" list. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 -
Author's measurement, October 7, 2026: Node 22.22.0 on Linux x86_64,
@openai/agents0.18.0 and 0.19.0 (each with the matching@openai/agents-core), Zod 4.6.5,ScriptedModelfrom@openai/agents/testing. The 0.19.0 package declareszod^4.0.0as a peer dependency. The published script was extracted from this post and run unchanged in fresh folders. Each version was run three times with identical output. The schema printout, the undecided-resume check and the three unchanged-behavior checks were separate small scripts run on both versions with the same setup. The schema printout usedtool()withz.array(z.string()).optional()and showedstrict: truewithccinrequired. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10 ↩11 ↩12 ↩13 ↩14 ↩15 ↩16 ↩17 ↩18 ↩19 ↩20 ↩21 ↩22 ↩23 ↩24 ↩25 -
OpenAI Agents SDK for JavaScript, "Human in the loop" guide. Quotes taken on October 7, 2026 from the guide's source file on the main branch:
human-in-the-loop.mdx. The main branch can be ahead of the 0.19.0 package. ↩ ↩2 ↩3 ↩4 ↩5 -
openai/openai-agents-js, issue #1914, "needsApproval gets null for omitted optional fields on Zod and strict JSON schema tools", opened 2026-09-14 and closed 2026-09-21. The statement that the model sends
nullwhen it does not set the field is from the issue text. ↩ ↩2 ↩3 ↩4 ↩5 -
openai/openai-agents-js, pull request #1948, "fix: align conditional tool approvals with normalized execution input", merged 2026-09-21 as commit 39decd7. The description says it supersedes #1915, which was closed without merging. ↩ ↩2 ↩3 ↩4 ↩5
-
OpenAI Agents SDK for JavaScript, "Tools" guide, parameters table: "Validation schemas automatically enable strict mode." Read from the source file
tools.mdxon main, October 7, 2026. ↩



