feat(agents): Swarm core — collector spawn, agents_wait, structured output, caps (gated) (#110932)

* docs: add Swarm implementation spec

* feat(agents): Swarm core — collector spawn, agents_wait, structured output, fastMode, caps

Implements docs/plan/swarms.md §4-6: tools.swarm config gate (default off),
collector-mode sessions_spawn (collect/outputSchema/fastMode/groupId), fail-closed
child approvals, agents_wait race-semantics tool, per-group FIFO scheduler with
maxConcurrent/maxChildrenPerGroup/maxTotalPerGroup caps, additive registry and
state-schema columns (no schema-version bump), and colocated tests.

Part of #110325

* fix(agents): rebase reconciliation — preserve swarm state columns

* fix(agents): O(1) collector session index, cheap-first preflight, collector start lifecycle hooks

Autoreview findings: replace the per-request full registry scan with an
index-maintaining run map and gate the persisted-store fallback behind
isSubagentSessionKey; emit subagent_progress/subagent_spawned from the swarm
scheduler start callback so collector children produce balanced plugin
lifecycle events.

* chore(protocol): regenerate Swift gateway models for swarm fields

* fix(agents): lint cleanup — typed catch, explicit microtask flush, no executor return

* fix(agents): knip/test-support/max-lines cleanup for swarm surfaces

* fix(agents): keep tool-catalog ui-safe — callers pass prepared swarmEnabled fact

resolveSwarmConfig value-import in tool-catalog dragged the server graph into
the Control UI bundle via tool-policy-shared (UNLOADABLE_DEPENDENCY on
subpath aliases). Catalog stays pure; the gateway tools-catalog handler
resolves the gate and passes the boolean.

* fix(agents): reconcile swarm spawn pipeline

* fix(agents): refresh swarm tool metadata

* fix(apps): sync swarm tool localization

* style(agents): compact tool display metadata

* fix(plugin-sdk): account for swarm config surface
This commit is contained in:
Peter Steinberger
2026-07-18 23:30:11 -07:00
committed by GitHub
parent 7055ed578d
commit ccb147518c
115 changed files with 6655 additions and 598 deletions
@@ -1188,6 +1188,7 @@ internal val nativeStringResourceIds: Map<String, Int> =
"Still waiting for approval" to R.string.native_752ae6f8d8349c75,
"Stop" to R.string.native_cae7d57bc067a514,
"Stop Dictation" to R.string.native_bf399ffa03617a96,
"Structured Output" to R.string.native_af1f10e9b3af3250,
"Sub-agent" to R.string.native_429f6057535c1ea3,
"Subagents" to R.string.native_88296ab3d6665a8e,
"Submit" to R.string.native_155f816c0407310c,
@@ -1338,6 +1339,7 @@ internal val nativeStringResourceIds: Map<String, Int> =
"Voice reply timed out; retrying queued turn" to R.string.native_f2bfed0b0c0055db,
"Voice request failed" to R.string.native_28d55ec6974d84e8,
"Voice setup" to R.string.native_209b9e597963e7e1,
"Wait for Agents" to R.string.native_56c4c3d30ceda56e,
"Wait for the current response to finish before starting a new chat." to R.string.native_d68f3aaa52604f24,
"Waiting" to R.string.native_6e293a8c009e0011,
"Waiting \${duration}" to R.string.native_e841c8273d808b7a,
@@ -513,6 +513,7 @@
<string name="native_565339bc4d33d728" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"حسنًا"</string>
<string name="native_56a508f3f2bf424a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"تعذّر فتح دليل الإعداد."</string>
<string name="native_56c41c606c44f3fc" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"اسأل OpenClaw %1$s"</string>
<string name="native_56c4c3d30ceda56e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Wait for Agents"</string>
<string name="native_56ef8f20955f2564" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"العنوان"</string>
<string name="native_5702e1d243eeb4e9" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ستظهر هنا الأعمال المجدولة التي تم إنشاؤها على Gateway."</string>
<string name="native_572739b05d138f8a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"يتم عرض أحدث جزء من السجل."</string>
@@ -1028,6 +1029,7 @@
<string name="native_aed306fbb63c0c68" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"تضمين حزم Android وحزم الخلفية."</string>
<string name="native_aedf1cf58372f943" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateway جاهز."</string>
<string name="native_aeffb9182e3d7f3d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"تم التشغيل"</string>
<string name="native_af1f10e9b3af3250" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Structured Output"</string>
<string name="native_af60a38d6b5866ce" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"يستغرق هذا وقتًا أطول من المتوقع.\nتحقق من أن Gateway قيد التشغيل ويمكن الوصول إليه."</string>
<string name="native_af7deaf2c81b107d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"لا توجد مهام في الخلفية لهذا الوكيل."</string>
<string name="native_afb118fcb16c3727" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"جارٍ إعادة الاتصال"</string>
@@ -513,6 +513,7 @@
<string name="native_565339bc4d33d728" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OK"</string>
<string name="native_56a508f3f2bf424a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Einrichtungsanleitung konnte nicht geöffnet werden."</string>
<string name="native_56c41c606c44f3fc" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OpenClaw %1$s fragen"</string>
<string name="native_56c4c3d30ceda56e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Wait for Agents"</string>
<string name="native_56ef8f20955f2564" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Adresse"</string>
<string name="native_5702e1d243eeb4e9" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Auf dem Gateway erstellte geplante Aufgaben werden hier angezeigt."</string>
<string name="native_572739b05d138f8a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Der neueste Protokollausschnitt wird angezeigt."</string>
@@ -1028,6 +1029,7 @@
<string name="native_aed306fbb63c0c68" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Android- und Hintergrundpakete einschließen."</string>
<string name="native_aedf1cf58372f943" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Ihr Gateway ist bereit."</string>
<string name="native_aeffb9182e3d7f3d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Ausgelöst"</string>
<string name="native_af1f10e9b3af3250" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Structured Output"</string>
<string name="native_af60a38d6b5866ce" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Dies dauert länger als erwartet.\nPrüfen Sie, ob das Gateway ausgeführt wird und erreichbar ist."</string>
<string name="native_af7deaf2c81b107d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Keine Hintergrundaufgaben für diesen Agenten."</string>
<string name="native_afb118fcb16c3727" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Verbindung wird wiederhergestellt"</string>
@@ -513,6 +513,7 @@
<string name="native_565339bc4d33d728" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Aceptar"</string>
<string name="native_56a508f3f2bf424a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"No se pudo abrir la guía de configuración."</string>
<string name="native_56c41c606c44f3fc" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"preguntar a OpenClaw %1$s"</string>
<string name="native_56c4c3d30ceda56e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Wait for Agents"</string>
<string name="native_56ef8f20955f2564" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Dirección"</string>
<string name="native_5702e1d243eeb4e9" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"El trabajo programado creado en el gateway aparecerá aquí."</string>
<string name="native_572739b05d138f8a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Mostrando el fragmento de registro más reciente."</string>
@@ -1028,6 +1029,7 @@
<string name="native_aed306fbb63c0c68" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Incluye Android y paquetes en segundo plano."</string>
<string name="native_aedf1cf58372f943" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Tu Gateway está listo."</string>
<string name="native_aeffb9182e3d7f3d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Activado"</string>
<string name="native_af1f10e9b3af3250" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Structured Output"</string>
<string name="native_af60a38d6b5866ce" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Esto está tardando más de lo esperado.\nComprueba que el Gateway esté en ejecución y accesible."</string>
<string name="native_af7deaf2c81b107d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"No hay tareas en segundo plano para este agente."</string>
<string name="native_afb118fcb16c3727" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Reconectando"</string>
@@ -513,6 +513,7 @@
<string name="native_565339bc4d33d728" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"تأیید"</string>
<string name="native_56a508f3f2bf424a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"راهنمای راه‌اندازی باز نشد."</string>
<string name="native_56c41c606c44f3fc" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"از OpenClaw بپرس %1$s"</string>
<string name="native_56c4c3d30ceda56e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Wait for Agents"</string>
<string name="native_56ef8f20955f2564" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"نشانی"</string>
<string name="native_5702e1d243eeb4e9" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"کارهای زمان‌بندی‌شده ایجادشده در Gateway اینجا نمایش داده می‌شوند."</string>
<string name="native_572739b05d138f8a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"در حال نمایش آخرین بخش گزارش."</string>
@@ -1028,6 +1029,7 @@
<string name="native_aed306fbb63c0c68" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"بسته‌های Android و پس‌زمینه را شامل کنید."</string>
<string name="native_aedf1cf58372f943" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateway شما آماده است."</string>
<string name="native_aeffb9182e3d7f3d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"فعال شد"</string>
<string name="native_af1f10e9b3af3250" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Structured Output"</string>
<string name="native_af60a38d6b5866ce" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"این فرایند بیش از حد انتظار طول کشیده است.\nبررسی کنید که Gateway در حال اجرا و در دسترس باشد."</string>
<string name="native_af7deaf2c81b107d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"هیچ وظیفه پس‌زمینه‌ای برای این عامل وجود ندارد."</string>
<string name="native_afb118fcb16c3727" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"در حال اتصال مجدد"</string>
@@ -513,6 +513,7 @@
<string name="native_565339bc4d33d728" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OK"</string>
<string name="native_56a508f3f2bf424a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Impossible douvrir le guide de configuration."</string>
<string name="native_56c41c606c44f3fc" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"demander à OpenClaw %1$s"</string>
<string name="native_56c4c3d30ceda56e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Wait for Agents"</string>
<string name="native_56ef8f20955f2564" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Adresse"</string>
<string name="native_5702e1d243eeb4e9" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Les tâches planifiées créées sur le Gateway apparaîtront ici."</string>
<string name="native_572739b05d138f8a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Affichage du dernier bloc de journal."</string>
@@ -1028,6 +1029,7 @@
<string name="native_aed306fbb63c0c68" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Inclure Android et les packages en arrière-plan."</string>
<string name="native_aedf1cf58372f943" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Votre Gateway est prête."</string>
<string name="native_aeffb9182e3d7f3d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Déclenché"</string>
<string name="native_af1f10e9b3af3250" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Structured Output"</string>
<string name="native_af60a38d6b5866ce" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Cela prend plus de temps que prévu.\nVérifiez que la Gateway est en cours dexécution et accessible."</string>
<string name="native_af7deaf2c81b107d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Aucune tâche en arrière-plan pour cet agent."</string>
<string name="native_afb118fcb16c3727" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Reconnexion"</string>
@@ -513,6 +513,7 @@
<string name="native_565339bc4d33d728" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ठीक है"</string>
<string name="native_56a508f3f2bf424a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"सेटअप गाइड नहीं खुल सका।"</string>
<string name="native_56c41c606c44f3fc" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OpenClaw से %1$s पूछें"</string>
<string name="native_56c4c3d30ceda56e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Wait for Agents"</string>
<string name="native_56ef8f20955f2564" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"पता"</string>
<string name="native_5702e1d243eeb4e9" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateway पर बनाया गया शेड्यूल किया हुआ कार्य यहाँ दिखाई देगा।"</string>
<string name="native_572739b05d138f8a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"नवीनतम लॉग खंड दिखाया जा रहा है।"</string>
@@ -1028,6 +1029,7 @@
<string name="native_aed306fbb63c0c68" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Android और बैकग्राउंड पैकेज शामिल करें।"</string>
<string name="native_aedf1cf58372f943" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"आपका Gateway तैयार है।"</string>
<string name="native_aeffb9182e3d7f3d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ट्रिगर किया गया"</string>
<string name="native_af1f10e9b3af3250" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Structured Output"</string>
<string name="native_af60a38d6b5866ce" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"इसमें अपेक्षा से अधिक समय लग रहा है।\nजाँचें कि Gateway चल रहा है और उस तक पहुँचा जा सकता है।"</string>
<string name="native_af7deaf2c81b107d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"इस एजेंट के लिए कोई बैकग्राउंड टास्क नहीं है।"</string>
<string name="native_afb118fcb16c3727" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"दोबारा कनेक्ट हो रहा है"</string>
@@ -513,6 +513,7 @@
<string name="native_565339bc4d33d728" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OK"</string>
<string name="native_56a508f3f2bf424a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Tidak dapat membuka panduan penyiapan."</string>
<string name="native_56c41c606c44f3fc" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"tanyakan OpenClaw %1$s"</string>
<string name="native_56c4c3d30ceda56e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Wait for Agents"</string>
<string name="native_56ef8f20955f2564" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Alamat"</string>
<string name="native_5702e1d243eeb4e9" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Pekerjaan terjadwal yang dibuat di Gateway akan muncul di sini."</string>
<string name="native_572739b05d138f8a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Menampilkan potongan log terbaru."</string>
@@ -1028,6 +1029,7 @@
<string name="native_aed306fbb63c0c68" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Sertakan paket Android dan latar belakang."</string>
<string name="native_aedf1cf58372f943" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateway Anda siap."</string>
<string name="native_aeffb9182e3d7f3d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Terpicu"</string>
<string name="native_af1f10e9b3af3250" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Structured Output"</string>
<string name="native_af60a38d6b5866ce" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Proses ini memakan waktu lebih lama dari yang diperkirakan.\nPastikan Gateway berjalan dan dapat dijangkau."</string>
<string name="native_af7deaf2c81b107d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Tidak ada tugas latar belakang untuk agen ini."</string>
<string name="native_afb118fcb16c3727" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Menyambungkan kembali"</string>
@@ -513,6 +513,7 @@
<string name="native_565339bc4d33d728" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OK"</string>
<string name="native_56a508f3f2bf424a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Impossibile aprire la guida alla configurazione."</string>
<string name="native_56c41c606c44f3fc" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"chiedi a OpenClaw %1$s"</string>
<string name="native_56c4c3d30ceda56e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Wait for Agents"</string>
<string name="native_56ef8f20955f2564" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Indirizzo"</string>
<string name="native_5702e1d243eeb4e9" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Il lavoro pianificato creato sul gateway apparirà qui."</string>
<string name="native_572739b05d138f8a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Visualizzazione dell\'ultimo blocco di log."</string>
@@ -1028,6 +1029,7 @@
<string name="native_aed306fbb63c0c68" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Includi Android e i pacchetti in background."</string>
<string name="native_aedf1cf58372f943" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Il tuo Gateway è pronto."</string>
<string name="native_aeffb9182e3d7f3d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Attivato"</string>
<string name="native_af1f10e9b3af3250" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Structured Output"</string>
<string name="native_af60a38d6b5866ce" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"L\'operazione sta richiedendo più tempo del previsto.\nVerifica che il Gateway sia in esecuzione e raggiungibile."</string>
<string name="native_af7deaf2c81b107d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Nessuna attività in background per questo agente."</string>
<string name="native_afb118fcb16c3727" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Riconnessione"</string>
@@ -513,6 +513,7 @@
<string name="native_565339bc4d33d728" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OK"</string>
<string name="native_56a508f3f2bf424a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"セットアップガイドを開けませんでした。"</string>
<string name="native_56c41c606c44f3fc" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OpenClaw に %1$s を尋ねる"</string>
<string name="native_56c4c3d30ceda56e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Wait for Agents"</string>
<string name="native_56ef8f20955f2564" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"アドレス"</string>
<string name="native_5702e1d243eeb4e9" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateway で作成されたスケジュール済みの作業がここに表示されます。"</string>
<string name="native_572739b05d138f8a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"最新のログチャンクを表示しています。"</string>
@@ -1028,6 +1029,7 @@
<string name="native_aed306fbb63c0c68" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Android とバックグラウンドのパッケージを含めます。"</string>
<string name="native_aedf1cf58372f943" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gatewayの準備ができました。"</string>
<string name="native_aeffb9182e3d7f3d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"トリガーされました"</string>
<string name="native_af1f10e9b3af3250" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Structured Output"</string>
<string name="native_af60a38d6b5866ce" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"予想より時間がかかっています。\nGatewayが起動していて、接続可能であることを確認してください。"</string>
<string name="native_af7deaf2c81b107d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"このエージェントにはバックグラウンドタスクがありません。"</string>
<string name="native_afb118fcb16c3727" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"再接続中"</string>
@@ -513,6 +513,7 @@
<string name="native_565339bc4d33d728" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"확인"</string>
<string name="native_56a508f3f2bf424a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"설정 가이드를 열 수 없습니다."</string>
<string name="native_56c41c606c44f3fc" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OpenClaw에 %1$s 요청"</string>
<string name="native_56c4c3d30ceda56e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Wait for Agents"</string>
<string name="native_56ef8f20955f2564" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"주소"</string>
<string name="native_5702e1d243eeb4e9" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateway에서 생성된 예약 작업이 여기에 표시됩니다."</string>
<string name="native_572739b05d138f8a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"최신 로그 청크를 표시하고 있습니다."</string>
@@ -1028,6 +1029,7 @@
<string name="native_aed306fbb63c0c68" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Android 및 백그라운드 패키지를 포함합니다."</string>
<string name="native_aedf1cf58372f943" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateway가 준비되었습니다."</string>
<string name="native_aeffb9182e3d7f3d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"트리거됨"</string>
<string name="native_af1f10e9b3af3250" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Structured Output"</string>
<string name="native_af60a38d6b5866ce" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"예상보다 오래 걸리고 있습니다.\nGateway가 실행 중이며 연결 가능한지 확인하세요."</string>
<string name="native_af7deaf2c81b107d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"이 에이전트에는 백그라운드 작업이 없습니다."</string>
<string name="native_afb118fcb16c3727" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"다시 연결 중"</string>
@@ -513,6 +513,7 @@
<string name="native_565339bc4d33d728" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OK"</string>
<string name="native_56a508f3f2bf424a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Kan de installatiehandleiding niet openen."</string>
<string name="native_56c41c606c44f3fc" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"vraag OpenClaw %1$s"</string>
<string name="native_56c4c3d30ceda56e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Wait for Agents"</string>
<string name="native_56ef8f20955f2564" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Adres"</string>
<string name="native_5702e1d243eeb4e9" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gepland werk dat op de Gateway is aangemaakt, verschijnt hier."</string>
<string name="native_572739b05d138f8a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"De nieuwste logchunk wordt weergegeven."</string>
@@ -1028,6 +1029,7 @@
<string name="native_aed306fbb63c0c68" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Neem Android- en achtergrondpakketten op."</string>
<string name="native_aedf1cf58372f943" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Je Gateway is klaar."</string>
<string name="native_aeffb9182e3d7f3d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Geactiveerd"</string>
<string name="native_af1f10e9b3af3250" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Structured Output"</string>
<string name="native_af60a38d6b5866ce" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Dit duurt langer dan verwacht.\nControleer of de Gateway actief en bereikbaar is."</string>
<string name="native_af7deaf2c81b107d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Geen achtergrondtaken voor deze agent."</string>
<string name="native_afb118fcb16c3727" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Opnieuw verbinding maken"</string>
@@ -513,6 +513,7 @@
<string name="native_565339bc4d33d728" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OK"</string>
<string name="native_56a508f3f2bf424a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Nie udało się otworzyć przewodnika konfiguracji."</string>
<string name="native_56c41c606c44f3fc" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"zapytaj OpenClaw %1$s"</string>
<string name="native_56c4c3d30ceda56e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Wait for Agents"</string>
<string name="native_56ef8f20955f2564" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Adres"</string>
<string name="native_5702e1d243eeb4e9" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Zaplanowane zadania utworzone w Gateway pojawią się tutaj."</string>
<string name="native_572739b05d138f8a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Wyświetlany jest najnowszy fragment logu."</string>
@@ -1028,6 +1029,7 @@
<string name="native_aed306fbb63c0c68" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Uwzględnij pakiety Androida i działające w tle."</string>
<string name="native_aedf1cf58372f943" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateway jest gotowy."</string>
<string name="native_aeffb9182e3d7f3d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Aktywowano"</string>
<string name="native_af1f10e9b3af3250" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Structured Output"</string>
<string name="native_af60a38d6b5866ce" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Trwa to dłużej niż oczekiwano.\nSprawdź, czy Gateway jest uruchomiony i osiągalny."</string>
<string name="native_af7deaf2c81b107d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Brak zadań w tle dla tego agenta."</string>
<string name="native_afb118fcb16c3727" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Ponowne łączenie"</string>
@@ -513,6 +513,7 @@
<string name="native_565339bc4d33d728" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OK"</string>
<string name="native_56a508f3f2bf424a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Não foi possível abrir o guia de configuração."</string>
<string name="native_56c41c606c44f3fc" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"perguntar ao OpenClaw %1$s"</string>
<string name="native_56c4c3d30ceda56e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Wait for Agents"</string>
<string name="native_56ef8f20955f2564" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Endereço"</string>
<string name="native_5702e1d243eeb4e9" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"As tarefas agendadas criadas no Gateway aparecerão aqui."</string>
<string name="native_572739b05d138f8a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Mostrando o bloco de log mais recente."</string>
@@ -1028,6 +1029,7 @@
<string name="native_aed306fbb63c0c68" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Incluir pacotes do Android e em segundo plano."</string>
<string name="native_aedf1cf58372f943" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Seu Gateway está pronto."</string>
<string name="native_aeffb9182e3d7f3d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Acionado"</string>
<string name="native_af1f10e9b3af3250" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Structured Output"</string>
<string name="native_af60a38d6b5866ce" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Isso está demorando mais do que o esperado.\nVerifique se o Gateway está em execução e acessível."</string>
<string name="native_af7deaf2c81b107d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Não há tarefas em segundo plano para este agente."</string>
<string name="native_afb118fcb16c3727" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Reconectando"</string>
@@ -513,6 +513,7 @@
<string name="native_565339bc4d33d728" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ОК"</string>
<string name="native_56a508f3f2bf424a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Не удалось открыть руководство по настройке."</string>
<string name="native_56c41c606c44f3fc" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"спросить OpenClaw %1$s"</string>
<string name="native_56c4c3d30ceda56e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Wait for Agents"</string>
<string name="native_56ef8f20955f2564" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Адрес"</string>
<string name="native_5702e1d243eeb4e9" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Запланированные задачи, созданные на Gateway, появятся здесь."</string>
<string name="native_572739b05d138f8a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Показан последний фрагмент журнала."</string>
@@ -1028,6 +1029,7 @@
<string name="native_aed306fbb63c0c68" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Включить Android и фоновые пакеты."</string>
<string name="native_aedf1cf58372f943" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Ваш Gateway готов."</string>
<string name="native_aeffb9182e3d7f3d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Активировано"</string>
<string name="native_af1f10e9b3af3250" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Structured Output"</string>
<string name="native_af60a38d6b5866ce" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Это занимает больше времени, чем ожидалось.\nУбедитесь, что Gateway запущен и доступен."</string>
<string name="native_af7deaf2c81b107d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"У этого агента нет фоновых задач."</string>
<string name="native_afb118fcb16c3727" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Повторное подключение"</string>
@@ -513,6 +513,7 @@
<string name="native_565339bc4d33d728" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OK"</string>
<string name="native_56a508f3f2bf424a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Det gick inte att öppna konfigurationsguiden."</string>
<string name="native_56c41c606c44f3fc" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"fråga OpenClaw %1$s"</string>
<string name="native_56c4c3d30ceda56e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Wait for Agents"</string>
<string name="native_56ef8f20955f2564" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Adress"</string>
<string name="native_5702e1d243eeb4e9" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Schemalagda uppgifter som skapas på Gateway visas här."</string>
<string name="native_572739b05d138f8a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Visar det senaste loggavsnittet."</string>
@@ -1028,6 +1029,7 @@
<string name="native_aed306fbb63c0c68" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Inkludera Android och bakgrundspaket."</string>
<string name="native_aedf1cf58372f943" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Din Gateway är redo."</string>
<string name="native_aeffb9182e3d7f3d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Aktiverad"</string>
<string name="native_af1f10e9b3af3250" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Structured Output"</string>
<string name="native_af60a38d6b5866ce" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Det här tar längre tid än väntat.\nKontrollera att Gateway körs och går att nå."</string>
<string name="native_af7deaf2c81b107d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Inga bakgrundsuppgifter för den här agenten."</string>
<string name="native_afb118fcb16c3727" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Återansluter"</string>
@@ -513,6 +513,7 @@
<string name="native_565339bc4d33d728" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ตกลง"</string>
<string name="native_56a508f3f2bf424a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ไม่สามารถเปิดคู่มือการตั้งค่าได้"</string>
<string name="native_56c41c606c44f3fc" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ถาม OpenClaw %1$s"</string>
<string name="native_56c4c3d30ceda56e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Wait for Agents"</string>
<string name="native_56ef8f20955f2564" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ที่อยู่"</string>
<string name="native_5702e1d243eeb4e9" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"งานตามกำหนดการที่สร้างบน Gateway จะปรากฏที่นี่"</string>
<string name="native_572739b05d138f8a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"กำลังแสดงส่วนบันทึกล่าสุด"</string>
@@ -1028,6 +1029,7 @@
<string name="native_aed306fbb63c0c68" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"รวมแพ็กเกจของ Android และแพ็กเกจเบื้องหลัง"</string>
<string name="native_aedf1cf58372f943" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateway ของคุณพร้อมใช้งานแล้ว"</string>
<string name="native_aeffb9182e3d7f3d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ทริกเกอร์แล้ว"</string>
<string name="native_af1f10e9b3af3250" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Structured Output"</string>
<string name="native_af60a38d6b5866ce" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ใช้เวลานานกว่าที่คาดไว้\nตรวจสอบว่า Gateway กำลังทำงานและสามารถเข้าถึงได้"</string>
<string name="native_af7deaf2c81b107d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ไม่มีงานเบื้องหลังสำหรับเอเจนต์นี้"</string>
<string name="native_afb118fcb16c3727" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"กำลังเชื่อมต่อใหม่"</string>
@@ -513,6 +513,7 @@
<string name="native_565339bc4d33d728" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Tamam"</string>
<string name="native_56a508f3f2bf424a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Kurulum kılavuzu açılamadı."</string>
<string name="native_56c41c606c44f3fc" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OpenClaw\'a %1$s sor"</string>
<string name="native_56c4c3d30ceda56e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Wait for Agents"</string>
<string name="native_56ef8f20955f2564" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Adres"</string>
<string name="native_5702e1d243eeb4e9" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateway üzerinde oluşturulan zamanlanmış işler burada görünür."</string>
<string name="native_572739b05d138f8a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"En son günlük parçası gösteriliyor."</string>
@@ -1028,6 +1029,7 @@
<string name="native_aed306fbb63c0c68" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Android ve arka plan paketlerini dahil edin."</string>
<string name="native_aedf1cf58372f943" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateway\'iniz hazır."</string>
<string name="native_aeffb9182e3d7f3d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Tetiklendi"</string>
<string name="native_af1f10e9b3af3250" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Structured Output"</string>
<string name="native_af60a38d6b5866ce" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Bu işlem beklenenden uzun sürüyor.\nGateway\'in çalıştığını ve erişilebilir olduğunu kontrol edin."</string>
<string name="native_af7deaf2c81b107d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Bu temsilci için arka plan görevi yok."</string>
<string name="native_afb118fcb16c3727" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Yeniden bağlanıyor"</string>
@@ -513,6 +513,7 @@
<string name="native_565339bc4d33d728" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ОК"</string>
<string name="native_56a508f3f2bf424a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Не вдалося відкрити посібник із налаштування."</string>
<string name="native_56c41c606c44f3fc" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"запитати OpenClaw %1$s"</string>
<string name="native_56c4c3d30ceda56e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Wait for Agents"</string>
<string name="native_56ef8f20955f2564" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Адреса"</string>
<string name="native_5702e1d243eeb4e9" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Заплановані завдання, створені на Gateway, з’являться тут."</string>
<string name="native_572739b05d138f8a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Показано останній фрагмент журналу."</string>
@@ -1028,6 +1029,7 @@
<string name="native_aed306fbb63c0c68" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Включати Android і фонові пакети."</string>
<string name="native_aedf1cf58372f943" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Ваш Gateway готовий."</string>
<string name="native_aeffb9182e3d7f3d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Активовано"</string>
<string name="native_af1f10e9b3af3250" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Structured Output"</string>
<string name="native_af60a38d6b5866ce" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Це займає більше часу, ніж очікувалося.\nПереконайтеся, що Gateway запущено й він доступний."</string>
<string name="native_af7deaf2c81b107d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Для цього агента немає фонових завдань."</string>
<string name="native_afb118fcb16c3727" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Повторне підключення"</string>
@@ -513,6 +513,7 @@
<string name="native_565339bc4d33d728" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OK"</string>
<string name="native_56a508f3f2bf424a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Không thể mở hướng dẫn thiết lập."</string>
<string name="native_56c41c606c44f3fc" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"hỏi OpenClaw %1$s"</string>
<string name="native_56c4c3d30ceda56e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Wait for Agents"</string>
<string name="native_56ef8f20955f2564" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Địa chỉ"</string>
<string name="native_5702e1d243eeb4e9" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Công việc theo lịch được tạo trên Gateway sẽ xuất hiện tại đây."</string>
<string name="native_572739b05d138f8a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Đang hiển thị phần nhật ký mới nhất."</string>
@@ -1028,6 +1029,7 @@
<string name="native_aed306fbb63c0c68" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Bao gồm Android và các gói chạy nền."</string>
<string name="native_aedf1cf58372f943" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateway của bạn đã sẵn sàng."</string>
<string name="native_aeffb9182e3d7f3d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Đã kích hoạt"</string>
<string name="native_af1f10e9b3af3250" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Structured Output"</string>
<string name="native_af60a38d6b5866ce" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Quá trình này mất nhiều thời gian hơn dự kiến.\nHãy kiểm tra để đảm bảo Gateway đang chạy và có thể truy cập được."</string>
<string name="native_af7deaf2c81b107d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Không có tác vụ nền nào cho tác nhân này."</string>
<string name="native_afb118fcb16c3727" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Đang kết nối lại"</string>
@@ -513,6 +513,7 @@
<string name="native_565339bc4d33d728" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"确定"</string>
<string name="native_56a508f3f2bf424a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"无法打开设置指南。"</string>
<string name="native_56c41c606c44f3fc" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"询问 OpenClaw %1$s"</string>
<string name="native_56c4c3d30ceda56e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Wait for Agents"</string>
<string name="native_56ef8f20955f2564" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"地址"</string>
<string name="native_5702e1d243eeb4e9" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"在 Gateway 上创建的计划任务将显示在此处。"</string>
<string name="native_572739b05d138f8a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"正在显示最新的日志块。"</string>
@@ -1028,6 +1029,7 @@
<string name="native_aed306fbb63c0c68" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"包括 Android 和后台包。"</string>
<string name="native_aedf1cf58372f943" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"您的 Gateway 已准备就绪。"</string>
<string name="native_aeffb9182e3d7f3d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"已触发"</string>
<string name="native_af1f10e9b3af3250" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Structured Output"</string>
<string name="native_af60a38d6b5866ce" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"所需时间比预期更长。\n请检查 Gateway 是否正在运行且可访问。"</string>
<string name="native_af7deaf2c81b107d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"此代理没有后台任务。"</string>
<string name="native_afb118fcb16c3727" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"正在重新连接"</string>
@@ -513,6 +513,7 @@
<string name="native_565339bc4d33d728" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"確定"</string>
<string name="native_56a508f3f2bf424a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"無法開啟設定指南。"</string>
<string name="native_56c41c606c44f3fc" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"詢問 OpenClaw %1$s"</string>
<string name="native_56c4c3d30ceda56e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Wait for Agents"</string>
<string name="native_56ef8f20955f2564" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"位址"</string>
<string name="native_5702e1d243eeb4e9" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"在 Gateway 上建立的排程工作將顯示於此。"</string>
<string name="native_572739b05d138f8a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"正在顯示最新的記錄區塊。"</string>
@@ -1028,6 +1029,7 @@
<string name="native_aed306fbb63c0c68" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"包含 Android 和背景套件。"</string>
<string name="native_aedf1cf58372f943" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"您的 Gateway 已準備就緒。"</string>
<string name="native_aeffb9182e3d7f3d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"已觸發"</string>
<string name="native_af1f10e9b3af3250" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Structured Output"</string>
<string name="native_af60a38d6b5866ce" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"所需時間比預期更長。\n請確認 Gateway 正在執行且可連線。"</string>
<string name="native_af7deaf2c81b107d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"此代理程式沒有背景任務。"</string>
<string name="native_afb118fcb16c3727" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"正在重新連線"</string>
@@ -513,6 +513,7 @@
<string name="native_565339bc4d33d728" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OK"</string>
<string name="native_56a508f3f2bf424a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Could not open setup guide."</string>
<string name="native_56c41c606c44f3fc" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ask OpenClaw %1$s"</string>
<string name="native_56c4c3d30ceda56e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Wait for Agents"</string>
<string name="native_56ef8f20955f2564" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Address"</string>
<string name="native_5702e1d243eeb4e9" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Scheduled work created on the gateway will appear here."</string>
<string name="native_572739b05d138f8a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Showing the latest log chunk."</string>
@@ -1028,6 +1029,7 @@
<string name="native_aed306fbb63c0c68" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Include Android and background packages."</string>
<string name="native_aedf1cf58372f943" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Your Gateway is ready."</string>
<string name="native_aeffb9182e3d7f3d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Triggered"</string>
<string name="native_af1f10e9b3af3250" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Structured Output"</string>
<string name="native_af60a38d6b5866ce" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"This is taking longer than expected.\nCheck that the Gateway is running and reachable."</string>
<string name="native_af7deaf2c81b107d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"No background tasks for this agent."</string>
<string name="native_afb118fcb16c3727" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Reconnecting"</string>
@@ -686,6 +686,21 @@
"cleanup"
]
},
"agents_wait": {
"emoji": "⏳",
"title": "Wait for Agents",
"detailKeys": [
"ids",
"timeoutSeconds"
]
},
"structured_output": {
"emoji": "🧾",
"title": "Structured Output",
"detailKeys": [
"result"
]
},
"subagents": {
"emoji": "🤖",
"title": "Subagents",
@@ -2222,6 +2222,8 @@ public struct AgentParams: Codable, Sendable {
public let sessioneffects: AnyCodable?
public let sourcereplydeliverymode: AnyCodable?
public let disablemessagetool: Bool?
public let swarmcollector: Bool?
public let swarmoutputschema: [String: AnyCodable]?
public let forcerestartsafetools: Bool?
public let voicewaketrigger: String?
public let idempotencykey: String
@@ -2267,6 +2269,8 @@ public struct AgentParams: Codable, Sendable {
sessioneffects: AnyCodable? = nil,
sourcereplydeliverymode: AnyCodable? = nil,
disablemessagetool: Bool? = nil,
swarmcollector: Bool? = nil,
swarmoutputschema: [String: AnyCodable]? = nil,
forcerestartsafetools: Bool? = nil,
voicewaketrigger: String? = nil,
idempotencykey: String,
@@ -2311,6 +2315,8 @@ public struct AgentParams: Codable, Sendable {
self.sessioneffects = sessioneffects
self.sourcereplydeliverymode = sourcereplydeliverymode
self.disablemessagetool = disablemessagetool
self.swarmcollector = swarmcollector
self.swarmoutputschema = swarmoutputschema
self.forcerestartsafetools = forcerestartsafetools
self.voicewaketrigger = voicewaketrigger
self.idempotencykey = idempotencykey
@@ -2357,6 +2363,8 @@ public struct AgentParams: Codable, Sendable {
case sessioneffects = "sessionEffects"
case sourcereplydeliverymode = "sourceReplyDeliveryMode"
case disablemessagetool = "disableMessageTool"
case swarmcollector = "swarmCollector"
case swarmoutputschema = "swarmOutputSchema"
case forcerestartsafetools = "forceRestartSafeTools"
case voicewaketrigger = "voiceWakeTrigger"
case idempotencykey = "idempotencyKey"
+24
View File
@@ -5107,6 +5107,30 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
- H2: Patch points
- H2: Focused tests
## plan/swarms.md
- Route: /plan/swarms
- Headings:
- H1: Swarms — agent fan-out and orchestration in code mode
- H2: 1. What and why
- H2: 2. Decisions (maintainer, 2026-07-17)
- H2: 3. Architecture overview
- H2: 4. Config gate (v1)
- H2: 5. Core: collector-mode spawn + agentswait (v1)
- H3: 5.1 sessionsspawn additions (all gated on swarm enabled)
- H3: 5.2 Approvals fail-closed
- H3: 5.3 agentswait tool (new, gated)
- H3: 5.4 Caps enforcement
- H2: 6. Testing contract (v1, lane A)
- H2: 7. QuickJS guest surface (lane B, after core)
- H2: 8. Codex harness projection (later lane)
- H2: 9. Persistence and retention
- H2: 10. Progress surface ("the dots") — later lane
- H2: 11. Labs page (Control UI, independent lane)
- H2: 12. Placement (later)
- H2: 13. Non-goals
- H2: 14. Build phases / PR slicing
## plan/ui-channels.md
- Route: /plan/ui-channels
+262
View File
@@ -0,0 +1,262 @@
# Swarms — agent fan-out and orchestration in code mode
Status: implementation spec (v1 in progress). This document is the frozen build
spec. It will be rewritten as user-facing docs (`docs/tools/swarm.md`) once the
feature lands and is tested. Feature-gated behind `tools.swarm` (default off);
`main` remains shippable at every point.
## 1. What and why
A **swarm** is many subagents orchestrated deterministically from a code-mode
script: fan out N readers, verify findings adversarially, synthesize through a
stateful prioritizer, loop on decision gates. Control flow (`Promise.all`,
`while`, `if`) _is_ the orchestration — there is deliberately **no graph DSL,
no new mode, no new top-level tool surface**.
OpenClaw code mode (QuickJS-WASI, snapshot/resume, bridge requests) is the
substrate. A parked bridge call survives VM snapshot, gateway restart, and
resumes exactly where it stopped — stronger than journal-replay designs, with
no determinism constraints on scripts.
Naming: product/docs name is **Swarm**. Code identifiers stay literal:
`agents.*` guest API, `tools.swarm` config, `swarm` group columns.
## 2. Decisions (maintainer, 2026-07-17)
- Cost: enforced config caps; per-swarm token budget optional. No mandatory budget.
- Approvals: children run **fail-closed / non-interactive**. Approval-requiring
actions are denied; the denial is reported in the child result; the script
decides. No operator prompt spam from fan-out.
- v1 is model-written ad-hoc scripts only. Saved/named workflows, CLI/cron
entry: later (headless code mode already exists for cron).
- Child identity: dedicated worker agent by default via `tools.swarm.defaultAgentId`
config (validated against existing subagent target allowlist); per-spawn
`agentId` override. Core ships no bundled agent id; docs recommend a lean
`worker` agent config.
- No Codex source changes. Codex harness uses the spawn/wait idiom (§8).
## 3. Architecture overview
```
code-mode script (QuickJS VM, gateway) Codex V8 script (codex process)
agents.run(...) ── parked bridge call tools.sessions_spawn / tools.agents_wait
│ │ item/tool/call RPC (≤600s each)
▼ ▼
CORE (harness-agnostic, this repo)
sessions_spawn {collect:true, outputSchema, fastMode, groupId}
agents_wait {ids, timeoutSeconds}
subagent registry (SQLite): collector completion records, swarm group id
children = ordinary subagent sessions (lane-capped, fail-closed approvals)
sessions.changed SSE ──► Control UI dots / sidebar / channel status message
```
One canonical owner of spawn/complete/settle semantics (core tools + registry).
Two await transports: QuickJS parks a bridge call indefinitely (snapshot);
Codex polls `agents_wait` in bounded RPCs.
## 4. Config gate (v1)
New `tools.swarm` (global + per-agent override, same merge pattern as
`tools.codeMode`):
```jsonc
"tools": {
"swarm": {
"enabled": false, // master gate, default OFF
"maxConcurrent": 8, // children running at once (swarm lane cap)
"maxChildrenPerGroup": 50, // live children per swarm group
"maxTotalPerGroup": 200, // lifetime spawn count per group (runaway backstop)
"waitTimeoutSecondsMax": 600,
"defaultAgentId": "" // optional; child agent id when spawn omits agentId
}
}
```
- Zod: union `boolean | strict object` like `CodeModeSchema`
(`src/config/zod-schema.agent-runtime.ts`); `swarm: true``{enabled: true}`.
- Types in `src/config/types.tools.ts` (both per-agent and top-level `tools`),
labels in `schema.labels.ts`, help in `schema.help.runtime.ts`.
- Resolution helper `resolveSwarmConfig(cfg, agentId)` mirroring
`resolveCodeModeConfig` (`src/agents/code-mode.ts:215`), clamping all numbers.
- Gate effects when disabled: `agents_wait` tool absent from catalogs;
`collect`/`outputSchema`/`fastMode`/`groupId` params on `sessions_spawn`
rejected with a clear error naming the config key. No other behavior change.
- `defaultAgentId` is validated through `resolveSubagentAllowedTargetIds`
(`src/agents/subagent-target-policy.ts`); unknown id → spawn error, not fallback.
## 5. Core: collector-mode spawn + `agents_wait` (v1)
### 5.1 `sessions_spawn` additions (all gated on swarm enabled)
- `collect: boolean` — when true, the child run is registered with
`expectsCompletionMessage: false` and a **collector completion record**
instead of announce/steering delivery. Tool returns `{ runId, sessionKey }`
immediately. No channel/thread binding.
- `outputSchema: object` — JSON Schema. Child gets a synthetic
`structured_output` tool appended to its tool surface; system-prompt addendum
instructs it to call it exactly once with its final result. On validation
failure the child gets one nudge retry; after that the completion record
carries `structured: undefined` plus the raw text and a `schemaError`.
- `fastMode: true | "auto" | false` — threaded into the child session patch
alongside model/thinking via `resolveSubagentModelAndThinkingPlan`
(`src/agents/subagent-spawn-plan.ts`), using the existing `FastMode` axis
(`src/shared/fast-mode.ts`). Omitted = inherit.
- `groupId: string` — swarm group stamp. Defaults to
`swarm:<requesterSessionKey>:<runId-of-requesting-run>`. Persisted on the
registry record and the child session row. Used for caps, listing, batch
archive, and the dots.
- `label: string` already exists — surfaces in dots and `subagents list`.
- Child agent id: `params.agentId` → else `tools.swarm.defaultAgentId` → else
requester agent (existing behavior).
### 5.2 Approvals fail-closed
Collector children run with a non-interactive approval context: any tool call
that would require operator approval resolves as a structured denial
(`approval_required`) visible to the child, which is expected to report the
blockage in its result. Implementation: reuse the existing exec/tool approval
policy plumbing with a forced `deny` resolver for collector-mode child runs.
No approval events are emitted to operator surfaces from collector children.
### 5.3 `agents_wait` tool (new, gated)
```
agents_wait({ ids: string[], timeoutSeconds?: number })
→ {
completed: [{ runId, status: "done"|"failed"|"killed"|"timeout",
result: string, structured?: unknown, schemaError?: string,
sessionKey, label?, usage?: {inputTokens, outputTokens} }],
pending: string[]
}
```
- Returns as soon as **at least one** id completes (first-completion / race
semantics, enables pipelines), or on timeout with `completed: []`.
- `timeoutSeconds` default 30, clamped to `waitTimeoutSecondsMax`.
- Idempotent: already-completed ids return their records again (records are
kept until group archive). Unknown id → per-id error entry, not a throw.
- Ownership: only the session that spawned a run (or its parent chain) may wait
on it — same ownership rule as `wait` in code mode (`code-mode.ts:1684`).
- Registry: completion records live in the existing subagent registry SQLite
store (`subagent-registry.store.sqlite.ts`) — new fields, no new store, no
schema-version bump (additive columns only; see §9 constraint).
### 5.4 Caps enforcement
- `maxConcurrent`: collector children run on the existing subagent lane but
counted per swarm group; spawns beyond the cap queue FIFO (host-side, in the
spawn path — return runId immediately, run starts when a slot frees).
- `maxChildrenPerGroup` / `maxTotalPerGroup`: spawn rejects with a typed error
once exceeded; the error text names the config key.
- Depth: collector children keep `DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH` semantics
(children are leaves unless nesting explicitly configured).
## 6. Testing contract (v1, lane A)
- Unit: config resolution/clamping; gate rejections when disabled; groupId
defaulting; cap enforcement (queue + reject); wait race semantics; wait
idempotency; ownership denial; structured-output validation + nudge retry +
schemaError path; fastMode plumbing into session patch; defaultAgentId
validation.
- Integration (vitest, mock model runtime): spawn 3 collector children, wait
in a loop, assert first-completion ordering and final drain; gateway-restart
simulation: registry reload → wait resolves from persisted completion.
- All tests colocated `*.test.ts`; no live model calls.
## 7. QuickJS guest surface (lane B, after core)
- Guest globals installed in `CONTROLLER_SOURCE`
(`src/agents/code-mode.worker.ts:190-374`), reserved names added in
`code-mode-namespaces.ts`:
- `agents.run(prompt, opts) → Promise<result|structured>` — sugar:
collector spawn + parked await on a dedicated bridge method (`agentWait`)
that the host settles on completion (no polling; snapshot-safe).
- `agents.session(system, opts) → Promise<handle>`;
`handle.send(input, opts) → Promise<...>`; `handle.close()`. (v1.1 —
ships after run(); uses `mode:"session"` + per-turn collector records.)
- `phase(title)`, `log(message)` — fire-and-forget bridge notifications →
swarm progress events.
- Bridge methods added to `CodeModeBridgeMethod` (`code-mode.ts:91`):
`agentSpawn`, `agentWait`, `swarmNote`. `agentSpawn`/`agentWait` are
replay-safe **by construction**: idempotency key `(codeModeRunId, bridgeId)`
stored on the registry record; restart re-settles from persisted completions
and never double-spawns.
- Pending `agentWait` bridge calls extend the run's snapshot TTL (pending
agent set is the signal; no flag).
- `API.read("agents.d.ts")` virtual file documents the typed surface + the
fan-out / gate / cycle idioms (`createCodeModeApiVirtualFiles`,
`code-mode-namespaces.ts:876`).
## 8. Codex harness projection (later lane)
- `sessions_spawn` (with new params) and `agents_wait` flow through the
existing dynamic-tool bridge; inside Codex code-mode scripts they appear as
`tools.*` automatically (verified: `codex-rs/code-mode/src/runtime/globals.rs:14-65`,
`codex-rs/core/src/tools/spec_plan.rs:448-507`).
- `agents_wait` gets the long dynamic-tool timeout class (600s cap;
`extensions/codex/src/app-server/dynamic-tool-execution.ts:37-39`) and is
marked timeout/replay-safe.
- Group key for Codex parents: `swarm:<parentSessionKey>:<turnId>`.
- Codex-native `spawn_agent` subagents coexist; their task-mirror rows feed
the same progress surface.
## 9. Persistence and retention
- No new stores. Registry records extend the existing subagent registry
SQLite tables; children are ordinary `sessions` rows. Additive columns only
— **any change requiring a SQLite schema-version bump needs explicit
maintainer sign-off first** (repo policy).
- Swarm group id on registry record + child session metadata.
- Retention: completed collector records survive until **group archive**:
when the parent run finishes (or TTL expires), the group's children archive
as a batch (extend the existing `DEFAULT_SUBAGENT_ARCHIVE_AFTER_MINUTES`
sweep to operate per group).
## 10. Progress surface ("the dots") — later lane
- Implicit, harness-driven. Derived from existing `sessions.changed` SSE +
registry; `phase`/`log` notes add semantics. No agent-driven rendering.
- Control UI: `swarm` renderer in the workspace widget family
(`ui/src/lib/workspace/widgets/`) — dot grid grouped by phase, narrator
line, per-dot status/label/model; sidebar child-tree unchanged.
- Channels: one throttled edited status message per group (follow
`docs/concepts/streaming.md`; never per-child messages).
## 11. Labs page (Control UI, independent lane)
Settings → **Labs**: experimental feature toggles, first entries **Code Mode**
and **Swarm**. Each row: name, one-line description, docs link, toggle wired
via the existing `config.patch` RPC (RFC 7396 merge-patch — set
`tools.codeMode.enabled` / `tools.swarm.enabled`), plus a "restart required"
hint when applicable. Discoverable, but copy makes the experimental status
clear. i18n: all strings through the normal `en.ts` + sync pipeline.
## 12. Placement (later)
- `placement` opt on spawn: `"local"` (default) | `"cloud:<profile>"` via
existing worker-environment dispatch (`sessions.dispatch`); pooled placement
later if shared-box SSH-sandbox children prove insufficient.
- Orchestrator VM always stays on the gateway; settle/dots/budget are
placement-blind.
## 13. Non-goals
- No graph DSL — control flow is the graph (deliberate, documented).
- No Codex source changes; no reuse of Codex Code Mode internals.
- No saved/named workflows in v1; no CLI entry point.
- No per-child operator approval bubbling.
- No 1:1 cloud provisioning at fan-out scale.
- No steady-state runtime compat shims; swarm is new surface, gated.
## 14. Build phases / PR slicing
1. **Lane A (core)**: §4 config + §5 spawn/wait/caps/approvals + §6 tests.
2. **Lane C (Labs page)**: §11 — independent, can land first.
3. **Lane B (QuickJS surface)**: §7 — after A contracts land.
4. Dots renderer (§10), Codex projection (§8), `agents.session` (§7 v1.1),
placement (§12), user docs rewrite — follow-up PRs in that order.
Each PR: green CI, `$autoreview` clean, gated off by default, main shippable.
@@ -326,6 +326,8 @@ export const AgentParamsSchema = closedObject({
Type.Union([Type.Literal("automatic"), Type.Literal("message_tool_only")]),
),
disableMessageTool: Type.Optional(Type.Boolean()),
swarmCollector: Type.Optional(Type.Boolean()),
swarmOutputSchema: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
// Host-owned recovery turns can force every Code Mode exec onto the
// restart-safe path even if the model omits or clears the tool argument.
forceRestartSafeTools: Type.Optional(Type.Boolean()),
+6 -3
View File
@@ -115,7 +115,8 @@ const defaultPublicDeprecatedExportsByEntrypointBudget = Object.freeze({
"config-runtime": 123,
"config-contracts": 1,
// +1 each: unified implicit-mention config and AgentThinkingLevel types.
"config-types": 427,
// +1: SwarmConfig mirrors the public tools.swarm config contract.
"config-types": 428,
"config-schema": 3,
"reply-dedupe": 1,
"inbound-reply-dispatch": 26,
@@ -280,7 +281,8 @@ export function readPluginSdkSurfaceBudgets(env = process.env) {
// +1: AgentThinkingLevel shared by default-turn and compaction config.
// +9: shared ingress monitor factory and lifecycle/result contracts across
// channel-outbound and its two deprecated compatibility barrels.
8177,
// +1: SwarmConfig exposes the tools.swarm contract through config-types.
8178,
env,
),
publicFunctionExports: readPluginSdkSurfaceBudgetEnv(
@@ -347,7 +349,8 @@ export function readPluginSdkSurfaceBudgets(env = process.env) {
// +1: AgentModelPolicyConfig mirrored by deprecated config-types.
// +6: ingress monitor lifecycle/result contracts through deprecated channel barrels.
// +1: AgentThinkingLevel mirrored by deprecated config-types.
3013,
// +1: SwarmConfig mirrored by deprecated config-types.
3014,
env,
),
publicWildcardReexports: readPluginSdkSurfaceBudgetEnv(
@@ -800,6 +800,33 @@ describe("before_tool_call loop detection behavior", () => {
mockCallGateway.mockReset();
});
it("returns a structured denial without an approval request in deny mode", async () => {
const onResolution = vi.fn();
hookRunner.hasHooks.mockImplementation((hookName: string) => hookName === "before_tool_call");
hookRunner.runBeforeToolCall.mockResolvedValueOnce({
requireApproval: { title: "Approve", description: "Approval required", onResolution },
});
const mockCallGateway = vi.mocked(callGatewayTool);
const execute = vi.fn().mockResolvedValue({ content: [{ type: "text", text: "ok" }] });
const tool = wrapToolWithBeforeToolCallHook(
{ name: "exec", execute } as unknown as AnyAgentTool,
{ agentId: "main", sessionKey: "session-key", runId: "run-1" },
{ approvalMode: "deny" },
);
const result = await tool.execute("tool-call-deny", { command: "private" });
expect(result.details).toEqual({
status: "blocked",
deniedReason: "plugin-approval",
reason: "approval_required",
});
expect(execute).not.toHaveBeenCalled();
expect(mockCallGateway).not.toHaveBeenCalled();
expect(onResolution).toHaveBeenCalledWith(PluginApprovalResolutions.DENY);
mockCallGateway.mockReset();
});
it.each([
{
label: "failure",
+16 -6
View File
@@ -219,7 +219,7 @@ export type DeferredPluginToolApproval = {
};
type BeforeToolCallWrapperOptions = {
approvalMode?: "request" | "report" | "defer";
approvalMode?: "request" | "report" | "deny" | "defer";
emitDiagnostics: boolean;
};
type BeforeToolCallPreparingTool = AnyAgentTool & {
@@ -1215,7 +1215,7 @@ export function cancelDeferredPluginToolApproval(
async function resolveBeforeToolCallApprovalOutcome(params: {
result: PluginHookBeforeToolCallResult | undefined;
approvalMode?: "request" | "report" | "defer";
approvalMode?: "request" | "report" | "deny" | "defer";
toolName: string;
toolCallId?: string;
ctx?: HookContext;
@@ -1252,6 +1252,16 @@ async function resolveBeforeToolCallApprovalOutcome(params: {
params: params.baseParams,
};
}
if (params.approvalMode === "deny") {
notifyPluginApprovalResolution(approval, PluginApprovalResolutions.DENY);
return {
blocked: true,
kind: "veto",
deniedReason: "plugin-approval",
reason: "approval_required",
params: params.baseParams,
};
}
return await requestPluginToolApproval({
approval,
toolName: params.toolName,
@@ -1266,7 +1276,7 @@ async function resolveBeforeToolCallApprovalOutcome(params: {
async function resolveSkillWorkshopApprovalForFinalParams(params: {
toolName: string;
params: unknown;
approvalMode?: "request" | "report" | "defer";
approvalMode?: "request" | "report" | "deny" | "defer";
toolCallId?: string;
ctx?: HookContext;
signal?: AbortSignal;
@@ -1437,7 +1447,7 @@ export async function runBeforeToolCallHook(args: {
toolCallId?: string;
ctx?: HookContext;
signal?: AbortSignal;
approvalMode?: "request" | "report" | "defer";
approvalMode?: "request" | "report" | "deny" | "defer";
}): Promise<HookOutcome> {
const toolName = normalizeToolName(args.toolName || "tool");
const params = args.params;
@@ -1750,7 +1760,7 @@ export async function runBeforeToolCallHook(args: {
export function wrapToolWithBeforeToolCallHook(
tool: AnyAgentTool,
ctx?: HookContext,
options: { approvalMode?: "request" | "report"; emitDiagnostics?: boolean } = {},
options: { approvalMode?: "request" | "report" | "deny"; emitDiagnostics?: boolean } = {},
): AnyAgentTool {
const execute = tool.execute;
if (!execute) {
@@ -2049,7 +2059,7 @@ export function wrapToolWithBeforeToolCallHook(
export function rewrapToolWithBeforeToolCallHook(
tool: AnyAgentTool,
ctx?: HookContext,
options: { approvalMode?: "request" | "report"; emitDiagnostics?: boolean } = {},
options: { approvalMode?: "request" | "report" | "deny"; emitDiagnostics?: boolean } = {},
): AnyAgentTool {
const taggedTool = tool as unknown as Record<symbol, unknown>;
const source = taggedTool[BEFORE_TOOL_CALL_SOURCE_TOOL];
+30 -3
View File
@@ -416,6 +416,10 @@ type OpenClawCodingToolsOptions = {
inboundEventKind?: InboundEventKind;
/** If true, omit the message tool from the tool list. */
disableMessageTool?: boolean;
/** Collector runs never open operator approval flows. */
swarmCollector?: boolean;
/** Synthetic structured_output schema for collector runs. */
swarmOutputSchema?: Record<string, unknown>;
/** Keep the message tool available even when the selected profile omits it. */
forceMessageTool?: boolean;
/** Include the heartbeat response tool for structured heartbeat outcomes. */
@@ -762,6 +766,7 @@ function createOpenClawCodingToolsInternal(options?: OpenClawCodingToolsOptions)
channelContext: options?.channelContext,
accountId: options?.agentAccountId,
approvalReviewerDeviceId: options?.approvalReviewerDeviceId,
nonInteractiveApproval: options?.swarmCollector,
backgroundMs: options?.exec?.backgroundMs ?? execConfig.backgroundMs,
timeoutSec: options?.exec?.timeoutSec ?? execConfig.timeoutSec,
approvalRunningNoticeMs:
@@ -879,7 +884,7 @@ function createOpenClawCodingToolsInternal(options?: OpenClawCodingToolsOptions)
modelId: options?.modelId,
modelHasVision: options?.modelHasVision,
requireExplicitMessageTarget: options?.requireExplicitMessageTarget,
disableMessageTool: options?.disableMessageTool,
disableMessageTool: options?.disableMessageTool || options?.swarmCollector,
requesterAgentIdOverride: agentId,
allowGatewaySubagentBinding: options?.allowGatewaySubagentBinding,
clientCaps: options?.clientCaps,
@@ -997,7 +1002,9 @@ function createOpenClawCodingToolsInternal(options?: OpenClawCodingToolsOptions)
sourceReplyDeliveryMode: options?.sourceReplyDeliveryMode,
taskSuggestionDeliveryMode: options?.taskSuggestionDeliveryMode,
inboundEventKind: options?.inboundEventKind,
disableMessageTool: options?.disableMessageTool,
disableMessageTool: options?.disableMessageTool || options?.swarmCollector,
swarmCollector: options?.swarmCollector,
swarmOutputSchema: options?.swarmOutputSchema,
enableHeartbeatTool,
disablePluginTools: !includePluginTools,
wrapBeforeToolCallHook: false,
@@ -1020,6 +1027,10 @@ function createOpenClawCodingToolsInternal(options?: OpenClawCodingToolsOptions)
...toolSearchTools,
];
options?.recordToolPrepStage?.("openclaw-tools");
const swarmStructuredOutputTool =
options?.swarmCollector && options.swarmOutputSchema
? tools.find((tool) => tool.name === "structured_output")
: undefined;
const toolsForMemoryFlush: AnyAgentTool[] = isMemoryFlushRun && memoryFlushWritePath ? [] : tools;
if (isMemoryFlushRun && memoryFlushWritePath) {
for (const tool of tools) {
@@ -1118,8 +1129,21 @@ function createOpenClawCodingToolsInternal(options?: OpenClawCodingToolsOptions)
const authorizedTools = applyDelegationCapability(
mergeAgentRingZeroTools(ringZeroTools, subagentFiltered),
options?.delegationCapability,
).filter(
(tool) =>
!options?.swarmCollector ||
(tool.name !== "ask_user" && tool.name !== "sessions_send" && tool.name !== "sessions_yield"),
);
if (
swarmStructuredOutputTool &&
!authorizedTools.some((tool) => tool.name === swarmStructuredOutputTool.name)
) {
// Collector output is a run contract, not an operator-configurable capability.
authorizedTools.push(swarmStructuredOutputTool);
}
if (shouldInheritEffectiveToolAllowlist) {
// Snapshot exporter only: this copies authorizedTools for descendants and
// never filters the mandatory structured_output tool from this turn.
replaceWithEffectiveToolAllowlist(inheritedToolAllowlist, authorizedTools);
}
if (shouldCaptureCronCreatorToolAllowlist) {
@@ -1167,7 +1191,10 @@ function createOpenClawCodingToolsInternal(options?: OpenClawCodingToolsOptions)
onToolOutcome: options?.onToolOutcome,
allocateToolOutcomeOrdinal: options?.allocateToolOutcomeOrdinal,
};
const hookOptions = { emitDiagnostics: options?.emitBeforeToolCallDiagnostics };
const hookOptions = {
emitDiagnostics: options?.emitBeforeToolCallDiagnostics,
...(options?.swarmCollector ? { approvalMode: "deny" as const } : {}),
};
const withHooks = normalized.map((tool) =>
isToolWrappedWithBeforeToolCallHook(tool)
? rewrapToolWithBeforeToolCallHook(tool, hookContext, hookOptions)
@@ -109,6 +109,7 @@ type ProcessGatewayAllowlistParams = {
sessionStore?: string;
bashElevated?: ExecElevatedDefaults;
approvalReviewerDeviceId?: string;
nonInteractiveApproval?: boolean;
turnSourceChannel?: string;
turnSourceTo?: string;
turnSourceAccountId?: string;
@@ -708,6 +709,23 @@ export async function processGatewayAllowlist(
);
}
if (requiresAsk) {
if (params.nonInteractiveApproval) {
const text = `Exec denied (approval_required): ${params.command}`;
return {
deniedResult: {
content: [{ type: "text", text }],
details: {
status: "failed",
exitCode: null,
failureKind: "approval_required",
durationMs: 0,
aggregated: text,
timedOut: false,
cwd: params.workdir,
},
},
};
}
const [autoReviewSegment] = allowlistEval.segments;
const autoReviewArgv =
allowlistEval.segments.length === 1 &&
@@ -598,6 +598,35 @@ describe("executeNodeHostCommand", () => {
registerExecApprovalRequestForHostOrThrowMock.mockReset();
});
it("denies non-interactive approval requests without creating operator events", async () => {
resolveExecHostApprovalContextMock.mockReturnValue({
approvals: { allowlist: [], file: { version: 1, agents: {} } },
hostSecurity: "full",
hostAsk: "always",
askFallback: "deny",
});
const result = await executeNodeHostCommand({
command: "bun ./script.ts",
workdir: "/tmp/work",
env: {},
security: "full",
ask: "always",
nonInteractiveApproval: true,
defaultTimeoutSec: 30,
approvalRunningNoticeMs: 0,
warnings: [],
agentId: "collector",
sessionKey: "agent:collector:subagent:child",
});
expect(result.details).toMatchObject({
status: "failed",
failureKind: "approval_required",
});
expect(createAndRegisterDefaultExecApprovalRequestMock).not.toHaveBeenCalled();
expect(registerExecApprovalRequestForHostOrThrowMock).not.toHaveBeenCalled();
});
it("forwards prepared systemRunPlan on async node invoke after approval", async () => {
resolveExecHostApprovalContextMock.mockReturnValue({
approvals: { allowlist: [], file: { version: 1, agents: {} } },
+15
View File
@@ -224,6 +224,21 @@ export async function executeNodeHostCommand(
}) ||
inlineEvalHit !== null ||
requiresSecurityAuditSuppressionApproval;
if (requiresAsk && params.nonInteractiveApproval) {
const text = `Exec denied (approval_required): ${params.command}`;
return {
content: [{ type: "text", text }],
details: {
status: "failed",
exitCode: null,
failureKind: "approval_required",
durationMs: 0,
aggregated: text,
timedOut: false,
cwd: prepared.cwd,
},
};
}
if (requiresSecurityAuditSuppressionApproval) {
params.warnings.push(
"Warning: security audit suppression changes require explicit approval unless exec is running in yolo mode.",
@@ -22,6 +22,7 @@ export type ExecuteNodeHostCommandParams = {
sessionStore?: string;
bashElevated?: ExecElevatedDefaults;
approvalReviewerDeviceId?: string;
nonInteractiveApproval?: boolean;
turnSourceChannel?: string;
turnSourceTo?: string;
turnSourceAccountId?: string;
+2
View File
@@ -81,6 +81,8 @@ export type ExecToolDefaults = {
channelContext?: PluginHookChannelContext;
accountId?: string;
approvalReviewerDeviceId?: string;
/** Deny approval-requiring commands without creating operator approval events. */
nonInteractiveApproval?: boolean;
notifyOnExit?: boolean;
notifyOnExitEmptySuccess?: boolean;
cwd?: string;
+2
View File
@@ -1893,6 +1893,7 @@ export function createExecTool(
sessionStore: defaults?.sessionStore,
bashElevated: elevatedDefaults,
approvalReviewerDeviceId: defaults?.approvalReviewerDeviceId,
nonInteractiveApproval: defaults?.nonInteractiveApproval,
turnSourceChannel: defaults?.messageProvider,
turnSourceTo: defaults?.currentChannelId,
turnSourceAccountId: defaults?.accountId,
@@ -1949,6 +1950,7 @@ export function createExecTool(
sessionStore: defaults?.sessionStore,
bashElevated: elevatedDefaults,
approvalReviewerDeviceId: defaults?.approvalReviewerDeviceId,
nonInteractiveApproval: defaults?.nonInteractiveApproval,
turnSourceChannel: defaults?.messageProvider,
turnSourceTo: defaults?.currentChannelId,
turnSourceAccountId: defaults?.accountId,
+2
View File
@@ -952,6 +952,8 @@ export function runAgentAttempt(params: {
inputProvenance: params.opts.inputProvenance,
sourceReplyDeliveryMode: params.opts.sourceReplyDeliveryMode,
disableMessageTool: params.opts.disableMessageTool,
swarmCollector: params.opts.swarmCollector,
swarmOutputSchema: params.opts.swarmOutputSchema,
forceRestartSafeTools: params.opts.forceRestartSafeTools,
streamParams: params.opts.streamParams,
agentDir: params.agentDir,
+4
View File
@@ -149,6 +149,10 @@ export type AgentCommandOpts = {
sourceReplyDeliveryMode?: SourceReplyDeliveryMode;
/** Internal runs can omit the channel message tool entirely. */
disableMessageTool?: boolean;
/** Collector children fail closed instead of emitting operator approval requests. */
swarmCollector?: boolean;
/** Synthetic structured_output input schema for collector children. */
swarmOutputSchema?: Record<string, unknown>;
/** Restrict this reconstructed run to restart-safe tools. */
forceRestartSafeTools?: boolean;
/** Host-owned exact media set for a scoped automatic recovery delivery. */
@@ -17,6 +17,8 @@ const CORE_TOOL_FACTORY_DESCRIPTORS = [
{ name: "exec", family: "shell" },
{ name: "process", family: "shell" },
{ name: "agents_list", family: "openclaw" },
// Static factory identity only; runtime and tools.catalog apply the Swarm config gate.
{ name: "agents_wait", family: "openclaw" },
{ name: "ask_user", family: "openclaw" },
{ name: "openclaw", family: "openclaw" },
{ name: "computer", family: "openclaw" },
@@ -43,6 +45,7 @@ const CORE_TOOL_FACTORY_DESCRIPTORS = [
{ name: "sessions_send", family: "openclaw" },
{ name: "sessions_spawn", family: "openclaw" },
{ name: "sessions_yield", family: "openclaw" },
{ name: "structured_output", family: "openclaw" },
{ name: "skill_workshop", family: "openclaw" },
{ name: "spawn_task", family: "openclaw" },
{ name: "create_goal", family: "openclaw" },
@@ -63,6 +63,8 @@ export function prepareEmbeddedAttemptToolBase(params: {
attempt.toolsAllow,
{
forceMessageTool: forceDirectMessageTool,
forceToolNames:
attempt.swarmCollector && attempt.swarmOutputSchema ? ["structured_output"] : undefined,
},
);
const toolsEnabled = supportsModelTools(attempt.model);
@@ -290,6 +292,8 @@ export function prepareEmbeddedAttemptToolBase(params: {
taskSuggestionDeliveryMode: attempt.taskSuggestionDeliveryMode,
inboundEventKind: attempt.currentInboundEventKind,
disableMessageTool: attempt.disableMessageTool,
swarmCollector: attempt.swarmCollector,
swarmOutputSchema: attempt.swarmOutputSchema,
forceMessageTool: attempt.forceMessageTool,
enableHeartbeatTool: attempt.enableHeartbeatTool,
forceHeartbeatTool: attempt.forceHeartbeatTool,
@@ -75,6 +75,23 @@ describe("applyEmbeddedAttemptToolsAllow", () => {
]);
});
it("materializes host-required collector output through empty runtime allowlists", () => {
const tools = [{ name: "structured_output" }, { name: "read" }];
const toolsAllow = mergeForcedEmbeddedAttemptToolsAllow([], {
forceToolNames: ["structured_output"],
});
expect(toolsAllow).toEqual(["structured_output"]);
expect(applyEmbeddedAttemptToolsAllow(tools, toolsAllow).map((tool) => tool.name)).toEqual([
"structured_output",
]);
expect(resolveEmbeddedAttemptToolConstructionPlan({ toolsAllow })).toMatchObject({
constructTools: true,
includeCoreTools: true,
codingToolConstructionPlan: { includeOpenClawTools: true },
});
});
it("normalizes explicit toolsAllow entries before filtering", () => {
const tools = [{ name: "cron" }, { name: "read" }, { name: "message" }];
@@ -82,26 +82,26 @@ export function applyEmbeddedAttemptToolsAllow<T extends { name: string }>(
}
/**
* Adds the message tool to a narrowed allowlist when the caller must support
* forced source-reply delivery. Wildcard and undefined allowlists already cover
* message, while an empty allowlist becomes message-only.
* Adds host-required tools to a narrowed runtime allowlist. Wildcard and
* undefined allowlists already cover every required tool.
*/
export function mergeForcedEmbeddedAttemptToolsAllow(
toolsAllow: string[] | undefined,
params: { forceMessageTool?: boolean },
params: { forceMessageTool?: boolean; forceToolNames?: readonly string[] },
): string[] | undefined {
if (
!params.forceMessageTool ||
toolsAllow === undefined ||
hasWildcardToolAllowlist(toolsAllow)
) {
if (toolsAllow === undefined || hasWildcardToolAllowlist(toolsAllow)) {
return toolsAllow;
}
if (toolsAllow.length === 0) {
return ["message"];
const required = [
...(params.forceMessageTool ? ["message"] : []),
...(params.forceToolNames ?? []),
];
if (required.length === 0) {
return toolsAllow;
}
const normalized = new Set(toolsAllow.map((entry) => normalizeToolName(entry)));
return normalized.has("message") ? toolsAllow : [...toolsAllow, "message"];
const missing = required.filter((name) => !normalized.has(normalizeToolName(name)));
return missing.length === 0 ? toolsAllow : [...toolsAllow, ...missing];
}
function resolveCodingToolConstructionPlanForAllowlist(
@@ -140,6 +140,8 @@ export type RunEmbeddedAgentParams = {
requireExplicitMessageTarget?: boolean;
/** If true, omit the message tool from the tool list. */
disableMessageTool?: boolean;
swarmCollector?: boolean;
swarmOutputSchema?: Record<string, unknown>;
/** Restrict this reconstructed run to restart-safe tools. */
forceRestartSafeTools?: boolean;
/** Internal one-shot model probe mode: no tools, no workspace/chat prompt policy. */
@@ -342,6 +342,8 @@ export async function dispatchEmbeddedRunAttempt(input: {
...(params.systemAgentTool ? { systemAgentTool: params.systemAgentTool } : {}),
cleanupBundleMcpOnRunEnd: params.cleanupBundleMcpOnRunEnd,
disableMessageTool: params.disableMessageTool,
swarmCollector: params.swarmCollector,
swarmOutputSchema: params.swarmOutputSchema,
forceRestartSafeTools: params.forceRestartSafeTools,
forceMessageTool: params.forceMessageTool,
enableHeartbeatTool: params.enableHeartbeatTool,
@@ -62,6 +62,17 @@ describe("subagent spawn model + thinking plan", () => {
expect(plan.initialSessionPatch.thinkingLevel).toBe("high");
});
it("threads explicit fast mode into the initial child session patch", () => {
const plan = expectOkPlan(
resolveSubagentModelAndThinkingPlan({
cfg: createConfig(),
targetAgentId: "research",
fastMode: "auto",
}),
);
expect(plan.initialSessionPatch.fastMode).toBe("auto");
});
it("rejects invalid thinking levels before any runtime work", () => {
const plan = resolveSubagentModelAndThinkingPlan({
cfg: createConfig(),
+100
View File
@@ -0,0 +1,100 @@
// Verifies Swarm tools remain absent by default and appear only for gated runs.
import { describe, expect, it } from "vitest";
import { createOpenClawCodingTools } from "./agent-tools.js";
import { createOpenClawTools } from "./openclaw-tools.js";
function toolNames(options: NonNullable<Parameters<typeof createOpenClawTools>[0]>) {
return createOpenClawTools({
disableMessageTool: true,
disablePluginTools: true,
wrapBeforeToolCallHook: false,
...options,
}).map((tool) => tool.name);
}
describe("openclaw-tools Swarm gating", () => {
it("registers agents_wait only when tools.swarm is enabled", () => {
const base = { agentSessionKey: "agent:main:main" };
expect(toolNames(base)).not.toContain("agents_wait");
expect(toolNames({ ...base, config: { tools: { swarm: true } } })).toContain("agents_wait");
});
it("uses the effective requester agent override for the agents_wait gate", () => {
const base = {
agentSessionKey: "agent:main:main",
requesterAgentIdOverride: "worker",
};
expect(
toolNames({
...base,
config: {
tools: { swarm: false },
agents: { list: [{ id: "worker", tools: { swarm: true } }] },
},
}),
).toContain("agents_wait");
expect(
toolNames({
...base,
config: {
tools: { swarm: true },
agents: { list: [{ id: "worker", tools: { swarm: false } }] },
},
}),
).not.toContain("agents_wait");
});
it("injects structured_output only for schema-backed collector runs", () => {
const base = {
agentSessionKey: "agent:worker:subagent:child",
runId: "collector-run",
config: { tools: { swarm: true } },
};
expect(toolNames({ ...base, swarmCollector: true })).not.toContain("structured_output");
expect(
toolNames({
...base,
swarmCollector: true,
swarmOutputSchema: { type: "object", properties: { answer: { type: "string" } } },
}),
).toContain("structured_output");
});
it("keeps structured_output through restrictive child tool policy", () => {
const names = createOpenClawCodingTools({
sessionKey: "agent:worker:subagent:child",
runId: "collector-run",
config: { tools: { allow: ["read"], swarm: true } },
swarmCollector: true,
swarmOutputSchema: { type: "object", properties: { answer: { type: "string" } } },
}).map((tool) => tool.name);
expect(names).toContain("read");
expect(names).toContain("structured_output");
expect(names).not.toContain("exec");
});
it("omits the message tool for collector runs by invariant", () => {
const names = createOpenClawCodingTools({
sessionKey: "agent:worker:subagent:child",
runId: "collector-run",
config: { tools: { swarm: true } },
swarmCollector: true,
}).map((tool) => tool.name);
expect(names).not.toContain("message");
});
it("omits interactive and pausing tools for non-interactive collector runs", () => {
const names = createOpenClawCodingTools({
sessionKey: "agent:worker:main",
runId: "collector-run",
config: { tools: { swarm: true } },
swarmCollector: true,
}).map((tool) => tool.name);
expect(names).not.toContain("ask_user");
expect(names).not.toContain("sessions_send");
expect(names).not.toContain("sessions_yield");
});
});
+50
View File
@@ -0,0 +1,50 @@
import type { OpenClawConfig } from "../config/types.openclaw.js";
import {
getLatestSubagentRunByChildSessionKey,
getSubagentRunByRunId,
recordSwarmStructuredOutput,
} from "./subagent-registry.js";
import { resolveSwarmConfig } from "./swarm-config.js";
import { createAgentsWaitTool } from "./tools/agents-wait-tool.js";
import type { AnyAgentTool } from "./tools/common.js";
import { createStructuredOutputTool } from "./tools/structured-output-tool.js";
export function createOpenClawSwarmToolGroups(params: {
config?: OpenClawConfig;
effectiveRequesterAgentId: string;
agentSessionKey?: string;
runSessionKey?: string;
runId?: string;
swarmCollector?: boolean;
swarmOutputSchema?: Record<string, unknown>;
}): { structuredOutput: AnyAgentTool[]; agentsWait: AnyAgentTool[] } {
const childSessionKey = params.runSessionKey ?? params.agentSessionKey;
const collectorEntry =
params.swarmCollector && params.runId && params.swarmOutputSchema
? (getSubagentRunByRunId(params.runId) ??
(childSessionKey ? getLatestSubagentRunByChildSessionKey(childSessionKey) : undefined))
: undefined;
const structuredOutput =
params.swarmCollector && params.runId && params.swarmOutputSchema
? [
createStructuredOutputTool({
runId: params.runId,
schema: params.swarmOutputSchema,
initialState: collectorEntry?.structuredOutput,
onStateChange: (state) =>
recordSwarmStructuredOutput({ runId: params.runId, childSessionKey }, state),
}),
]
: [];
const agentsWait = resolveSwarmConfig(params.config, params.effectiveRequesterAgentId).enabled
? [
createAgentsWaitTool({
agentSessionKey: params.agentSessionKey,
runSessionKey: params.runSessionKey,
agentId: params.effectiveRequesterAgentId,
config: params.config,
}),
]
: [];
return { structuredOutput, agentsWait };
}
+18 -1
View File
@@ -41,6 +41,7 @@ import {
shouldIncludeAskUserToolForOpenClawTools,
shouldIncludeUpdatePlanToolForOpenClawTools,
} from "./openclaw-tools.registration.js";
import { createOpenClawSwarmToolGroups } from "./openclaw-tools.swarm.js";
import type { SandboxFsBridge } from "./sandbox/fs-bridge.js";
import type { SpawnedToolContext } from "./spawned-context.js";
import type { ToolFsPolicy } from "./tool-fs-policy.js";
@@ -191,6 +192,8 @@ export function createOpenClawTools(
inboundEventKind?: InboundEventKind;
/** If true, omit the message tool from the tool list. */
disableMessageTool?: boolean;
swarmCollector?: boolean;
swarmOutputSchema?: Record<string, unknown>;
/** If true, include the heartbeat response tool for structured heartbeat outcomes. */
enableHeartbeatTool?: boolean;
/** If true, skip plugin tool resolution and return only shipped core tools. */
@@ -245,6 +248,16 @@ export function createOpenClawTools(
config: resolvedConfig,
agentId: options?.requesterAgentIdOverride,
});
const effectiveRequesterAgentId = sessionAgentId;
const swarmToolGroups = createOpenClawSwarmToolGroups({
config: resolvedConfig,
effectiveRequesterAgentId,
agentSessionKey: options?.agentSessionKey,
runSessionKey: options?.runSessionKey,
runId: options?.runId,
swarmCollector: options?.swarmCollector,
swarmOutputSchema: options?.swarmOutputSchema,
});
// Fall back to the session agent workspace so plugin loading stays workspace-stable
// even when a caller forgets to thread workspaceDir explicitly.
const inferredWorkspaceDir =
@@ -592,6 +605,7 @@ export function createOpenClawTools(
}),
]),
...(includeUpdatePlanTool ? [createUpdatePlanTool()] : []),
...swarmToolGroups.structuredOutput,
...(includeAskUserTool
? [
createAskUserTool({
@@ -672,13 +686,16 @@ export function createOpenClawTools(
agentMemberRoleIds: options?.agentMemberRoleIds,
sandboxed: options?.sandboxed,
config: resolvedConfig,
requesterAgentIdOverride: options?.requesterAgentIdOverride,
requesterAgentIdOverride: effectiveRequesterAgentId,
requesterRunId: options?.runId,
swarmCollector: options?.swarmCollector,
workspaceDir: spawnWorkspaceDir,
inheritedToolAllowlist: options?.inheritedToolAllowlist,
inheritedToolDenylist: options?.inheritedToolDenylist,
}),
]
: []),
...swarmToolGroups.agentsWait,
createSessionsYieldTool({
sessionId: options?.sessionId,
onBeforeYield:
+4 -1
View File
@@ -64,8 +64,11 @@ export async function runSpawnPipeline<TState>(params: {
return { ok: false, phase: "dispatch", state, error };
}
const registration = params.buildRegistration(state, runId);
let registration: RegisterSubagentRunInput;
try {
// Keep construction and registration in one synchronous section so callers
// can revalidate shared admission state without an interleaving await.
registration = params.buildRegistration(state, runId);
registerSubagentRun(registration);
} catch (error) {
await params.adapter.cleanupOnFailure({ phase: "register", state, error });
+62
View File
@@ -33,6 +33,7 @@ import {
getSubagentRunByChildSessionKey,
resetSubagentRegistryForTests,
} from "./subagent-registry.test-helpers.js";
import type { SubagentRunRecord } from "./subagent-registry.types.js";
vi.mock("../gateway/call.js", () => ({
callGateway: vi.fn(),
@@ -250,6 +251,35 @@ describe("sendControlledSubagentMessage", () => {
testing.setDepsForTest();
});
it("rejects follow-up messages for collector runs", async () => {
const result = await sendControlledSubagentMessage({
cfg: {} as OpenClawConfig,
controller: {
controllerSessionKey: "agent:main:main",
callerSessionKey: "agent:main:main",
callerIsSubagent: false,
controlScope: "children",
},
entry: {
runId: "run-collector",
childSessionKey: "agent:worker:subagent:collector",
controllerSessionKey: "agent:main:main",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "collect",
cleanup: "keep",
collect: true,
createdAt: Date.now(),
},
message: "change direction",
});
expect(result).toEqual({
status: "forbidden",
error: "Collector subagents cannot receive follow-up messages; use agents_wait.",
});
});
it("rejects runs controlled by another session", async () => {
const result = await sendControlledSubagentMessage({
cfg: {
@@ -1730,6 +1760,38 @@ describe("steerControlledSubagentRun", () => {
testing.setDepsForTest();
});
it("rejects steering collector runs", async () => {
const entry: SubagentRunRecord = {
runId: "run-collector",
childSessionKey: "agent:worker:subagent:collector",
controllerSessionKey: "agent:main:main",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "collect",
cleanup: "keep",
collect: true,
createdAt: Date.now(),
};
const result = await steerControlledSubagentRun({
cfg: {} as OpenClawConfig,
controller: {
controllerSessionKey: "agent:main:main",
callerSessionKey: "agent:main:main",
callerIsSubagent: false,
controlScope: "children",
},
entry,
message: "change direction",
});
expect(result).toEqual({
status: "forbidden",
runId: entry.runId,
sessionKey: entry.childSessionKey,
error: "Collector subagents cannot be steered; use agents_wait or cancel the task.",
});
});
it("returns an error and clears the restart marker when run remap fails", async () => {
addSubagentRunForTests({
runId: "run-steer-old",
+14
View File
@@ -651,6 +651,14 @@ export async function steerControlledSubagentRun(params: {
error: ownershipError,
};
}
if (params.entry.collect) {
return {
status: "forbidden",
runId: params.entry.runId,
sessionKey: params.entry.childSessionKey,
error: "Collector subagents cannot be steered; use agents_wait or cancel the task.",
};
}
if (params.controller.controlScope !== "children") {
return {
status: "forbidden",
@@ -835,6 +843,12 @@ export async function sendControlledSubagentMessage(params: {
if (ownershipError) {
return { status: "forbidden" as const, error: ownershipError };
}
if (params.entry.collect) {
return {
status: "forbidden" as const,
error: "Collector subagents cannot receive follow-up messages; use agents_wait.",
};
}
if (params.controller.controlScope !== "children") {
return {
status: "forbidden" as const,
+12 -2
View File
@@ -143,6 +143,7 @@ vi.mock("./subagent-announce-origin.js", () => ({
vi.mock("./subagent-registry-steer-runtime.js", () => ({
replaceSubagentRunAfterSteer: vi.fn(() => true),
finalizeInterruptedSubagentRun: vi.fn(async () => 1),
reserveSwarmCollectorLaunch: vi.fn(() => true),
}));
function createTestRunRecord(overrides: Partial<SubagentRunRecord> = {}): SubagentRunRecord {
@@ -233,7 +234,7 @@ describe("subagent-orphan-recovery", () => {
vi.restoreAllMocks();
});
it("recovers orphaned sessions with abortedLastRun=true", async () => {
it("recovers orphaned collectors with their non-interactive output contract", async () => {
const sessionEntry = {
sessionId: "session-abc",
updatedAt: Date.now(),
@@ -244,7 +245,10 @@ describe("subagent-orphan-recovery", () => {
"agent:main:subagent:test-session-1": sessionEntry,
});
const run = createTestRunRecord();
const run = createTestRunRecord({
collect: true,
outputSchema: { type: "object", required: ["answer"] },
});
const activeRuns = new Map<string, SubagentRunRecord>();
activeRuns.set("run-1", run);
@@ -266,6 +270,12 @@ describe("subagent-orphan-recovery", () => {
expect(opts.sessionKey).toBe("agent:main:subagent:test-session-1");
expect(opts.message).toContain("gateway reload");
expect(opts.message).toContain("Test task: implement feature X");
expect(opts.swarmCollector).toBe(true);
expect(opts.swarmOutputSchema).toEqual({ type: "object", required: ["answer"] });
expect(subagentRegistrySteerRuntime.reserveSwarmCollectorLaunch).toHaveBeenCalledWith(
"run-1",
opts.idempotencyKey,
);
expect(dispatchAgent.mock.calls[0]?.[1]).toBe(10_000);
expect(subagentRegistrySteerRuntime.replaceSubagentRunAfterSteer).toHaveBeenCalledOnce();
const replaceParams = requireRecord(
+13
View File
@@ -33,6 +33,7 @@ import {
import {
finalizeInterruptedSubagentRun,
replaceSubagentRunAfterSteer,
reserveSwarmCollectorLaunch,
} from "./subagent-registry-steer-runtime.js";
import type { SubagentRunRecord } from "./subagent-registry.types.js";
import { isStaleUnendedSubagentRun } from "./subagent-run-liveness.js";
@@ -167,6 +168,12 @@ async function resumeOrphanedSession(params: {
try {
const idempotencyKey = crypto.randomUUID();
if (
params.originalRun.collect === true &&
!reserveSwarmCollectorLaunch(params.originalRunId, idempotencyKey)
) {
return { resumed: false, error: "failed to reserve collector recovery launch" };
}
const result = await params.gatewayRuntime.dispatchAgent<{ runId: string }>(
{
message: resumeMessage,
@@ -174,6 +181,12 @@ async function resumeOrphanedSession(params: {
idempotencyKey,
deliver: false,
lane: "subagent",
...(params.originalRun.collect
? {
swarmCollector: true,
swarmOutputSchema: params.originalRun.outputSchema,
}
: {}),
inputProvenance: {
kind: "inter_session",
sourceSessionKey: params.originalRun.requesterSessionKey,
@@ -1,11 +1,13 @@
// Subagent registry helper tests cover orphan reconciliation and compact logging
// for announce delivery give-up paths.
import { promises as fs } from "node:fs";
import { afterEach, describe, expect, it, vi } from "vitest";
import { defaultRuntime } from "../runtime.js";
import {
capFrozenResultText,
logAnnounceGiveUp,
reconcileOrphanedRun,
safeRemoveAttachmentsDir,
} from "./subagent-registry-helpers.js";
import type { SubagentRunRecord } from "./subagent-registry.types.js";
@@ -34,6 +36,25 @@ describe("capFrozenResultText", () => {
});
});
describe("safeRemoveAttachmentsDir", () => {
it("reports non-ENOENT realpath failures instead of treating cleanup as complete", async () => {
const realpathSpy = vi
.spyOn(fs, "realpath")
.mockRejectedValue(Object.assign(new Error("permission denied"), { code: "EACCES" }));
await expect(
safeRemoveAttachmentsDir(
createRunEntry({
attachmentsDir: "/tmp/openclaw-child-attachments",
attachmentsRootDir: "/tmp/openclaw-attachments",
}),
),
).resolves.toBe(false);
realpathSpy.mockRestore();
});
});
describe("reconcileOrphanedRun", () => {
afterEach(() => {
vi.useRealTimers();
+6 -5
View File
@@ -181,9 +181,9 @@ function isResolvedChildPath(params: { childPath: string; rootPath: string }) {
}
/** Best-effort async removal for a subagent attachment directory. */
export async function safeRemoveAttachmentsDir(entry: SubagentRunRecord): Promise<void> {
export async function safeRemoveAttachmentsDir(entry: SubagentRunRecord): Promise<boolean> {
if (!entry.attachmentsDir || !entry.attachmentsRootDir) {
return;
return true;
}
const resolveReal = async (targetPath: string): Promise<string | null> => {
@@ -203,17 +203,18 @@ export async function safeRemoveAttachmentsDir(entry: SubagentRunRecord): Promis
resolveReal(entry.attachmentsDir),
]);
if (!dirReal) {
return;
return true;
}
const rootBase = rootReal ?? path.resolve(entry.attachmentsRootDir);
const dirBase = dirReal;
if (!isResolvedChildPath({ childPath: dirBase, rootPath: rootBase })) {
return;
return false;
}
await fs.rm(dirBase, { recursive: true, force: true });
return true;
} catch {
// best effort
return false;
}
}
+86 -2
View File
@@ -2489,15 +2489,22 @@ describe("subagent registry lifecycle hardening", () => {
});
});
it("skips announce delivery when completion messages are disabled", async () => {
it("persists collector completion and skips announce delivery", async () => {
const persist = vi.fn();
const entry = createRunEntry({
expectsCompletionMessage: false,
retainAttachmentsOnKeep: true,
collect: true,
groupId: "swarm:test",
});
const runSubagentAnnounceFlow = vi.fn(async () => true);
const controller = createLifecycleController({ entry, persist, runSubagentAnnounceFlow });
const controller = createLifecycleController({
entry,
persist,
runSubagentAnnounceFlow,
captureSubagentCompletionReply: vi.fn(async () => "raw collector result"),
});
await expect(
controller.completeSubagentRun({
@@ -2517,10 +2524,87 @@ describe("subagent registry lifecycle hardening", () => {
expect(runSubagentAnnounceFlow).not.toHaveBeenCalled();
expect(hasDeliveredTaskStatusUpdate(entry.runId)).toBe(false);
await waitForLifecycleState(() => expect(entry.cleanupCompletedAt).toBeTypeOf("number"));
expect(entry.completion?.resultText).toBe("raw collector result");
expect(entry.collectorCompletion).toEqual({ status: "done" });
expect(entry.delivery?.status).toBe("not_required");
expect(entry.delivery?.announcedAt).toBeUndefined();
});
it("deletes collector session resources while retaining the waitable record", async () => {
const entry = createRunEntry({
cleanup: "delete",
expectsCompletionMessage: false,
collect: true,
groupId: "swarm:test",
});
const runs = new Map([[entry.runId, entry]]);
const notifyContextEngineSubagentEnded = vi.fn(async () => {});
const controller = createLifecycleController({
entry,
runs,
notifyContextEngineSubagentEnded,
captureSubagentCompletionReply: vi.fn(async () => "raw collector result"),
});
await controller.completeSubagentRun({
runId: entry.runId,
endedAt: 4_000,
outcome: { status: "ok" },
reason: SUBAGENT_ENDED_REASON_COMPLETE,
triggerCleanup: true,
});
await waitForLifecycleState(() => expect(entry.cleanupCompletedAt).toBeTypeOf("number"));
await waitForLifecycleState(() =>
expect(gatewayMocks.callGateway).toHaveBeenCalledWith({
method: "sessions.delete",
params: {
key: entry.childSessionKey,
deleteTranscript: true,
emitLifecycleHooks: false,
},
timeoutMs: 10_000,
}),
);
await waitForLifecycleState(() =>
expect(notifyContextEngineSubagentEnded).toHaveBeenCalledWith({
childSessionKey: entry.childSessionKey,
reason: "deleted",
agentDir: entry.agentDir,
workspaceDir: entry.workspaceDir,
}),
);
expect(helperMocks.safeRemoveAttachmentsDir).toHaveBeenCalledWith(entry);
expect(runs.get(entry.runId)).toBe(entry);
expect(entry.collectorCompletion).toEqual({ status: "done" });
});
it("marks a successful collector with invalid structured output failed", async () => {
const entry = createRunEntry({
expectsCompletionMessage: false,
collect: true,
outputSchema: { type: "object" },
});
const controller = createLifecycleController({
entry,
captureSubagentCompletionReply: vi.fn(async () => "raw collector result"),
});
await controller.completeSubagentRun({
runId: entry.runId,
endedAt: 4_000,
outcome: { status: "ok" },
reason: SUBAGENT_ENDED_REASON_COMPLETE,
triggerCleanup: true,
});
await waitForLifecycleState(() => expect(entry.cleanupCompletedAt).toBeTypeOf("number"));
expect(entry.collectorCompletion).toEqual({
status: "failed",
schemaError: "structured_output was not called",
});
});
it("archives delete-mode sessions when completion messages are disabled", async () => {
const persist = vi.fn();
const entry = createRunEntry({
+29
View File
@@ -82,6 +82,8 @@ import {
resolveSubagentRunEffectiveEndedAt,
} from "./subagent-run-timeout.js";
import { deleteSubagentSessionForCleanup } from "./subagent-session-cleanup.js";
import { updateSwarmCollectorCompletion } from "./swarm-collector.js";
import { releaseSwarmRun } from "./swarm-scheduler.js";
type CaptureSubagentCompletionReply =
(typeof import("./subagent-announce.js"))["captureSubagentCompletionReply"];
@@ -941,6 +943,7 @@ export function createSubagentRegistryLifecycleController(params: {
function scheduleRequesterSettleWake(runId: string, entry: SubagentRunRecord): void {
const requesterSessionKey = entry.requesterSessionKey?.trim();
if (
entry.collect ||
!requesterSessionKey ||
scheduledRequesterSettleWakeRuns.has(runId) ||
scheduledRequesterSettleWakeTimers.has(runId)
@@ -1254,6 +1257,26 @@ export function createSubagentRegistryLifecycleController(params: {
// Waking here could tell the requester to finalize while the child still runs.
return;
}
if (cleanupParams.entry.collect) {
// Delete-mode session cleanup already ran before this durable bookkeeping.
// Preserve only the collector result tombstone for waits and group caps.
if (cleanupParams.cleanup === "delete") {
params.clearPendingLifecycleError(cleanupParams.runId);
runCleanupTail("context-engine cleanup", async () => {
await params.notifyContextEngineSubagentEnded({
childSessionKey: cleanupParams.entry.childSessionKey,
reason: "deleted",
agentDir: cleanupParams.entry.agentDir,
workspaceDir: cleanupParams.entry.workspaceDir,
});
});
}
cleanupParams.entry.cleanupCompletedAt = cleanupParams.completedAt;
cleanupParams.entry.requesterSettleWake = undefined;
params.persist();
retryDeferredCompletedAnnounces(cleanupParams.runId);
return;
}
if (cleanupParams.cleanup === "delete") {
params.clearPendingLifecycleError(cleanupParams.runId);
runCleanupTail("context-engine cleanup", async () => {
@@ -2062,6 +2085,9 @@ export function createSubagentRegistryLifecycleController(params: {
mutated = true;
}
}
if (updateSwarmCollectorCompletion(entry)) {
mutated = true;
}
if (provisionalKillSnapshot) {
// Keep the tombstone's superseded generation boundary through task
// commit. Clearing it on the canonical registry row must not let a
@@ -2177,6 +2203,9 @@ export function createSubagentRegistryLifecycleController(params: {
await retireSupersededSession(entry);
return;
}
if (entry.collect) {
releaseSwarmRun(entry.schedulerSlotId ?? entry.runId);
}
const isProvisionalKill = entry.killReconciliation !== undefined;
// Record only the current, non-superseded callback with a committed outcome; the
// run-terminal dedupe key is first-write-wins, so a provisional/stale status here
+70 -1
View File
@@ -3,6 +3,75 @@
*
* Shared by registry read/write helpers for active in-memory run state.
*/
import { isDeepStrictEqual } from "node:util";
import type { SubagentRunRecord } from "./subagent-registry.types.js";
export const subagentRuns = new Map<string, SubagentRunRecord>();
// Preflight consults the collector lookup on every Gateway agent request, so it
// must stay O(1) regardless of retained collector records. The map subclass
// maintains the index through every existing mutation path (registry, run
// manager, tests); collect/childSessionKey are fixed at registration, so
// in-place field edits never require re-indexing.
const collectorRunIdByChildSessionKey = new Map<string, string>();
class SubagentRunMap extends Map<string, SubagentRunRecord> {
override set(runId: string, entry: SubagentRunRecord): this {
const prev = this.get(runId);
if (prev?.collect === true && prev.childSessionKey) {
collectorRunIdByChildSessionKey.delete(prev.childSessionKey);
}
super.set(runId, entry);
if (entry.collect === true && entry.childSessionKey) {
collectorRunIdByChildSessionKey.set(entry.childSessionKey, runId);
}
return this;
}
override delete(runId: string): boolean {
const prev = this.get(runId);
if (
prev?.collect === true &&
prev.childSessionKey &&
collectorRunIdByChildSessionKey.get(prev.childSessionKey) === runId
) {
collectorRunIdByChildSessionKey.delete(prev.childSessionKey);
}
return super.delete(runId);
}
override clear(): void {
super.clear();
collectorRunIdByChildSessionKey.clear();
}
}
export const subagentRuns: Map<string, SubagentRunRecord> = new SubagentRunMap();
/** Resolve a collector tombstone that reserves its child session from ordinary turns. */
export function findSwarmCollectorSession(childSessionKey?: string): SubagentRunRecord | undefined {
const key = childSessionKey?.trim();
if (!key) {
return undefined;
}
const runId = collectorRunIdByChildSessionKey.get(key);
return runId ? subagentRuns.get(runId) : undefined;
}
/** Resolve the host-registered collector that authorizes a Gateway request. */
export function findAuthorizedSwarmCollectorRequest(params: {
childSessionKey?: string;
idempotencyKey?: string;
outputSchema?: Record<string, unknown>;
}): SubagentRunRecord | undefined {
const idempotencyKey = params.idempotencyKey?.trim();
if (!idempotencyKey) {
return undefined;
}
const entry = findSwarmCollectorSession(params.childSessionKey);
if (!entry) {
return undefined;
}
return entry.swarmLaunchIdempotencyKey === idempotencyKey &&
isDeepStrictEqual(entry.outputSchema, params.outputSchema)
? entry
: undefined;
}
@@ -116,6 +116,25 @@ describe("subagent registry query regressions", () => {
expect(countActiveRunsForSessionFromRuns(runs, "agent:main:main")).toBe(1);
});
it("counts collectors against their stable spawning-session owner", () => {
const stableOwner = "agent:main:telegram:default:direct:456";
const runs = toRunMap([
makeRun({
runId: "run-routed-collector",
childSessionKey: "agent:worker:subagent:routed",
controllerSessionKey: "agent:main:main",
requesterSessionKey: "agent:main:main",
collect: true,
swarmRequesterSessionKey: stableOwner,
createdAt: Date.now(),
execution: { status: "queued" },
}),
]);
expect(countActiveRunsForSessionFromRuns(runs, stableOwner)).toBe(1);
expect(countActiveRunsForSessionFromRuns(runs, "agent:main:main")).toBe(0);
});
it("does not count stale unended descendants as pending work", () => {
const now = Date.now();
const parentSessionKey = "agent:main:subagent:parent-stale-desc";
+7 -1
View File
@@ -13,6 +13,12 @@ function resolveControllerSessionKey(entry: SubagentRunRecord): string {
return entry.controllerSessionKey?.trim() || entry.requesterSessionKey;
}
function resolveConcurrencyOwnerSessionKey(entry: SubagentRunRecord): string {
return entry.collect
? entry.swarmRequesterSessionKey?.trim() || resolveControllerSessionKey(entry)
: resolveControllerSessionKey(entry);
}
/** Lists requester-owned runs, optionally scoped to the lifetime of a requester run. */
export function listRunsForRequesterFromRuns(
runs: Map<string, SubagentRunRecord>,
@@ -390,7 +396,7 @@ export function countActiveRunsForSessionFromRuns(
const latestByChildSessionKey = new Map<string, SubagentRunRecord>();
for (const entry of runs.values()) {
if (resolveControllerSessionKey(entry) !== key) {
if (resolveConcurrencyOwnerSessionKey(entry) !== key) {
continue;
}
const existing = latestByChildSessionKey.get(entry.childSessionKey);
+273 -11
View File
@@ -12,7 +12,12 @@ import {
SUBAGENT_KILL_TASK_ERROR,
type DetachedTaskFindResult,
} from "../tasks/detached-task-runtime-contract.js";
import { createRunningTaskRun, finalizeTaskRunByRunId } from "../tasks/detached-task-runtime.js";
import {
createQueuedTaskRun,
createRunningTaskRun,
finalizeTaskRunByRunId,
startTaskRunByRunId,
} from "../tasks/detached-task-runtime.js";
import { normalizeDeliveryContext } from "../utils/delivery-context.shared.js";
import type { DeliveryContext } from "../utils/delivery-context.types.js";
import { buildAgentRunTerminalOutcomeFromWaitResult } from "./agent-run-terminal-outcome.js";
@@ -40,7 +45,11 @@ import {
resolveArchiveAfterMs,
safeRemoveAttachmentsDir,
} from "./subagent-registry-helpers.js";
import type { SubagentProgressOrigin, SubagentRunRecord } from "./subagent-registry.types.js";
import type {
SubagentProgressOrigin,
SubagentRunRecord,
SwarmQueuedLaunch,
} from "./subagent-registry.types.js";
import {
compareSubagentRunGeneration,
nextSubagentRunGeneration,
@@ -51,6 +60,8 @@ import {
getSubagentSessionStartedAt,
} from "./subagent-session-metrics.js";
import type { SubagentSessionCompletion } from "./subagent-session-reconciliation.js";
import { updateSwarmCollectorCompletion } from "./swarm-collector.js";
import { isSwarmRunQueued, removeQueuedSwarmRun } from "./swarm-scheduler.js";
const log = createSubsystemLogger("agents/subagent-registry");
const RECOVERABLE_WAIT_RETRY_DELAY_MS = process.env.OPENCLAW_TEST_FAST === "1" ? 25 : 5_000;
@@ -60,6 +71,31 @@ function shouldDeleteAttachments(entry: SubagentRunRecord) {
return entry.cleanup === "delete" || !entry.retainAttachmentsOnKeep;
}
function resolveSwarmWaitOwnerSessionKeys(
runs: ReadonlyMap<string, SubagentRunRecord>,
requesterSessionKey: string,
): string[] {
const ownerSessionKeys: string[] = [];
const visited = new Set<string>();
let currentSessionKey = requesterSessionKey.trim();
while (currentSessionKey && !visited.has(currentSessionKey)) {
visited.add(currentSessionKey);
ownerSessionKeys.push(currentSessionKey);
let latestOwner: SubagentRunRecord | undefined;
for (const candidate of runs.values()) {
if (
candidate.childSessionKey === currentSessionKey &&
(!latestOwner || compareSubagentRunGeneration(candidate, latestOwner) > 0)
) {
latestOwner = candidate;
}
}
currentSessionKey =
latestOwner?.controllerSessionKey?.trim() || latestOwner?.requesterSessionKey.trim() || "";
}
return ownerSessionKeys;
}
function resolveHardRunTimeoutEndedAt(
entry: SubagentRunRecord,
now: number,
@@ -190,6 +226,13 @@ export type RegisterSubagentRunParams = {
attachmentsDir?: string;
attachmentsRootDir?: string;
retainAttachmentsOnKeep?: boolean;
collect?: boolean;
swarmRequesterSessionKey?: string;
swarmLaunchIdempotencyKey?: string;
groupId?: string;
outputSchema?: Record<string, unknown>;
queuedLaunch?: SwarmQueuedLaunch;
queued?: boolean;
};
export function createSubagentRunManager(params: {
@@ -673,6 +716,7 @@ export function createSubagentRunManager(params: {
startedAt: now,
transcriptTarget: replaceParams.transcriptTarget,
},
swarmLaunchPending: false,
completion: {
required: source.expectsCompletionMessage === true,
fallbackResultText: preserveFrozenResultFallback ? sourceCompletion.resultText : undefined,
@@ -754,6 +798,7 @@ export function createSubagentRunManager(params: {
const runTimeoutSeconds = registerParams.runTimeoutSeconds ?? 0;
const waitTimeoutMs = params.resolveSubagentWaitTimeoutMs(cfg, runTimeoutSeconds);
const requesterOrigin = normalizeDeliveryContext(registerParams.requesterOrigin);
const queued = registerParams.queued === true;
const entry: SubagentRunRecord = normalizeSubagentRunState({
runId,
taskRunId: runId,
@@ -766,6 +811,7 @@ export function createSubagentRunManager(params: {
requesterOrigin,
progressOrigin: registerParams.progressOrigin,
requesterDisplayKey: registerParams.requesterDisplayKey,
requesterAgentId: registerParams.requesterAgentId,
task: registerParams.task,
taskName: registerParams.taskName,
cleanup: registerParams.cleanup,
@@ -776,12 +822,25 @@ export function createSubagentRunManager(params: {
agentDir: registerParams.agentDir,
workspaceDir: registerParams.workspaceDir,
runTimeoutSeconds,
collect: registerParams.collect,
swarmRequesterSessionKey: registerParams.swarmRequesterSessionKey,
swarmWaitOwnerSessionKeys:
registerParams.collect && registerParams.swarmRequesterSessionKey
? resolveSwarmWaitOwnerSessionKeys(params.runs, registerParams.swarmRequesterSessionKey)
: undefined,
swarmRunId: registerParams.collect ? runId : undefined,
schedulerSlotId: registerParams.collect ? runId : undefined,
swarmLaunchIdempotencyKey: registerParams.swarmLaunchIdempotencyKey,
swarmLaunchPending: registerParams.collect === true,
groupId: registerParams.groupId,
outputSchema: registerParams.outputSchema,
queuedLaunch: registerParams.queuedLaunch,
generation,
createdAt: now,
startedAt: now,
startedAt: queued ? undefined : now,
execution: {
status: "running",
startedAt: now,
status: queued ? "queued" : "running",
startedAt: queued ? undefined : now,
},
completion: {
required: registerParams.expectsCompletionMessage === true,
@@ -789,7 +848,7 @@ export function createSubagentRunManager(params: {
delivery: {
status: registerParams.expectsCompletionMessage === false ? "not_required" : "pending",
},
sessionStartedAt: now,
sessionStartedAt: queued ? undefined : now,
accumulatedRuntimeMs: 0,
archiveAtMs,
cleanupHandled: false,
@@ -809,7 +868,7 @@ export function createSubagentRunManager(params: {
throw error;
}
try {
const task = createRunningTaskRun({
const taskParams = {
runtime: "subagent",
sourceId: runId,
ownerKey: requesterSessionKey,
@@ -823,9 +882,14 @@ export function createSubagentRunManager(params: {
requesterAgentId: registerParams.requesterAgentId,
deliveryStatus:
registerParams.expectsCompletionMessage === false ? "not_applicable" : "pending",
startedAt: now,
lastEventAt: now,
});
} as const;
const task = queued
? createQueuedTaskRun(taskParams)
: createRunningTaskRun({
...taskParams,
startedAt: now,
lastEventAt: now,
});
if (!task) {
log.warn("Failed to persist background task for subagent run", {
runId: registerParams.runId,
@@ -843,7 +907,186 @@ export function createSubagentRunManager(params: {
params.startSweeper();
// Wait for subagent completion via gateway RPC (cross-process).
// The in-process lifecycle listener is a fallback for embedded runs.
void waitForSubagentCompletion(runId, waitTimeoutMs, entry);
if (!queued) {
void waitForSubagentCompletion(runId, waitTimeoutMs, entry);
}
};
const startQueuedSubagentRun = (runId: string, gatewayRunId?: string) => {
const key = runId.trim();
const entry =
params.runs.get(key) ??
[...params.runs.values()].find((candidate) => candidate.swarmRunId === key);
if (
!entry ||
entry.execution?.status !== "queued" ||
typeof entry.endedAt === "number" ||
entry.collectorCompletion
) {
return false;
}
const nextRunId = gatewayRunId?.trim() || entry.runId;
const conflicting = params.runs.get(nextRunId);
if (conflicting && conflicting !== entry) {
throw new Error(`collector gateway run id already exists: ${nextRunId}`);
}
const startedAt = Date.now();
const previousRunId = entry.runId;
const previousStartedAt = entry.startedAt;
const previousSessionStartedAt = entry.sessionStartedAt;
const previousExecution = entry.execution;
const previousQueuedLaunch = entry.queuedLaunch;
const previousSwarmRunId = entry.swarmRunId;
const previousSchedulerSlotId = entry.schedulerSlotId;
const previousSwarmLaunchPending = entry.swarmLaunchPending;
entry.swarmRunId ??= previousRunId;
entry.schedulerSlotId ??= entry.swarmRunId;
if (previousRunId !== nextRunId) {
params.runs.delete(previousRunId);
entry.runId = nextRunId;
params.runs.set(nextRunId, entry);
}
entry.startedAt = startedAt;
entry.sessionStartedAt ??= startedAt;
entry.execution = { ...entry.execution, status: "running", startedAt };
entry.swarmLaunchPending = false;
entry.queuedLaunch = undefined;
let persistedRunning = false;
try {
params.persistOrThrow();
persistedRunning = true;
startTaskRunByRunId({
runId: entry.taskRunId ?? entry.runId,
runtime: "subagent",
sessionKey: entry.childSessionKey,
startedAt,
lastEventAt: startedAt,
});
} catch (error) {
if (previousRunId !== nextRunId) {
params.runs.delete(nextRunId);
entry.runId = previousRunId;
params.runs.set(previousRunId, entry);
}
entry.startedAt = previousStartedAt;
entry.sessionStartedAt = previousSessionStartedAt;
entry.execution = previousExecution;
entry.queuedLaunch = previousQueuedLaunch;
entry.swarmRunId = previousSwarmRunId;
entry.schedulerSlotId = previousSchedulerSlotId;
entry.swarmLaunchPending = previousSwarmLaunchPending;
if (persistedRunning) {
try {
params.persistOrThrow();
} catch (rollbackError) {
// The failure callback terminalizes this in-memory queued row next.
log.warn("failed to persist collector start rollback", {
runId: previousRunId,
error: rollbackError,
});
}
}
throw error;
}
const cfg = params.getRuntimeConfig();
void waitForSubagentCompletion(
nextRunId,
params.resolveSubagentWaitTimeoutMs(cfg, entry.runTimeoutSeconds),
entry,
);
return true;
};
const failQueuedSubagentRun = (runId: string, error: string) => {
const key = runId.trim();
const entry =
params.runs.get(key) ??
[...params.runs.values()].find((candidate) => candidate.swarmRunId === key);
if (!entry || entry.execution?.status !== "queued") {
return false;
}
const snapshot = structuredClone(entry);
const endedAt = Date.now();
entry.endedAt = endedAt;
entry.endedReason = SUBAGENT_ENDED_REASON_ERROR;
entry.outcome = { status: "error", error, endedAt };
entry.execution = { ...entry.execution, status: "terminal", endedAt, outcome: entry.outcome };
entry.queuedLaunch = undefined;
entry.collectorLaunchCleanupPending = true;
entry.completion = { required: false, resultText: error, capturedAt: endedAt };
updateSwarmCollectorCompletion(entry);
try {
params.persistOrThrow();
} catch (persistError) {
const target = entry as unknown as Record<string, unknown>;
for (const property of Object.keys(target)) {
delete target[property];
}
Object.assign(target, snapshot);
throw persistError;
}
try {
finalizeTaskRunByRunId({
runId: entry.taskRunId ?? entry.runId,
runtime: "subagent",
sessionKey: entry.childSessionKey,
status: "failed",
endedAt,
lastEventAt: endedAt,
error,
suppressDelivery: true,
});
} catch (taskError) {
// Collector failure is already durable. Detached-task cleanup cannot
// turn it back into queued work or the scheduler could launch it twice.
log.warn("failed to finalize task after collector launch failure", {
runId: entry.runId,
error: taskError,
});
}
return true;
};
const settleFailedQueuedSubagentLaunch = (runId: string, error: string) => {
const entry =
params.runs.get(runId) ??
[...params.runs.values()].find((candidate) => candidate.swarmRunId === runId);
if (!entry?.collect) {
return false;
}
if (typeof entry.endedAt !== "number") {
return failQueuedSubagentRun(runId, error);
}
if (entry.collectorCompletion) {
return true;
}
const snapshot = structuredClone(entry);
entry.swarmLaunchPending = false;
entry.collectorLaunchCleanupPending = true;
entry.queuedLaunch = undefined;
entry.execution = {
...entry.execution,
status: "terminal",
endedAt: entry.endedAt,
outcome: entry.outcome,
};
entry.completion = {
required: false,
resultText: entry.outcome?.status === "error" ? (entry.outcome.error ?? error) : error,
capturedAt: entry.endedAt,
};
updateSwarmCollectorCompletion(entry);
try {
params.persistOrThrow();
} catch (persistError) {
const target = entry as unknown as Record<string, unknown>;
for (const property of Object.keys(target)) {
delete target[property];
}
Object.assign(target, snapshot);
throw persistError;
}
return true;
};
const releaseSubagentRun = (runId: string) => {
@@ -894,6 +1137,7 @@ export function createSubagentRunManager(params: {
const reason = markParams.reason?.trim() || "killed";
let updated = 0;
const entriesByChildSessionKey = new Map<string, SubagentRunRecord>();
const queuedCollectorRunIds: string[] = [];
const entrySnapshots = new Map<SubagentRunRecord, SubagentRunRecord>();
const pendingTaskFinalizations: Array<{ entry: SubagentRunRecord; endedAt: number }> = [];
const finalizeKilledTask = (entry: SubagentRunRecord, endedAt: number) => {
@@ -943,6 +1187,14 @@ export function createSubagentRunManager(params: {
}
entrySnapshots.set(entry, structuredClone(entry));
const wasYielded = entry.pauseReason === "sessions_yield";
const wasQueuedCollector = entry.collect && entry.execution?.status === "queued";
const collectorLaunchInFlight =
wasQueuedCollector &&
entry.swarmLaunchPending === true &&
!isSwarmRunQueued(entry.schedulerSlotId ?? entry.runId);
if (wasQueuedCollector) {
queuedCollectorRunIds.push(entry.runId);
}
const endedAt =
(wasYielded || wasKilledLifecycle) && typeof entry.endedAt === "number"
? entry.endedAt
@@ -980,6 +1232,9 @@ export function createSubagentRunManager(params: {
: undefined,
supersededAt: existingKillReconciliation?.supersededAt,
};
if (wasQueuedCollector && !collectorLaunchInFlight) {
updateSwarmCollectorCompletion(entry);
}
pendingTaskFinalizations.push({ entry, endedAt: taskEndedAt });
if (!entriesByChildSessionKey.has(entry.childSessionKey)) {
entriesByChildSessionKey.set(entry.childSessionKey, entry);
@@ -1004,6 +1259,10 @@ export function createSubagentRunManager(params: {
for (const pending of pendingTaskFinalizations) {
finalizeKilledTask(pending.entry, pending.endedAt);
}
for (const runId of queuedCollectorRunIds) {
const entry = params.runs.get(runId);
removeQueuedSwarmRun(entry?.schedulerSlotId ?? runId);
}
for (const entry of entriesByChildSessionKey.values()) {
// Task finalization removes the suspension blocker before these session-owned
// writes finish. Join them under one independent root so snapshots stay atomic.
@@ -1047,6 +1306,9 @@ export function createSubagentRunManager(params: {
markSubagentRunForSteerRestart,
markSubagentRunTerminated,
registerSubagentRun,
startQueuedSubagentRun,
failQueuedSubagentRun,
settleFailedQueuedSubagentLaunch,
releaseSubagentRun,
replaceSubagentRunAfterSteer,
waitForSubagentCompletion,
@@ -34,17 +34,21 @@ type FinalizeInterruptedSubagentRunParams = {
type FinalizeInterruptedSubagentRunFn = (
params: FinalizeInterruptedSubagentRunParams,
) => Promise<number>;
type ReserveSwarmCollectorLaunchFn = (runId: string, idempotencyKey: string) => boolean;
let replaceSubagentRunAfterSteerImpl: ReplaceSubagentRunAfterSteerFn | null = null;
let finalizeInterruptedSubagentRunImpl: FinalizeInterruptedSubagentRunFn | null = null;
let reserveSwarmCollectorLaunchImpl: ReserveSwarmCollectorLaunchFn | null = null;
/** Installs registry mutation hooks used by steer/recovery runtime paths. */
export function configureSubagentRegistrySteerRuntime(params: {
replaceSubagentRunAfterSteer: ReplaceSubagentRunAfterSteerFn;
finalizeInterruptedSubagentRun?: FinalizeInterruptedSubagentRunFn;
reserveSwarmCollectorLaunch?: ReserveSwarmCollectorLaunchFn;
}) {
replaceSubagentRunAfterSteerImpl = params.replaceSubagentRunAfterSteer;
finalizeInterruptedSubagentRunImpl = params.finalizeInterruptedSubagentRun ?? null;
reserveSwarmCollectorLaunchImpl = params.reserveSwarmCollectorLaunch ?? null;
}
/** Replaces a previous run id after steering, returning false when no hook is installed. */
@@ -56,3 +60,8 @@ export function replaceSubagentRunAfterSteer(params: ReplaceSubagentRunAfterStee
export async function finalizeInterruptedSubagentRun(params: FinalizeInterruptedSubagentRunParams) {
return (await finalizeInterruptedSubagentRunImpl?.(params)) ?? 0;
}
/** Durably reserves one host-originated collector recovery request. */
export function reserveSwarmCollectorLaunch(runId: string, idempotencyKey: string) {
return reserveSwarmCollectorLaunchImpl?.(runId, idempotencyKey) ?? false;
}
@@ -38,6 +38,7 @@ import {
resetSubagentRegistryForTests,
} from "./subagent-registry.test-helpers.js";
import type { SubagentRunRecord } from "./subagent-registry.types.js";
import { createAgentsWaitTool } from "./tools/agents-wait-tool.js";
const { announceSpy } = vi.hoisted(() => ({
announceSpy: vi.fn(async () => true),
@@ -488,6 +489,112 @@ describe("subagent registry persistence", () => {
});
});
it("reloads waitable swarm collector completions after a gateway restart", async () => {
tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-subagent-"));
setTestEnvValue("OPENCLAW_STATE_DIR", tempStateDir);
const run: SubagentRunRecord = {
runId: "run-swarm-restart",
childSessionKey: "agent:worker:subagent:swarm-restart",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "persist collector result",
cleanup: "keep",
createdAt: 1,
endedAt: 2,
collect: true,
swarmRequesterSessionKey: "agent:worker:subagent:owner",
swarmWaitOwnerSessionKeys: ["agent:worker:subagent:owner", "agent:main:main"],
groupId: "swarm:agent:main:main:parent-run",
outputSchema: { type: "object", required: ["answer"] },
completion: { required: false, resultText: "raw answer", capturedAt: 2 },
collectorCompletion: {
status: "done",
structured: { answer: 42 },
usage: { inputTokens: 10, outputTokens: 3 },
},
};
saveSubagentRegistryToSqlite(new Map([[run.runId, run]]));
await writeChildSessionEntry({
sessionKey: run.childSessionKey,
sessionId: "session-swarm-restart",
updatedAt: run.endedAt,
});
closeOpenClawStateDatabaseForTest();
const restored = loadSubagentRegistryFromSqlite().get(run.runId);
expect(restored).toMatchObject({
runId: run.runId,
collect: true,
swarmRequesterSessionKey: run.swarmRequesterSessionKey,
swarmWaitOwnerSessionKeys: run.swarmWaitOwnerSessionKeys,
groupId: run.groupId,
outputSchema: run.outputSchema,
completion: { resultText: "raw answer" },
collectorCompletion: {
status: "done",
structured: { answer: 42 },
usage: { inputTokens: 10, outputTokens: 3 },
},
});
restartRegistry();
const wait = createAgentsWaitTool({
agentSessionKey: "agent:main:main",
agentId: "main",
config: { tools: { swarm: true } },
});
const waited = await wait.execute("wait-after-restart", {
ids: [run.runId],
timeoutSeconds: 0,
});
expect(waited.details).toMatchObject({
completed: [
{
runId: run.runId,
status: "done",
result: "raw answer",
structured: { answer: 42 },
},
],
pending: [],
});
});
it("reloads queued launch and in-flight structured state", async () => {
tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-subagent-"));
setTestEnvValue("OPENCLAW_STATE_DIR", tempStateDir);
const run: SubagentRunRecord = {
runId: "run-swarm-in-flight",
childSessionKey: "agent:worker:subagent:swarm-in-flight",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "persist collector launch",
cleanup: "keep",
createdAt: 1,
collect: true,
swarmRequesterSessionKey: "agent:main:telegram:default:direct:456",
groupId: "logical-group",
outputSchema: { type: "object" },
execution: { status: "queued" },
structuredOutput: { invalidAttempts: 1, schemaError: "answer is required" },
queuedLaunch: {
request: { sessionKey: "agent:worker:subagent:swarm-in-flight" },
timeoutMs: 1_000,
schedulerGroupKey: '["agent:main:main","logical-group"]',
maxConcurrent: 8,
},
};
saveSubagentRegistryToSqlite(new Map([[run.runId, run]]));
closeOpenClawStateDatabaseForTest();
expect(loadSubagentRegistryFromSqlite().get(run.runId)).toMatchObject({
swarmRequesterSessionKey: run.swarmRequesterSessionKey,
structuredOutput: run.structuredOutput,
queuedLaunch: run.queuedLaunch,
});
});
it("retries cleanup announce after a failed announce", async () => {
const persisted = createPersistedEndedRun({
runId: "run-3",
@@ -184,6 +184,18 @@ function rowToSubagentRunRecord(row: SubagentRunSqliteRow): SubagentRunRecord |
row,
payload.requesterSettleWake,
);
const structured = parseJson(row.swarm_structured_json);
const outputSchema = parseJson(row.swarm_output_schema_json);
const usage = parseJson(row.swarm_usage_json) as
| { inputTokens: number; outputTokens: number }
| undefined;
const collectorStatus =
row.swarm_completion_status === "done" ||
row.swarm_completion_status === "failed" ||
row.swarm_completion_status === "killed" ||
row.swarm_completion_status === "timeout"
? row.swarm_completion_status
: undefined;
const record = normalizeSubagentRunState({
...payload,
runId: row.run_id,
@@ -237,6 +249,21 @@ function rowToSubagentRunRecord(row: SubagentRunSqliteRow): SubagentRunRecord |
: {}),
...(delivery ? { delivery } : {}),
...(requesterSettleWake ? { requesterSettleWake } : {}),
...(sqliteBool(row.swarm_collector) !== undefined
? { collect: sqliteBool(row.swarm_collector) }
: {}),
...(row.swarm_group_id ? { groupId: row.swarm_group_id } : {}),
...(outputSchema ? { outputSchema: outputSchema as Record<string, unknown> } : {}),
...(collectorStatus
? {
collectorCompletion: {
status: collectorStatus,
...(structured !== undefined ? { structured } : {}),
...(row.swarm_schema_error ? { schemaError: row.swarm_schema_error } : {}),
...(usage ? { usage } : {}),
},
}
: {}),
});
return record.runId && record.childSessionKey && record.requesterSessionKey ? record : null;
}
@@ -301,6 +328,13 @@ function subagentRunRecordToSqliteInsert(entry: SubagentRunRecord): SubagentRunS
pending_final_delivery_last_error: delivery?.lastError ?? null,
pending_final_delivery_payload_json: jsonStringify(delivery?.payload),
completion_announced_at: delivery?.announcedAt ?? null,
swarm_group_id: normalized.groupId ?? null,
swarm_collector: boolToSqlite(normalized.collect),
swarm_output_schema_json: jsonStringify(normalized.outputSchema),
swarm_completion_status: normalized.collectorCompletion?.status ?? null,
swarm_structured_json: jsonStringify(normalized.collectorCompletion?.structured),
swarm_schema_error: normalized.collectorCompletion?.schemaError ?? null,
swarm_usage_json: jsonStringify(normalized.collectorCompletion?.usage),
payload_json: JSON.stringify(normalized),
};
}
@@ -28,6 +28,7 @@ type RegistryTestApi = {
releaseSubagentRun(runId: string): void;
resetSubagentRegistryForTests(opts?: { persist?: boolean }): void;
testing: {
failQueuedSubagentRun(runId: string, error: string): boolean;
sweepOnceForTests(): Promise<void>;
runSweeperTickForTests(): Promise<void>;
setDepsForTest(overrides?: Partial<RegistryDeps>): void;
@@ -82,6 +83,8 @@ export async function finalizeInterruptedSubagentRun(params: {
}
export const testing = {
failQueuedSubagentRun: (runId: string, error: string) =>
getRegistryTestApi().testing.failQueuedSubagentRun(runId, error),
sweepOnceForTests: () => getRegistryTestApi().testing.sweepOnceForTests(),
runSweeperTickForTests: () => getRegistryTestApi().testing.runSweeperTickForTests(),
setDepsForTest: (overrides?: Partial<RegistryDeps>) =>
+631 -1
View File
@@ -11,6 +11,7 @@ import type {
SessionEntryPatchContext,
SessionEntryPatchOptions,
} from "../config/sessions/session-accessor.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { AgentEventPayload } from "../infra/agent-events.js";
import {
getActiveGatewayRootWorkCount,
@@ -36,6 +37,7 @@ import {
SUBAGENT_ENDED_REASON_ERROR,
SUBAGENT_ENDED_REASON_KILLED,
} from "./subagent-lifecycle-events.js";
import { testing as swarmSchedulerTesting } from "./swarm-scheduler.test-support.js";
const noop = () => {};
const waitForFast = <T>(callback: () => T | Promise<T>) =>
@@ -108,7 +110,7 @@ const mocks = vi.hoisted(() => ({
callGateway: vi.fn<(request: { method?: string }) => Promise<Record<string, unknown>>>(),
onAgentEvent: vi.fn<(_handler: (event: AgentEventPayload) => void) => typeof noop>(() => noop),
getAgentRunContext: vi.fn<(_runId: string) => unknown>(() => undefined),
getRuntimeConfig: vi.fn(() => ({
getRuntimeConfig: vi.fn<() => OpenClawConfig>(() => ({
agents: { defaults: { subagents: { archiveAfterMinutes: 0 } } },
session: { mainKey: "main", scope: "per-sender" as const },
})),
@@ -344,6 +346,7 @@ describe("subagent registry seam flow", () => {
resolveContextEngine: mocks.resolveContextEngine,
});
mod.resetSubagentRegistryForTests({ persist: false });
swarmSchedulerTesting.reset();
});
afterEach(() => {
@@ -351,6 +354,8 @@ describe("subagent registry seam flow", () => {
resetDetachedTaskLifecycleRuntimeForTests();
mod.testing.setDepsForTest();
mod.resetSubagentRegistryForTests({ persist: false });
swarmSchedulerTesting.reset();
vi.unstubAllEnvs();
vi.useRealTimers();
});
@@ -387,6 +392,152 @@ describe("subagent registry seam flow", () => {
await waitForFast(() => expect(getActiveGatewayRootWorkCount()).toBe(0));
});
it("archives completed collector groups only after every member reaches TTL", async () => {
const now = Date.now();
for (const [suffix, archiveAtMs] of [
["one", now - 1],
["two", now + 1_000],
] as const) {
mod.addSubagentRunForTests({
runId: `run-collector-${suffix}`,
childSessionKey: `agent:main:subagent:collector-${suffix}`,
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "retain lifetime group count",
cleanup: "delete",
createdAt: now - 10_000,
endedAt: now - 5_000,
cleanupCompletedAt: now - 4_000,
archiveAtMs,
collect: true,
groupId: "swarm:lifetime",
collectorCompletion: { status: "done" },
});
}
await mod.testing.sweepOnceForTests();
expect(mod.getSubagentRunByRunId("run-collector-one")).toBeDefined();
expect(mod.getSubagentRunByRunId("run-collector-two")).toBeDefined();
expect(mocks.callGateway).not.toHaveBeenCalledWith(
expect.objectContaining({ method: "sessions.delete" }),
);
vi.setSystemTime(now + 1_001);
await mod.testing.sweepOnceForTests();
expect(mod.getSubagentRunByRunId("run-collector-one")).toBeUndefined();
expect(mod.getSubagentRunByRunId("run-collector-two")).toBeUndefined();
expect(
mocks.callGateway.mock.calls.filter(([request]) => request.method === "sessions.delete"),
).toHaveLength(2);
});
it("keeps collector groups while any member owes failed-launch cleanup", async () => {
const now = Date.now();
mod.addSubagentRunForTests({
runId: "run-collector-clean",
childSessionKey: "agent:main:subagent:collector-clean",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "completed sibling",
cleanup: "keep",
createdAt: now - 10_000,
endedAt: now - 5_000,
archiveAtMs: now - 1,
collect: true,
groupId: "swarm:cleanup-pending",
collectorCompletion: { status: "done" },
});
mod.addSubagentRunForTests({
runId: "run-collector-cleanup-pending",
childSessionKey: "agent:main:subagent:collector-cleanup-pending",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "failed launch cleanup",
cleanup: "keep",
createdAt: now - 9_000,
endedAt: now - 4_000,
archiveAtMs: now - 1,
collect: true,
groupId: "swarm:cleanup-pending",
collectorLaunchCleanupPending: true,
collectorCompletion: { status: "failed" },
});
mocks.callGateway.mockRejectedValueOnce(new Error("delete unavailable"));
await mod.testing.sweepOnceForTests();
expect(mod.getSubagentRunByRunId("run-collector-clean")).toBeDefined();
expect(mod.getSubagentRunByRunId("run-collector-cleanup-pending")).toMatchObject({
collectorLaunchCleanupPending: true,
});
});
it("keeps collector records when attachment cleanup cannot prove a safe path", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-swarm-archive-"));
const attachmentsRootDir = path.join(tempRoot, "root");
const attachmentsDir = path.join(tempRoot, "outside");
await fs.mkdir(attachmentsRootDir);
await fs.mkdir(attachmentsDir);
try {
mod.addSubagentRunForTests({
runId: "run-collector-unsafe-attachments",
childSessionKey: "agent:main:subagent:collector-unsafe-attachments",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "retain record until attachments are safely removed",
cleanup: "keep",
createdAt: Date.now() - 10_000,
endedAt: Date.now() - 5_000,
archiveAtMs: Date.now() - 1,
collect: true,
groupId: "swarm:unsafe-attachments",
attachmentsDir,
attachmentsRootDir,
collectorCompletion: { status: "done" },
});
await mod.testing.sweepOnceForTests();
expect(mod.getSubagentRunByRunId("run-collector-unsafe-attachments")).toBeDefined();
await expect(fs.access(attachmentsDir)).resolves.toBeUndefined();
} finally {
await fs.rm(tempRoot, { recursive: true, force: true });
}
});
it("snapshots ordinary parent-chain wait ownership when registering a collector", () => {
const parentSessionKey = "agent:main:subagent:collector-parent";
mod.addSubagentRunForTests({
runId: "run-ordinary-parent",
childSessionKey: parentSessionKey,
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "spawn nested child",
cleanup: "keep",
createdAt: Date.now(),
expectsCompletionMessage: true,
});
mod.registerSubagentRun({
runId: "run-collector-descendant",
childSessionKey: "agent:main:subagent:collector-descendant",
controllerSessionKey: parentSessionKey,
requesterSessionKey: parentSessionKey,
requesterDisplayKey: "parent",
task: "nested result",
cleanup: "keep",
expectsCompletionMessage: false,
collect: true,
swarmRequesterSessionKey: parentSessionKey,
groupId: "swarm:descendant",
queued: true,
});
expect(mod.getSubagentRunByRunId("run-collector-descendant")).toMatchObject({
swarmWaitOwnerSessionKeys: [parentSessionKey, "agent:main:main"],
});
});
it("tracks missing-entry lifecycle result refresh until capture and persistence settle", async () => {
const childSessionKey = "agent:main:subagent:refresh-admission";
mocks.callGateway.mockImplementation(async (request: { method?: string }) =>
@@ -513,6 +664,83 @@ describe("subagent registry seam flow", () => {
await waitForFast(() => expect(getActiveGatewayRootWorkCount()).toBe(0));
});
it("does not freeze running collector output from a provisional kill", async () => {
const runId = "run-collector-provisional-kill";
mod.addSubagentRunForTests({
runId,
childSessionKey: "agent:main:subagent:child",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "finish collector cancellation",
cleanup: "keep",
createdAt: Date.now(),
collect: true,
execution: { status: "running" },
structuredOutput: { invalidAttempts: 0, structured: { answer: 42 } },
});
expect(mod.markSubagentRunTerminated({ runId, reason: "manual kill" })).toBe(1);
expect(mod.getSubagentRunByRunId(runId)?.collectorCompletion).toBeUndefined();
expect(mod.getSubagentRunByRunId(runId)?.structuredOutput).toEqual({
invalidAttempts: 0,
structured: { answer: 42 },
});
await waitForFast(() => expect(getActiveGatewayRootWorkCount()).toBe(0));
});
it("keeps an in-flight queued collector pending until launch cleanup settles", async () => {
const runId = "run-collector-launch-kill";
mod.addSubagentRunForTests({
runId,
childSessionKey: "agent:main:subagent:launch-kill",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "cancel while gateway launch is unresolved",
cleanup: "keep",
createdAt: Date.now(),
collect: true,
swarmRunId: runId,
schedulerSlotId: runId,
swarmLaunchPending: true,
execution: { status: "queued" },
completion: { required: false },
});
expect(mod.markSubagentRunTerminated({ runId, reason: "manual kill" })).toBe(1);
expect(mod.getSubagentRunByRunId(runId)?.collectorCompletion).toBeUndefined();
expect(mod.settleFailedQueuedSubagentLaunch(runId, "launch response lost")).toBe(true);
expect(mod.getSubagentRunByRunId(runId)?.collectorCompletion).toMatchObject({
status: "killed",
});
await waitForFast(() => expect(getActiveGatewayRootWorkCount()).toBe(0));
});
it("records early structured output through the child session identity", () => {
const childSessionKey = "agent:main:subagent:early-structured-output";
mod.addSubagentRunForTests({
runId: "public-collector-run",
childSessionKey,
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "return structured output immediately",
cleanup: "keep",
createdAt: Date.now(),
collect: true,
execution: { status: "queued" },
});
mod.recordSwarmStructuredOutput(
{ runId: "gateway-run-not-yet-remapped", childSessionKey },
{ invalidAttempts: 0, structured: { answer: 42 } },
);
expect(mod.getSubagentRunByRunId("public-collector-run")?.structuredOutput).toEqual({
invalidAttempts: 0,
structured: { answer: 42 },
});
});
it("lists active and pending-delivery child sessions for maintenance preservation", () => {
const now = Date.now();
mod.addSubagentRunForTests({
@@ -645,6 +873,408 @@ describe("subagent registry seam flow", () => {
);
});
it("rehydrates persisted collector FIFO queues after registry restore", async () => {
const now = Date.now();
mocks.getRuntimeConfig.mockReturnValue({
tools: { swarm: { enabled: true, maxConcurrent: 1 } },
agents: { defaults: { subagents: { archiveAfterMinutes: 0 } } },
session: { mainKey: "main", scope: "per-sender" as const },
});
const makeQueuedRun = (runId: string, createdAt: number) => ({
runId,
childSessionKey: `agent:main:subagent:${runId}`,
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: runId,
cleanup: "keep" as const,
collect: true,
groupId: "logical-group",
createdAt,
execution: { status: "queued" as const },
completion: { required: false },
queuedLaunch: {
request: { sessionKey: `agent:main:subagent:${runId}`, idempotencyKey: runId },
timeoutMs: 1_000,
schedulerGroupKey: '["agent:main:main","logical-group"]',
maxConcurrent: 2,
},
});
mocks.restoreSubagentRunsFromDisk.mockImplementation(((params: {
runs: Map<string, unknown>;
}) => {
params.runs.set("run-queued-one", makeQueuedRun("run-queued-one", now));
params.runs.set("run-queued-two", makeQueuedRun("run-queued-two", now + 1));
return 2;
}) as never);
mocks.loadSessionStore.mockReturnValue({
"agent:main:subagent:run-queued-one": { sessionId: "session-one", updatedAt: now },
"agent:main:subagent:run-queued-two": { sessionId: "session-two", updatedAt: now },
});
mocks.callGateway.mockImplementation(async (request: { method?: string }) => {
if (request.method === "agent") {
return { runId: "gateway-run-one" };
}
return request.method === "agent.wait" ? { status: "pending" } : {};
});
mod.initSubagentRegistry();
await waitForFast(() => {
const agentCalls = mocks.callGateway.mock.calls.filter(
([request]) => request.method === "agent",
);
expect(agentCalls).toHaveLength(1);
expect(agentCalls[0]?.[0]).toMatchObject({
params: { idempotencyKey: "run-queued-one" },
});
});
expect(mod.getSubagentRunByRunId("run-queued-one")?.execution?.status).toBe("running");
expect(mod.getSubagentRunByRunId("gateway-run-one")).toMatchObject({
runId: "gateway-run-one",
swarmRunId: "run-queued-one",
schedulerSlotId: "run-queued-one",
});
expect(mod.getSubagentRunByRunId("run-queued-two")?.execution?.status).toBe("queued");
});
it("holds a restored FIFO slot until an accepted collector is confirmed stopped", async () => {
vi.useRealTimers();
const now = Date.now();
mocks.getRuntimeConfig.mockReturnValue({
tools: { swarm: { enabled: true, maxConcurrent: 1 } },
agents: { defaults: { subagents: { archiveAfterMinutes: 0 } } },
session: { mainKey: "main", scope: "per-sender" as const },
});
const makeQueuedRun = (runId: string, createdAt: number) => ({
runId,
childSessionKey: `agent:main:subagent:${runId}`,
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: runId,
cleanup: "keep" as const,
collect: true,
groupId: "restore-stop",
createdAt,
execution: { status: "queued" as const },
completion: { required: false },
queuedLaunch: {
request: { sessionKey: `agent:main:subagent:${runId}`, idempotencyKey: runId },
timeoutMs: 1_000,
schedulerGroupKey: '["agent:main:main","restore-stop"]',
maxConcurrent: 1,
},
});
mocks.restoreSubagentRunsFromDisk.mockImplementation(((params: {
runs: Map<string, unknown>;
}) => {
params.runs.set("run-restored-stop-one", makeQueuedRun("run-restored-stop-one", now));
params.runs.set("run-restored-stop-two", makeQueuedRun("run-restored-stop-two", now + 1));
return 2;
}) as never);
mocks.loadSessionStore.mockReturnValue({
"agent:main:subagent:run-restored-stop-one": { sessionId: "one", updatedAt: now },
"agent:main:subagent:run-restored-stop-two": { sessionId: "two", updatedAt: now },
});
mocks.persistSubagentRunsToDiskOrThrow.mockImplementationOnce(() => {
throw new Error("sqlite unavailable after Gateway acceptance");
});
let agentCalls = 0;
let releaseAbort: (() => void) | undefined;
mocks.callGateway.mockImplementation(async (request: { method?: string }) => {
if (request.method === "agent") {
agentCalls += 1;
return { runId: `gateway-restored-${agentCalls}` };
}
if (request.method === "chat.abort") {
return await new Promise<Record<string, unknown>>((resolve) => {
releaseAbort = () => resolve({});
});
}
if (request.method === "sessions.delete") {
throw new Error("delete unavailable");
}
return request.method === "agent.wait" ? { status: "pending" } : {};
});
mod.initSubagentRegistry();
await waitForFast(() => expect(releaseAbort).toBeTypeOf("function"));
expect(agentCalls).toBe(1);
releaseAbort?.();
await waitForFast(() => expect(agentCalls).toBe(2));
});
it("holds a restored FIFO slot while an indeterminate launch session is deleted", async () => {
vi.useRealTimers();
const now = Date.now();
mocks.getRuntimeConfig.mockReturnValue({
tools: { swarm: { enabled: true, maxConcurrent: 1 } },
agents: { defaults: { subagents: { archiveAfterMinutes: 0 } } },
session: { mainKey: "main", scope: "per-sender" as const },
});
const makeQueuedRun = (runId: string, createdAt: number) => ({
runId,
childSessionKey: `agent:main:subagent:${runId}`,
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: runId,
cleanup: "keep" as const,
collect: true,
groupId: "restore-delete",
createdAt,
execution: { status: "queued" as const },
completion: { required: false },
queuedLaunch: {
request: { sessionKey: `agent:main:subagent:${runId}`, idempotencyKey: runId },
timeoutMs: 1_000,
schedulerGroupKey: '["agent:main:main","restore-delete"]',
maxConcurrent: 1,
},
});
mocks.restoreSubagentRunsFromDisk.mockImplementation(((params: {
runs: Map<string, unknown>;
}) => {
params.runs.set("run-restored-delete-one", makeQueuedRun("run-restored-delete-one", now));
params.runs.set("run-restored-delete-two", makeQueuedRun("run-restored-delete-two", now + 1));
return 2;
}) as never);
mocks.loadSessionStore.mockReturnValue({
"agent:main:subagent:run-restored-delete-one": { sessionId: "one", updatedAt: now },
"agent:main:subagent:run-restored-delete-two": { sessionId: "two", updatedAt: now },
});
let agentCalls = 0;
let releaseDelete: (() => void) | undefined;
mocks.callGateway.mockImplementation(async (request: { method?: string }) => {
if (request.method === "agent") {
agentCalls += 1;
if (agentCalls === 1) {
throw new Error("launch response lost");
}
return { runId: "gateway-restored-second" };
}
if (request.method === "sessions.delete") {
return await new Promise<Record<string, unknown>>((resolve) => {
releaseDelete = () => resolve({});
});
}
return request.method === "agent.wait" ? { status: "pending" } : {};
});
mod.initSubagentRegistry();
await waitForFast(() => expect(releaseDelete).toBeTypeOf("function"));
expect(agentCalls).toBe(1);
releaseDelete?.();
await waitForFast(() => expect(agentCalls).toBe(2));
});
it("cleans prepared resources after a restored collector launch fails", async () => {
const now = Date.now();
mocks.restoreSubagentRunsFromDisk.mockImplementation(((params: {
runs: Map<string, unknown>;
}) => {
params.runs.set("run-queued-failure", {
runId: "run-queued-failure",
childSessionKey: "agent:main:subagent:queued-failure",
requesterSessionKey: "agent:main:main",
swarmRequesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "fail restored launch",
cleanup: "keep",
collect: true,
groupId: "restore-failure",
createdAt: now,
execution: { status: "queued" },
completion: { required: false },
queuedLaunch: {
request: { sessionKey: "agent:main:subagent:queued-failure" },
timeoutMs: 1_000,
schedulerGroupKey: '["agent:main:main","restore-failure"]',
maxConcurrent: 1,
},
});
return 1;
}) as never);
mocks.loadSessionStore.mockReturnValue({
"agent:main:subagent:queued-failure": { sessionId: "queued-failure", updatedAt: now },
});
mocks.callGateway.mockImplementation(async (request: { method?: string }) => {
if (request.method === "agent") {
throw new Error("launch failed");
}
return {};
});
mod.initSubagentRegistry();
await waitForFast(() =>
expect(mod.getSubagentRunByRunId("run-queued-failure")).toMatchObject({
execution: { status: "terminal" },
collectorCompletion: { status: "failed" },
}),
);
await waitForFast(() =>
expect(mocks.callGateway).toHaveBeenCalledWith(
expect.objectContaining({
method: "sessions.delete",
params: expect.objectContaining({
key: "agent:main:subagent:queued-failure",
deleteTranscript: true,
}),
}),
),
);
await waitForFast(() =>
expect(mocks.onSubagentEnded).toHaveBeenCalledWith(
expect.objectContaining({
childSessionKey: "agent:main:subagent:queued-failure",
reason: "deleted",
}),
),
);
expect(mocks.emitSessionLifecycleEvent).toHaveBeenCalledWith({
sessionKey: "agent:main:subagent:queued-failure",
reason: "delete",
parentSessionKey: "agent:main:main",
});
expect(mod.getSubagentRunByRunId("run-queued-failure")).toMatchObject({
collectorLaunchCleanupPending: false,
contextEngineCleanupCompletedAt: expect.any(Number),
});
const contextEndCalls = mocks.onSubagentEnded.mock.calls.length;
await mod.testing.sweepOnceForTests();
expect(mocks.onSubagentEnded).toHaveBeenCalledTimes(contextEndCalls);
});
it("retries restored collector session cleanup before announcing deletion", async () => {
const now = Date.now();
mocks.restoreSubagentRunsFromDisk.mockImplementation(((params: {
runs: Map<string, unknown>;
}) => {
params.runs.set("run-queued-cleanup-retry", {
runId: "run-queued-cleanup-retry",
childSessionKey: "agent:main:subagent:queued-cleanup-retry",
requesterSessionKey: "agent:main:main",
swarmRequesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "retry failed cleanup",
cleanup: "keep",
collect: true,
groupId: "restore-cleanup-retry",
createdAt: now,
execution: { status: "queued" },
completion: { required: false },
queuedLaunch: {
request: { sessionKey: "agent:main:subagent:queued-cleanup-retry" },
timeoutMs: 1_000,
schedulerGroupKey: '["agent:main:main","restore-cleanup-retry"]',
maxConcurrent: 1,
},
});
return 1;
}) as never);
mocks.loadSessionStore.mockReturnValue({
"agent:main:subagent:queued-cleanup-retry": {
sessionId: "queued-cleanup-retry",
updatedAt: now,
},
});
let deleteAttempts = 0;
let releaseDelete: (() => void) | undefined;
mocks.callGateway.mockImplementation(async (request: { method?: string }) => {
if (request.method === "agent") {
throw new Error("launch failed");
}
if (request.method === "sessions.delete") {
deleteAttempts += 1;
if (deleteAttempts === 1) {
throw new Error("delete unavailable");
}
return await new Promise<Record<string, unknown>>((resolve) => {
releaseDelete = () => resolve({});
});
}
return {};
});
mod.initSubagentRegistry();
await waitForFast(() =>
expect(mod.getSubagentRunByRunId("run-queued-cleanup-retry")).toMatchObject({
execution: { status: "queued" },
}),
);
await waitForFast(() => expect(releaseDelete).toBeTypeOf("function"));
expect(deleteAttempts).toBe(2);
expect(mocks.emitSessionLifecycleEvent).not.toHaveBeenCalledWith(
expect.objectContaining({
sessionKey: "agent:main:subagent:queued-cleanup-retry",
reason: "delete",
}),
);
expect(mocks.onSubagentEnded).not.toHaveBeenCalled();
expect(
mod.getSubagentRunByRunId("run-queued-cleanup-retry")?.collectorCompletion,
).toBeUndefined();
releaseDelete?.();
await waitForFast(() =>
expect(mod.getSubagentRunByRunId("run-queued-cleanup-retry")).toMatchObject({
execution: { status: "terminal" },
collectorCompletion: { status: "failed" },
}),
);
await waitForFast(() =>
expect(mocks.emitSessionLifecycleEvent).toHaveBeenCalledWith({
sessionKey: "agent:main:subagent:queued-cleanup-retry",
reason: "delete",
parentSessionKey: "agent:main:main",
}),
);
await waitForFast(() =>
expect(mocks.onSubagentEnded).toHaveBeenCalledWith(
expect.objectContaining({
childSessionKey: "agent:main:subagent:queued-cleanup-retry",
reason: "deleted",
}),
),
);
});
it("rolls back a queued collector failure when persistence fails", () => {
const runId = "run-queued-persist-failure";
mod.addSubagentRunForTests({
runId,
childSessionKey: "agent:main:subagent:queued-persist-failure",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "remain queued after sqlite failure",
cleanup: "keep",
collect: true,
groupId: "persist-failure",
createdAt: Date.now(),
execution: { status: "queued" },
completion: { required: false },
queuedLaunch: {
request: { sessionKey: "agent:main:subagent:queued-persist-failure" },
timeoutMs: 1_000,
schedulerGroupKey: '["agent:main:main","persist-failure"]',
maxConcurrent: 1,
},
});
mocks.persistSubagentRunsToDiskOrThrow.mockImplementationOnce(() => {
throw new Error("sqlite busy");
});
expect(() => mod.testing.failQueuedSubagentRun(runId, "launch failed")).toThrow("sqlite busy");
expect(mod.getSubagentRunByRunId(runId)).toMatchObject({
execution: { status: "queued" },
queuedLaunch: { maxConcurrent: 1 },
});
expect(mod.getSubagentRunByRunId(runId)?.collectorCompletion).toBeUndefined();
expect(mod.getSubagentRunByRunId(runId)?.endedAt).toBeUndefined();
});
it("requeues durable requester-settle obligations after a worker error", async () => {
const endedAt = Date.now() - 1_000;
mod.addSubagentRunForTests({
+518 -17
View File
@@ -17,6 +17,7 @@ import {
isGatewayRestartDraining,
runWithGatewayIndependentRootWorkAdmission,
} from "../process/gateway-work-admission.js";
import { emitSessionLifecycleEvent } from "../sessions/session-lifecycle-events.js";
import {
formatAbandonedLivenessError,
formatBlockedLivenessError,
@@ -94,7 +95,7 @@ import {
restoreSubagentRunsFromDisk,
} from "./subagent-registry-state.js";
import { configureSubagentRegistrySteerRuntime } from "./subagent-registry-steer-runtime.js";
import type { SubagentRunRecord } from "./subagent-registry.types.js";
import type { SubagentRunRecord, SwarmStructuredOutputState } from "./subagent-registry.types.js";
import { compareSubagentRunGeneration } from "./subagent-run-generation.js";
import {
resolveSubagentRunDeadlineMs,
@@ -108,11 +109,21 @@ import {
resolveSubagentSessionStartedAt,
type SubagentSessionStoreCache,
} from "./subagent-session-reconciliation.js";
import { resolveSwarmConfig } from "./swarm-config.js";
import { enqueueSwarmRun } from "./swarm-scheduler.js";
import { resolveAgentTimeoutMs } from "./timeout.js";
export type { SubagentRunRecord } from "./subagent-registry.types.js";
const log = createSubsystemLogger("agents/subagent-registry");
function readGatewayRunId(response: unknown): string | undefined {
if (!response || typeof response !== "object") {
return undefined;
}
const runId = (response as { runId?: unknown }).runId;
return typeof runId === "string" && runId.trim() ? runId.trim() : undefined;
}
type SubagentAnnounceModule = Pick<
typeof import("./subagent-announce.js"),
"captureSubagentCompletionReply" | "runSubagentAnnounceFlow"
@@ -667,32 +678,119 @@ function schedulePendingLifecycleTimeout(params: {
});
}
async function notifyContextEngineSubagentEnded(params: {
type ContextEngineSubagentEndedParams = {
childSessionKey: string;
reason: SubagentEndReason;
agentDir?: string;
workspaceDir?: string;
}) {
};
async function runContextEngineSubagentEnded(
params: ContextEngineSubagentEndedParams,
): Promise<void> {
const cfg = subagentRegistryDeps.getRuntimeConfig();
await ensureSubagentRegistryPluginRuntimeLoaded({
config: cfg,
workspaceDir: params.workspaceDir,
allowGatewaySubagentBinding: true,
});
const engine = await resolveSubagentRegistryContextEngine(cfg, {
agentDir: params.agentDir,
workspaceDir: params.workspaceDir,
});
await engine.onSubagentEnded?.(params);
}
async function notifyContextEngineSubagentEnded(
params: ContextEngineSubagentEndedParams,
): Promise<void> {
try {
const cfg = subagentRegistryDeps.getRuntimeConfig();
await ensureSubagentRegistryPluginRuntimeLoaded({
config: cfg,
workspaceDir: params.workspaceDir,
allowGatewaySubagentBinding: true,
});
const engine = await resolveSubagentRegistryContextEngine(cfg, {
agentDir: params.agentDir,
workspaceDir: params.workspaceDir,
});
if (!engine.onSubagentEnded) {
return;
}
await engine.onSubagentEnded(params);
await runContextEngineSubagentEnded(params);
} catch (err) {
log.warn("context-engine onSubagentEnded failed (best-effort)", { err });
}
}
async function finishCollectorContextEngineCleanup(
params: ContextEngineSubagentEndedParams,
): Promise<boolean> {
try {
await runContextEngineSubagentEnded(params);
return true;
} catch (err) {
log.warn("context-engine collector cleanup failed", { err });
return false;
}
}
async function cleanupCollectorLaunchResources(entry: SubagentRunRecord): Promise<boolean> {
let internalEffectsRemoved = true;
try {
await removeInternalSessionEffectsSession(entry.execution?.transcriptTarget);
} catch (err) {
internalEffectsRemoved = false;
log.warn("failed to remove collector internal session effects", {
runId: entry.runId,
childSessionKey: entry.childSessionKey,
err,
});
}
const contextAlreadyEnded = typeof entry.contextEngineCleanupCompletedAt === "number";
const [attachmentsRemoved, contextEnded] = await Promise.all([
safeRemoveAttachmentsDir(entry),
contextAlreadyEnded
? true
: finishCollectorContextEngineCleanup({
childSessionKey: entry.childSessionKey,
reason: "deleted",
agentDir: entry.agentDir,
workspaceDir: entry.workspaceDir,
}),
]);
if (!contextAlreadyEnded && contextEnded) {
entry.contextEngineCleanupCompletedAt = Date.now();
persistSubagentRuns();
}
return internalEffectsRemoved && attachmentsRemoved && contextEnded;
}
async function terminateAcceptedRestoredCollectorRun(params: {
entry: SubagentRunRecord;
gatewayRunId: string;
timeoutMs: number;
}): Promise<void> {
// A restored FIFO slot cannot be released until the accepted Gateway run is
// definitely stopped; otherwise the group can exceed maxConcurrent.
for (;;) {
try {
await subagentRegistryDeps.callGateway({
method: "chat.abort",
params: { sessionKey: params.entry.childSessionKey, runId: params.gatewayRunId },
timeoutMs: params.timeoutMs,
});
return;
} catch {
try {
await subagentRegistryDeps.callGateway({
method: "sessions.delete",
params: {
key: params.entry.childSessionKey,
deleteTranscript: true,
emitLifecycleHooks: false,
},
timeoutMs: params.timeoutMs,
});
return;
} catch {
await new Promise<void>((resolve) => {
const timer = setTimeout(resolve, process.env.OPENCLAW_TEST_FAST === "1" ? 1 : 1_000);
timer.unref?.();
});
}
}
}
}
function suppressAnnounceForSteerRestart(entry?: SubagentRunRecord) {
return entry?.suppressAnnounceReason === "steer-restart";
}
@@ -1009,6 +1107,67 @@ function restoreSubagentRunsOnce() {
startSweeper();
const restoredSessionCache: SubagentSessionStoreCache = new Map();
for (const [runId, entry] of subagentRuns) {
if (entry.collect && entry.execution?.status === "queued") {
const launch = entry.queuedLaunch;
if (!launch) {
void failAndCleanupRestoredQueuedRun(
runId,
entry,
"queued collector launch state was unavailable after restart",
false,
);
continue;
}
const groupRuns = listSwarmRunsForGroup(
entry.groupId ?? "",
entry.swarmRequesterSessionKey ?? entry.requesterSessionKey,
);
const currentSwarmConfig = resolveSwarmConfig(
subagentRegistryDeps.getRuntimeConfig(),
entry.requesterAgentId,
);
let launchTerminationConfirmed = false;
enqueueSwarmRun({
groupId: launch.schedulerGroupKey,
runId,
maxConcurrent: currentSwarmConfig.maxConcurrent,
activeRunIds: groupRuns
.filter((candidate) => candidate.execution?.status === "running")
.map((candidate) => candidate.schedulerSlotId ?? candidate.runId),
start: async () => {
const response = await subagentRegistryDeps.callGateway({
method: "agent",
params: launch.request,
timeoutMs: launch.timeoutMs,
});
const gatewayRunId = readGatewayRunId(response) ?? runId;
try {
if (!startQueuedSubagentRun(runId, gatewayRunId)) {
throw new Error(
"collector registry row could not transition from queued to running",
);
}
} catch (error) {
await terminateAcceptedRestoredCollectorRun({
entry,
gatewayRunId,
timeoutMs: launch.timeoutMs,
});
launchTerminationConfirmed = true;
throw error;
}
},
onStartFailure: (error) => {
return failAndCleanupRestoredQueuedRun(
runId,
entry,
error instanceof Error ? error.message : String(error),
launchTerminationConfirmed,
);
},
});
continue;
}
// An aborted persisted session belongs to orphan recovery. Waiting on its
// pre-restart run can terminalize it before the replacement turn starts.
if (
@@ -1032,6 +1191,79 @@ function restoreSubagentRunsOnce() {
}
}
async function failAndCleanupRestoredQueuedRun(
runId: string,
entry: SubagentRunRecord,
error: string,
launchTerminationConfirmed: boolean,
): Promise<boolean> {
const cleanupComplete = await runWithGatewayIndependentRootWorkAdmission(async () => {
for (;;) {
try {
await subagentRegistryDeps.callGateway({
method: "sessions.delete",
params: {
key: entry.childSessionKey,
deleteTranscript: true,
emitLifecycleHooks: false,
},
timeoutMs: 10_000,
});
break;
} catch (cleanupError) {
log.warn("failed to delete restored collector session after launch failure", {
runId,
childSessionKey: entry.childSessionKey,
error: cleanupError,
});
if (launchTerminationConfirmed) {
return false;
}
}
await new Promise<void>((resolve) => {
const timer = setTimeout(resolve, process.env.OPENCLAW_TEST_FAST === "1" ? 1 : 1_000);
timer.unref?.();
});
}
if (!(await cleanupCollectorLaunchResources(entry))) {
return false;
}
return true;
}).catch((cleanupError: unknown) => {
log.warn("failed to clean restored collector after launch failure", {
runId,
childSessionKey: entry.childSessionKey,
error: cleanupError,
});
return false;
});
for (;;) {
try {
subagentRunManager.settleFailedQueuedSubagentLaunch(runId, error);
break;
} catch (persistError) {
log.warn("failed to persist restored collector launch failure", {
runId,
childSessionKey: entry.childSessionKey,
error: persistError,
});
}
await new Promise<void>((resolve) => {
const timer = setTimeout(resolve, process.env.OPENCLAW_TEST_FAST === "1" ? 1 : 1_000);
timer.unref?.();
});
}
if (cleanupComplete) {
emitSessionLifecycleEvent({
sessionKey: entry.childSessionKey,
reason: "delete",
parentSessionKey: entry.swarmRequesterSessionKey ?? entry.requesterSessionKey,
});
completeCollectorLaunchCleanup(runId);
}
return true;
}
function resolveSubagentWaitTimeoutMs(cfg: OpenClawConfig, runTimeoutSeconds?: number) {
return subagentRegistryDeps.resolveAgentTimeoutMs({
cfg,
@@ -1190,6 +1422,7 @@ async function sweepSubagentRuns() {
const now = Date.now();
const storeCache: SubagentSessionStoreCache = new Map();
let mutated = false;
const archivedCollectorGroups = new Set<string>();
const suspendedEntries = [...subagentRuns.entries()].filter(([, entry]) =>
isSuspendedPendingFinalDelivery(entry),
);
@@ -1522,6 +1755,151 @@ async function sweepSubagentRuns() {
startSubagentAnnounceCleanupFlow(runId, entry);
continue;
}
if (entry.collect && entry.collectorCompletion) {
if (entry.collectorLaunchCleanupPending) {
try {
await subagentRegistryDeps.callGateway({
method: "sessions.delete",
params: {
key: entry.childSessionKey,
deleteTranscript: true,
emitLifecycleHooks: false,
},
timeoutMs: 10_000,
});
} catch (err) {
log.warn("failed to retry collector launch cleanup", {
runId,
childSessionKey: entry.childSessionKey,
err,
});
continue;
}
if (!(await cleanupCollectorLaunchResources(entry))) {
continue;
}
emitSessionLifecycleEvent({
sessionKey: entry.childSessionKey,
reason: "delete",
parentSessionKey: entry.swarmRequesterSessionKey ?? entry.requesterSessionKey,
});
entry.collectorLaunchCleanupPending = false;
entry.cleanupCompletedAt = now;
mutated = true;
}
const groupId = entry.groupId?.trim();
const swarmRequesterSessionKey =
entry.swarmRequesterSessionKey ?? entry.requesterSessionKey;
const groupKey = groupId ? JSON.stringify([swarmRequesterSessionKey, groupId]) : undefined;
if (!groupKey || archivedCollectorGroups.has(groupKey)) {
continue;
}
const groupEntries = [...subagentRuns.entries()].filter(
([, candidate]) =>
candidate.collect === true &&
(candidate.swarmRequesterSessionKey ?? candidate.requesterSessionKey) ===
swarmRequesterSessionKey &&
candidate.groupId === groupId,
);
// Collector results and lifetime cap accounting remain durable until
// the entire group reaches its shared TTL, then archive as one batch.
if (
groupEntries.some(
([, candidate]) =>
!candidate.collectorCompletion ||
candidate.collectorLaunchCleanupPending === true ||
candidate.archiveAtMs === undefined ||
candidate.archiveAtMs > now,
)
) {
continue;
}
let deleteFailed = false;
// Lifecycle cleanup already attempted each delete-mode session. Retry
// here only so a transient cleanup failure cannot survive group archive.
for (const [candidateRunId, candidate] of groupEntries) {
if (candidate.cleanup !== "delete") {
continue;
}
try {
await subagentRegistryDeps.callGateway({
method: "sessions.delete",
params: {
key: candidate.childSessionKey,
deleteTranscript: true,
emitLifecycleHooks: false,
},
timeoutMs: 10_000,
});
} catch (err) {
log.warn("sessions.delete failed during collector group sweep; keeping group", {
runId: candidateRunId,
childSessionKey: candidate.childSessionKey,
groupId,
err,
});
deleteFailed = true;
break;
}
}
if (deleteFailed) {
continue;
}
let attachmentCleanupFailed = false;
for (const [candidateRunId, candidate] of groupEntries) {
if (await safeRemoveAttachmentsDir(candidate)) {
continue;
}
log.warn("attachment cleanup failed during collector group sweep; keeping group", {
runId: candidateRunId,
childSessionKey: candidate.childSessionKey,
groupId,
});
attachmentCleanupFailed = true;
break;
}
if (attachmentCleanupFailed) {
continue;
}
let contextCleanupFailed = false;
for (const [candidateRunId, candidate] of groupEntries) {
if (
candidate.cleanup === "delete" ||
typeof candidate.contextEngineCleanupCompletedAt === "number"
) {
continue;
}
try {
await runContextEngineSubagentEnded({
childSessionKey: candidate.childSessionKey,
reason: "swept",
agentDir: candidate.agentDir,
workspaceDir: candidate.workspaceDir,
});
candidate.contextEngineCleanupCompletedAt = Date.now();
persistSubagentRuns();
} catch (err) {
log.warn("context-engine cleanup failed during collector group sweep; keeping group", {
runId: candidateRunId,
childSessionKey: candidate.childSessionKey,
groupId,
err,
});
contextCleanupFailed = true;
break;
}
}
if (contextCleanupFailed) {
continue;
}
for (const [candidateRunId] of groupEntries) {
clearPendingLifecycleError(candidateRunId);
subagentRuns.delete(candidateRunId);
}
archivedCollectorGroups.add(groupKey);
mutated = true;
continue;
}
if (!entry.archiveAtMs && entry.cleanup === "keep" && entry.spawnMode !== "session") {
continue;
}
@@ -1793,6 +2171,31 @@ const subagentRunManager = createSubagentRunManager({
configureSubagentRegistrySteerRuntime({
replaceSubagentRunAfterSteer: (params) => subagentRunManager.replaceSubagentRunAfterSteer(params),
finalizeInterruptedSubagentRun: async (params) => await finalizeInterruptedSubagentRun(params),
reserveSwarmCollectorLaunch: (runId, idempotencyKey) => {
const entry =
subagentRuns.get(runId) ??
[...subagentRuns.values()].find((candidate) => candidate.swarmRunId === runId);
if (
!entry ||
entry.collect !== true ||
entry.collectorCompletion ||
typeof entry.endedAt === "number"
) {
return false;
}
const previousIdempotencyKey = entry.swarmLaunchIdempotencyKey;
const previousPending = entry.swarmLaunchPending;
entry.swarmLaunchIdempotencyKey = idempotencyKey;
entry.swarmLaunchPending = true;
try {
persistSubagentRunsOrThrow();
} catch (error) {
entry.swarmLaunchIdempotencyKey = previousIdempotencyKey;
entry.swarmLaunchPending = previousPending;
throw error;
}
return true;
},
});
export function markSubagentRunForSteerRestart(runId: string) {
@@ -1819,6 +2222,18 @@ export function registerSubagentRun(params: RegisterSubagentRunParams) {
subagentRunManager.registerSubagentRun(params);
}
export function startQueuedSubagentRun(runId: string, gatewayRunId?: string) {
return subagentRunManager.startQueuedSubagentRun(runId, gatewayRunId);
}
function failQueuedSubagentRun(runId: string, error: string) {
return subagentRunManager.failQueuedSubagentRun(runId, error);
}
export function settleFailedQueuedSubagentLaunch(runId: string, error: string) {
return subagentRunManager.settleFailedQueuedSubagentLaunch(runId, error);
}
function resetSubagentRegistryForTests(opts?: { persist?: boolean }) {
clearScheduledResumeTimers();
for (const timer of resumeRetryTimers) {
@@ -1851,6 +2266,7 @@ function resetSubagentRegistryForTests(opts?: { persist?: boolean }) {
}
const testing = {
failQueuedSubagentRun,
async sweepOnceForTests() {
await sweepSubagentRuns();
},
@@ -2029,6 +2445,91 @@ export function listSubagentRunsForController(controllerSessionKey: string): Sub
);
}
export function getSubagentRunByRunId(runId: string): SubagentRunRecord | undefined {
const key = runId.trim();
const snapshot = subagentRegistryDeps.getSubagentRunsSnapshotForRead(subagentRuns);
return snapshot.get(key) ?? [...snapshot.values()].find((entry) => entry.swarmRunId === key);
}
export function getSubagentRunsByRunIds(runIds: readonly string[]): {
entries: Map<string, SubagentRunRecord>;
} {
const snapshot = subagentRegistryDeps.getSubagentRunsSnapshotForRead(subagentRuns);
const byId = new Map<string, SubagentRunRecord>();
for (const entry of snapshot.values()) {
byId.set(entry.runId, entry);
if (entry.swarmRunId) {
byId.set(entry.swarmRunId, entry);
}
}
return {
entries: new Map(
runIds.flatMap((runId) => {
const entry = byId.get(runId.trim());
return entry ? [[runId, entry] as const] : [];
}),
),
};
}
export function completeCollectorLaunchCleanup(runId: string): void {
const key = runId.trim();
const entry =
subagentRuns.get(key) ??
[...subagentRuns.values()].find((candidate) => candidate.swarmRunId === key);
if (!entry?.collectorLaunchCleanupPending) {
return;
}
entry.collectorLaunchCleanupPending = false;
entry.cleanupCompletedAt = Date.now();
entry.contextEngineCleanupCompletedAt ??= entry.cleanupCompletedAt;
persistSubagentRuns();
}
export function recordSwarmStructuredOutput(
identity: { runId?: string; childSessionKey?: string },
state: SwarmStructuredOutputState,
): void {
const runId = identity.runId?.trim();
const childSessionKey = identity.childSessionKey?.trim();
const entry =
(runId
? (subagentRuns.get(runId) ??
[...subagentRuns.values()].find((candidate) => candidate.swarmRunId === runId))
: undefined) ??
(childSessionKey
? [...subagentRuns.values()]
.filter((candidate) => candidate.childSessionKey === childSessionKey)
.toSorted((left, right) => (right.generation ?? 0) - (left.generation ?? 0))[0]
: undefined);
if (!entry?.collect || entry.collectorCompletion) {
throw new Error("collector run is unavailable");
}
const previous = entry.structuredOutput;
entry.structuredOutput = structuredClone(state);
try {
persistSubagentRunsOrThrow();
} catch (error) {
entry.structuredOutput = previous;
throw error;
}
}
export function listSwarmRunsForGroup(
groupId: string,
requesterSessionKey?: string,
): SubagentRunRecord[] {
const key = groupId.trim();
const requesterKey = requesterSessionKey?.trim();
return [...subagentRegistryDeps.getSubagentRunsSnapshotForRead(subagentRuns).values()].filter(
(entry) =>
entry.collect === true &&
entry.groupId === key &&
(!requesterKey ||
(entry.swarmRequesterSessionKey ?? entry.requesterSessionKey) === requesterKey),
);
}
export function countActiveRunsForSession(requesterSessionKey: string): number {
return countActiveRunsForSessionFromRuns(
subagentRegistryDeps.getSubagentRunsSnapshotForRead(subagentRuns),
+48 -1
View File
@@ -37,7 +37,7 @@ export type PendingFinalDeliveryPayload = {
};
export type SubagentExecutionState = {
status: "running" | "interrupted" | "terminal";
status: "queued" | "running" | "interrupted" | "terminal";
startedAt?: number;
endedAt?: number;
outcome?: SubagentRunOutcome;
@@ -54,6 +54,28 @@ export type SubagentCompletionState = {
fallbackCapturedAt?: number;
};
export type SwarmCollectorStatus = "done" | "failed" | "killed" | "timeout";
type SwarmCollectorCompletion = {
status: SwarmCollectorStatus;
structured?: unknown;
schemaError?: string;
usage?: { inputTokens: number; outputTokens: number };
};
export type SwarmStructuredOutputState = {
structured?: unknown;
schemaError?: string;
invalidAttempts: number;
};
export type SwarmQueuedLaunch = {
request: Record<string, unknown>;
timeoutMs: number;
schedulerGroupKey: string;
maxConcurrent: number;
};
export type SubagentCompletionDeliveryState = {
status:
| "not_required"
@@ -142,6 +164,8 @@ export type SubagentRunRecord = {
/** Durable source locator for transport-neutral progress presentation. */
progressOrigin?: SubagentProgressOrigin;
requesterDisplayKey: string;
/** Effective requester agent, including cron/hook overrides not encoded in the session key. */
requesterAgentId?: string;
task: string;
taskName?: string;
cleanup: "delete" | "keep";
@@ -188,4 +212,27 @@ export type SubagentRunRecord = {
attachmentsDir?: string;
attachmentsRootDir?: string;
retainAttachmentsOnKeep?: boolean;
/** Collector-mode runs remain waitable and never announce to the requester. */
collect?: boolean;
/** Stable spawning-session owner for caps, scheduling, and wait authorization. */
swarmRequesterSessionKey?: string;
/** Spawner plus ancestor sessions authorized to wait, frozen when the collector is registered. */
swarmWaitOwnerSessionKeys?: string[];
/** Stable public collector id; gateway execution ids can change across dispatch/recovery. */
swarmRunId?: string;
/** Stable scheduler slot identity across gateway-assigned run id replacements. */
schedulerSlotId?: string;
/** Exact host-reserved Gateway request identity for the current collector turn. */
swarmLaunchIdempotencyKey?: string;
/** True only between host reservation and accepted Gateway dispatch. */
swarmLaunchPending?: boolean;
groupId?: string;
outputSchema?: Record<string, unknown>;
structuredOutput?: SwarmStructuredOutputState;
queuedLaunch?: SwarmQueuedLaunch;
/** Durable retry obligation for a prepared collector session whose launch failed. */
collectorLaunchCleanupPending?: boolean;
/** Set after failed-launch context-engine cleanup succeeds, preventing duplicate end hooks. */
contextEngineCleanupCompletedAt?: number;
collectorCompletion?: SwarmCollectorCompletion;
};
+3
View File
@@ -5,6 +5,7 @@
*/
import { formatThinkingLevels } from "../auto-reply/thinking.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { FastMode } from "../shared/fast-mode.js";
import {
resolveDefaultModelForAgent,
resolveSubagentConfiguredModelSelection,
@@ -59,6 +60,7 @@ export function resolveSubagentModelAndThinkingPlan(params: {
modelOverride?: string;
thinkingOverrideRaw?: string;
callerThinkingRaw?: string;
fastMode?: FastMode;
}) {
const resolvedModel = resolveSubagentSpawnModelSelection({
cfg: params.cfg,
@@ -127,6 +129,7 @@ export function resolveSubagentModelAndThinkingPlan(params: {
}
: {}),
...thinkingPlan.initialSessionPatch,
...(params.fastMode !== undefined ? { fastMode: params.fastMode } : {}),
},
};
}
+1
View File
@@ -27,6 +27,7 @@ export {
} from "../utils/delivery-context.shared.js";
export { resolveAgentConfig } from "./agent-scope.js";
export { AGENT_LANE_SUBAGENT } from "./lanes.js";
export { loadModelCatalog } from "./model-catalog.js";
export { resolveSandboxRuntimeStatus } from "./sandbox/runtime-status.js";
export { buildSubagentSystemPrompt } from "./subagent-system-prompt.js";
export { resolveInternalSessionKey, resolveMainSessionAlias } from "./tools/sessions-helpers.js";
+11
View File
@@ -133,6 +133,7 @@ export async function loadSubagentSpawnModuleForTest(params: {
hasInProcessGatewayContextMock?: MockFn;
getRuntimeConfig?: () => Record<string, unknown>;
loadSessionStoreMock?: MockFn;
loadModelCatalogMock?: MockFn;
ensureContextEnginesInitializedMock?: MockFn;
updateSessionStoreMock?: MockFn;
forkSessionEntryFromParentMock?: MockFn;
@@ -140,6 +141,9 @@ export async function loadSubagentSpawnModuleForTest(params: {
resolveContextEngineMock?: MockFn;
resolveParentForkDecisionMock?: MockFn;
registerSubagentRunMock?: MockFn;
startQueuedSubagentRunMock?: MockFn;
settleFailedQueuedSubagentLaunchMock?: MockFn;
completeCollectorLaunchCleanupMock?: MockFn;
emitSessionLifecycleEventMock?: MockFn;
hookRunner?: HookRunner;
resolveAgentConfig?: (cfg: Record<string, unknown>, agentId: string) => unknown;
@@ -147,6 +151,7 @@ export async function loadSubagentSpawnModuleForTest(params: {
resolveSubagentSpawnModelSelection?: () => string | undefined;
getSubagentDepthFromSessionStore?: (sessionKey: string, opts?: unknown) => number;
countActiveRunsForSession?: (sessionKey: string) => number;
listSwarmRunsForGroup?: (groupId: string) => unknown[];
resolveSandboxRuntimeStatus?: (params: {
cfg?: Record<string, unknown>;
sessionKey?: string;
@@ -261,6 +266,7 @@ export async function loadSubagentSpawnModuleForTest(params: {
getRuntimeConfig: () =>
params.getRuntimeConfig?.() ??
createSubagentSpawnTestConfig(params.workspaceDir ?? os.tmpdir()),
loadModelCatalog: (...args: unknown[]) => params.loadModelCatalogMock?.(...args) ?? [],
loadSessionEntry: (scope: { storePath?: string; sessionKey: string }) =>
((params.loadSessionStoreMock?.(scope.storePath) ?? {}) as SessionStore)[scope.sessionKey],
loadSessionStore: params.loadSessionStoreMock ?? (() => ({})),
@@ -381,10 +387,15 @@ export async function loadSubagentSpawnModuleForTest(params: {
}));
vi.doMock("./subagent-registry.js", () => ({
completeCollectorLaunchCleanup: params.completeCollectorLaunchCleanupMock ?? vi.fn(),
countActiveRunsForSession: params.countActiveRunsForSession ?? (() => 0),
listSwarmRunsForGroup: params.listSwarmRunsForGroup ?? vi.fn(() => []),
registerSubagentRun:
params.registerSubagentRunMock ?? vi.fn((_record: Record<string, unknown>) => undefined),
resetSubagentRegistryForTests,
settleFailedQueuedSubagentLaunch:
params.settleFailedQueuedSubagentLaunchMock ?? vi.fn(() => true),
startQueuedSubagentRun: params.startQueuedSubagentRunMock ?? vi.fn(() => true),
}));
const subagentSpawnModule = await import("./subagent-spawn.js");
@@ -11,6 +11,7 @@ type SpawnDeps = Omit<
| "getGlobalHookRunner"
| "getRuntimeConfig"
| "hasInProcessGatewayContext"
| "loadModelCatalog"
| "resolveContextEngine"
>,
"getGlobalHookRunner"
+594 -1
View File
@@ -1,24 +1,32 @@
// Subagent spawn tests cover target policy, session patching, runtime model
// persistence, registry registration, and lifecycle event emission.
import os from "node:os";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import {
createSubagentSpawnTestConfig,
expectPersistedRuntimeModel,
installSessionStoreCaptureMock,
loadSubagentSpawnModuleForTest,
} from "./subagent-spawn.test-helpers.js";
import { testing as swarmSchedulerTesting } from "./swarm-scheduler.test-support.js";
import { installAcceptedSubagentGatewayMock } from "./test-helpers/subagent-gateway.js";
const hoisted = vi.hoisted(() => ({
callGatewayMock: vi.fn(),
loadSessionStoreMock: vi.fn(),
loadModelCatalogMock: vi.fn(),
updateSessionStoreMock: vi.fn(),
registerSubagentRunMock: vi.fn(),
startQueuedSubagentRunMock: vi.fn(),
settleFailedQueuedSubagentLaunchMock: vi.fn(),
completeCollectorLaunchCleanupMock: vi.fn(),
emitSessionLifecycleEventMock: vi.fn(),
dispatchGatewayMethodInProcessMock: vi.fn(),
hasInProcessGatewayContextMock: vi.fn(),
resolveAgentConfigMock: vi.fn(),
resolveContextEngineMock: vi.fn(),
countActiveRunsForSessionMock: vi.fn(),
listSwarmRunsForGroupMock: vi.fn(),
configOverride: {} as Record<string, unknown>,
}));
@@ -72,10 +80,17 @@ describe("spawnSubagentDirect seam flow", () => {
hasInProcessGatewayContextMock: hoisted.hasInProcessGatewayContextMock,
getRuntimeConfig: () => hoisted.configOverride,
loadSessionStoreMock: hoisted.loadSessionStoreMock,
loadModelCatalogMock: hoisted.loadModelCatalogMock,
updateSessionStoreMock: hoisted.updateSessionStoreMock,
registerSubagentRunMock: hoisted.registerSubagentRunMock,
startQueuedSubagentRunMock: hoisted.startQueuedSubagentRunMock,
settleFailedQueuedSubagentLaunchMock: hoisted.settleFailedQueuedSubagentLaunchMock,
completeCollectorLaunchCleanupMock: hoisted.completeCollectorLaunchCleanupMock,
emitSessionLifecycleEventMock: hoisted.emitSessionLifecycleEventMock,
resolveAgentConfig: hoisted.resolveAgentConfigMock,
resolveContextEngineMock: hoisted.resolveContextEngineMock,
countActiveRunsForSession: hoisted.countActiveRunsForSessionMock,
listSwarmRunsForGroup: hoisted.listSwarmRunsForGroupMock,
resolveSubagentSpawnModelSelection: () => "openai/gpt-5.4",
resolveSandboxRuntimeStatus: () => ({ sandboxed: false }),
sessionStorePath: "/tmp/subagent-spawn-session-store.json",
@@ -83,15 +98,23 @@ describe("spawnSubagentDirect seam flow", () => {
});
beforeEach(() => {
swarmSchedulerTesting.reset();
resetSubagentRegistryForTests();
hoisted.callGatewayMock.mockReset();
hoisted.loadSessionStoreMock.mockReset();
hoisted.loadModelCatalogMock.mockReset().mockResolvedValue([]);
hoisted.updateSessionStoreMock.mockReset();
hoisted.registerSubagentRunMock.mockReset();
hoisted.startQueuedSubagentRunMock.mockReset().mockReturnValue(true);
hoisted.settleFailedQueuedSubagentLaunchMock.mockReset().mockReturnValue(true);
hoisted.completeCollectorLaunchCleanupMock.mockReset();
hoisted.emitSessionLifecycleEventMock.mockReset();
hoisted.dispatchGatewayMethodInProcessMock.mockReset();
hoisted.hasInProcessGatewayContextMock.mockReset().mockReturnValue(false);
hoisted.resolveAgentConfigMock.mockReset();
hoisted.resolveContextEngineMock.mockReset().mockResolvedValue({});
hoisted.countActiveRunsForSessionMock.mockReset().mockReturnValue(0);
hoisted.listSwarmRunsForGroupMock.mockReset().mockReturnValue([]);
hoisted.resolveAgentConfigMock.mockImplementation(
(cfg: { agents?: { list?: Array<{ id?: string }> } }, agentId: string) =>
cfg.agents?.list?.find((agent) => agent.id === agentId),
@@ -112,6 +135,56 @@ describe("spawnSubagentDirect seam flow", () => {
);
});
afterEach(() => {
swarmSchedulerTesting.reset();
vi.unstubAllEnvs();
});
it("rejects direct swarm parameters while tools.swarm is disabled", async () => {
const result = await spawnSubagentDirect(
{ task: "collect", collect: true },
{ agentSessionKey: "agent:main:main", requesterRunId: "parent-run" },
);
expect(result).toMatchObject({
status: "forbidden",
error: expect.stringContaining("tools.swarm.enabled=true"),
});
expect(gatewayRequestRecords()).toEqual([]);
});
it("requires a requesting run id when a collector omits groupId", async () => {
hoisted.configOverride = createConfigOverride({ tools: { swarm: true } });
const result = await spawnSubagentDirect(
{ task: "missing default group identity", collect: true },
{ agentSessionKey: "agent:main:main" },
);
expect(result).toMatchObject({
status: "error",
error: expect.stringContaining("requesting run id"),
});
});
it.each([{ mode: "session" as const }, { thread: true }])(
"rejects interactive collector mode at the direct spawn boundary",
async (params) => {
hoisted.configOverride = createConfigOverride({ tools: { swarm: true } });
const result = await spawnSubagentDirect(
{ task: "collect once", collect: true, ...params },
{ agentSessionKey: "agent:main:main", requesterRunId: "parent-run" },
);
expect(result).toMatchObject({
status: "error",
error: expect.stringContaining("mode=run and thread=false"),
});
expect(gatewayRequestRecords()).toEqual([]);
},
);
it("rejects explicit same-agent targets when allowAgents excludes the requester", async () => {
hoisted.configOverride = createConfigOverride({
agents: {
@@ -184,6 +257,462 @@ describe("spawnSubagentDirect seam flow", () => {
expect(result.childSessionKey).toMatch(/^agent:task-manager:subagent:/);
});
it("defaults collector group id from requester session and requesting run", async () => {
hoisted.configOverride = createConfigOverride({ tools: { swarm: true } });
const sessionPatches: Record<string, unknown>[] = [];
hoisted.updateSessionStoreMock.mockImplementation(
async (
_storePath: string,
mutator: (store: Record<string, Record<string, unknown>>) => unknown,
) => {
const store: Record<string, Record<string, unknown>> = {};
await mutator(store);
sessionPatches.push(...Object.values(store));
return store;
},
);
const result = await spawnSubagentDirect(
{
task: "collect evidence",
collect: true,
outputSchema: { type: "object", required: ["answer"] },
},
{
agentSessionKey: "agent:main:main",
agentChannel: "telegram",
agentAccountId: "default",
agentTo: "chat:123",
agentThreadId: "456",
requesterRunId: "parent-run",
},
);
expect(result.status).toBe("accepted");
expect(result.sessionKey).toBe(result.childSessionKey);
const registerInput = firstRegisteredSubagentRun();
expect(registerInput).toMatchObject({
runId: result.runId,
collect: true,
queued: true,
expectsCompletionMessage: false,
groupId: "swarm:agent:main:main:parent-run",
outputSchema: { type: "object", required: ["answer"] },
progressOrigin: {
channel: "telegram",
accountId: "default",
to: "chat:123",
threadId: "456",
},
});
expect(sessionPatches).toContainEqual(
expect.objectContaining({
swarmGroupId: "swarm:agent:main:main:parent-run",
swarmCollector: true,
swarmOutputSchema: { type: "object", required: ["answer"] },
}),
);
await vi.waitFor(() =>
expect(gatewayRequest("agent")).toEqual(expect.objectContaining({ method: "agent" })),
);
expect(gatewayRequest("agent")).toMatchObject({
params: {
swarmCollector: true,
swarmOutputSchema: { type: "object", required: ["answer"] },
},
});
const agentParams = requireRecord(gatewayRequest("agent").params);
expect(agentParams).not.toHaveProperty("channel");
expect(agentParams).not.toHaveProperty("to");
expect(agentParams).not.toHaveProperty("accountId");
expect(agentParams).not.toHaveProperty("threadId");
expect(agentParams.extraSystemPrompt).toContain("until one payload is accepted");
expect(agentParams.extraSystemPrompt).toContain("at most one retry");
await vi.waitFor(() =>
expect(hoisted.startQueuedSubagentRunMock).toHaveBeenCalledWith(result.runId, "run-1"),
);
});
it("aborts a collector cancelled while its gateway launch is in flight", async () => {
hoisted.configOverride = createConfigOverride({ tools: { swarm: true } });
hoisted.startQueuedSubagentRunMock.mockReturnValue(false);
const result = await spawnSubagentDirect(
{ task: "cancel during launch", collect: true },
{ agentSessionKey: "agent:main:main", requesterRunId: "parent-run" },
);
expect(result.status).toBe("accepted");
await vi.waitFor(() => expect(gatewayRequest("chat.abort")).toBeDefined());
expect(gatewayRequest("chat.abort")).toMatchObject({
params: { sessionKey: result.childSessionKey, runId: "run-1" },
});
await vi.waitFor(() =>
expect(hoisted.completeCollectorLaunchCleanupMock).toHaveBeenCalledWith(result.runId),
);
});
it("holds the collector slot until an accepted run is confirmed stopped", async () => {
vi.stubEnv("OPENCLAW_TEST_FAST", "1");
hoisted.configOverride = createConfigOverride({
tools: { swarm: { enabled: true, maxConcurrent: 1 } },
});
hoisted.startQueuedSubagentRunMock.mockReturnValueOnce(false).mockReturnValue(true);
let stopAllowed = false;
let agentCalls = 0;
let abortCalls = 0;
hoisted.callGatewayMock.mockImplementation(async (request: { method?: string }) => {
if (request.method === "agent") {
agentCalls += 1;
return { runId: `gateway-${agentCalls}` };
}
if (request.method === "chat.abort") {
abortCalls += 1;
if (!stopAllowed) {
throw new Error("abort unavailable");
}
return {};
}
if (request.method === "sessions.delete") {
throw new Error("delete unavailable");
}
return {};
});
const first = await spawnSubagentDirect(
{ task: "stop-confirmation-first", collect: true, groupId: "stop-confirmation" },
{ agentSessionKey: "agent:main:main", requesterRunId: "parent-run" },
);
const second = await spawnSubagentDirect(
{ task: "stop-confirmation-second", collect: true, groupId: "stop-confirmation" },
{ agentSessionKey: "agent:main:main", requesterRunId: "parent-run" },
);
await vi.waitFor(() => expect(abortCalls).toBeGreaterThan(0));
expect(agentCalls).toBe(1);
stopAllowed = true;
await vi.waitFor(() => expect(agentCalls).toBe(2));
await vi.waitFor(() =>
expect(hoisted.startQueuedSubagentRunMock).toHaveBeenCalledWith(second.runId, "gateway-2"),
);
expect(hoisted.settleFailedQueuedSubagentLaunchMock).toHaveBeenCalledWith(
first.runId,
expect.any(String),
);
});
it("holds the collector slot while an indeterminate launch session is deleted", async () => {
hoisted.configOverride = createConfigOverride({
tools: { swarm: { enabled: true, maxConcurrent: 1 } },
});
let agentCalls = 0;
let releaseDelete: (() => void) | undefined;
hoisted.callGatewayMock.mockImplementation(
async (request: { method?: string; params?: unknown }) => {
if (request.method === "agent") {
const message = String(requireRecord(request.params).message);
if (
!message.includes("indeterminate-first") &&
!message.includes("indeterminate-second")
) {
return { runId: "unrelated" };
}
agentCalls += 1;
if (agentCalls === 1) {
throw new Error("launch response lost");
}
return { runId: "gateway-second" };
}
if (request.method === "sessions.delete") {
return await new Promise<Record<string, unknown>>((resolve) => {
releaseDelete = () => resolve({});
});
}
return {};
},
);
await spawnSubagentDirect(
{ task: "indeterminate-first", collect: true, groupId: "indeterminate" },
{ agentSessionKey: "agent:main:main", requesterRunId: "parent-run" },
);
await spawnSubagentDirect(
{ task: "indeterminate-second", collect: true, groupId: "indeterminate" },
{ agentSessionKey: "agent:main:main", requesterRunId: "parent-run" },
);
await vi.waitFor(() => expect(releaseDelete).toBeTypeOf("function"));
expect(agentCalls).toBe(1);
expect(hoisted.settleFailedQueuedSubagentLaunchMock).not.toHaveBeenCalled();
releaseDelete?.();
await vi.waitFor(() => expect(agentCalls).toBe(2));
expect(hoisted.settleFailedQueuedSubagentLaunchMock).toHaveBeenCalledOnce();
});
it("emits collector deletion after an asynchronous launch failure", async () => {
hoisted.configOverride = createConfigOverride({ tools: { swarm: true } });
hoisted.callGatewayMock.mockImplementation(async (request: { method?: string }) => {
if (request.method === "agent") {
throw new Error("launch failed");
}
return {};
});
const result = await spawnSubagentDirect(
{ task: "fail launch", collect: true },
{ agentSessionKey: "agent:main:main", requesterRunId: "parent-run" },
);
expect(result.status).toBe("accepted");
await vi.waitFor(() =>
expect(hoisted.emitSessionLifecycleEventMock).toHaveBeenCalledWith({
sessionKey: result.childSessionKey,
reason: "delete",
parentSessionKey: "agent:main:main",
}),
);
});
it("keeps failed-launch cleanup pending when context rollback fails", async () => {
hoisted.configOverride = createConfigOverride({ tools: { swarm: true } });
hoisted.resolveContextEngineMock.mockResolvedValue({
prepareSubagentSpawn: async () => ({
rollback: async () => {
throw new Error("rollback unavailable");
},
}),
});
hoisted.callGatewayMock.mockImplementation(async (request: { method?: string }) => {
if (request.method === "agent") {
throw new Error("launch failed");
}
return {};
});
await spawnSubagentDirect(
{ task: "fail launch", collect: true },
{ agentSessionKey: "agent:main:main", requesterRunId: "parent-run" },
);
await vi.waitFor(() =>
expect(
hoisted.callGatewayMock.mock.calls.some(
([request]) => (request as { method?: string }).method === "sessions.delete",
),
).toBe(true),
);
expect(hoisted.completeCollectorLaunchCleanupMock).not.toHaveBeenCalled();
});
it("uses and validates tools.swarm.defaultAgentId for collector children", async () => {
hoisted.configOverride = createConfigOverride({
tools: { swarm: { enabled: true, defaultAgentId: "worker" } },
agents: {
defaults: { workspace: os.tmpdir() },
list: [
{
id: "main",
workspace: "/tmp/workspace-main",
subagents: { allowAgents: ["worker"] },
},
{ id: "worker", workspace: "/tmp/workspace-worker" },
],
},
});
const result = await spawnSubagentDirect(
{ task: "collect as worker", collect: true },
{ agentSessionKey: "agent:main:main", requesterRunId: "parent-run" },
);
expect(result.status).toBe("accepted");
expect(result.childSessionKey).toMatch(/^agent:worker:subagent:/);
hoisted.configOverride = createConfigOverride({
tools: { swarm: { enabled: true, defaultAgentId: "missing" } },
});
const rejected = await spawnSubagentDirect(
{ task: "collect as missing", collect: true },
{ agentSessionKey: "agent:main:main", requesterRunId: "parent-run" },
);
expect(rejected.status).toBe("forbidden");
expect(rejected.error).toContain("tools.swarm.defaultAgentId");
});
it("rejects collector live and lifetime caps with config-key errors", async () => {
hoisted.configOverride = createConfigOverride({
tools: {
swarm: {
enabled: true,
maxChildrenPerGroup: 1,
maxTotalPerGroup: 2,
},
},
});
hoisted.listSwarmRunsForGroupMock.mockReturnValueOnce([
{ runId: "live", collect: true, groupId: "group" },
]);
const liveRejected = await spawnSubagentDirect(
{ task: "second live child", collect: true, groupId: "group" },
{ agentSessionKey: "agent:main:main", requesterRunId: "parent-run" },
);
expect(liveRejected.status).toBe("forbidden");
expect(liveRejected.error).toContain("tools.swarm.maxChildrenPerGroup");
expect(hoisted.listSwarmRunsForGroupMock).toHaveBeenLastCalledWith("group", "agent:main:main");
hoisted.listSwarmRunsForGroupMock.mockReturnValueOnce([
{ runId: "done", collect: true, collectorCompletion: { status: "done" } },
{ runId: "failed", collect: true, collectorCompletion: { status: "failed" } },
]);
const totalRejected = await spawnSubagentDirect(
{ task: "third lifetime child", collect: true, groupId: "group" },
{ agentSessionKey: "agent:main:main", requesterRunId: "parent-run" },
);
expect(totalRejected.status).toBe("forbidden");
expect(totalRejected.error).toContain("tools.swarm.maxTotalPerGroup");
});
it("keeps live collector caps independent across caller-supplied group ids", async () => {
hoisted.configOverride = createConfigOverride({
tools: { swarm: { enabled: true, maxChildrenPerGroup: 1 } },
});
const accepted = await spawnSubagentDirect(
{ task: "new group", collect: true, groupId: "fresh" },
{ agentSessionKey: "agent:main:main", requesterRunId: "parent-run" },
);
expect(accepted.status).toBe("accepted");
expect(hoisted.listSwarmRunsForGroupMock).toHaveBeenCalledWith("fresh", "agent:main:main");
});
it("enforces group caps atomically across concurrent collector registration", async () => {
hoisted.configOverride = createConfigOverride({
tools: { swarm: { enabled: true, maxChildrenPerGroup: 1 } },
});
hoisted.listSwarmRunsForGroupMock.mockImplementation(() =>
hoisted.registerSubagentRunMock.mock.calls.map(([run]) => requireRecord(run)),
);
const results = await Promise.all([
spawnSubagentDirect(
{ task: "first concurrent child", collect: true, groupId: "shared" },
{ agentSessionKey: "agent:main:main", requesterRunId: "parent-run" },
),
spawnSubagentDirect(
{ task: "second concurrent child", collect: true, groupId: "shared" },
{ agentSessionKey: "agent:main:main", requesterRunId: "parent-run" },
),
]);
expect(results.map((result) => result.status).toSorted()).toEqual(["accepted", "forbidden"]);
expect(results.find((result) => result.status === "forbidden")?.error).toContain(
"tools.swarm.maxChildrenPerGroup",
);
expect(hoisted.registerSubagentRunMock).toHaveBeenCalledTimes(1);
});
it("retains the requester-wide active-child ceiling for collector groups", async () => {
hoisted.configOverride = createConfigOverride({
tools: { swarm: true },
agents: {
defaults: {
workspace: os.tmpdir(),
subagents: { maxChildrenPerAgent: 1 },
},
list: [{ id: "main", workspace: "/tmp/workspace-main" }],
},
});
hoisted.countActiveRunsForSessionMock.mockReturnValue(1);
const rejected = await spawnSubagentDirect(
{ task: "new group", collect: true, groupId: "fresh" },
{ agentSessionKey: "agent:main:main", requesterRunId: "parent-run" },
);
expect(rejected.status).toBe("forbidden");
expect(rejected.error).toContain("max active children");
});
it("rechecks the requester-wide cap before reserving a prepared collector", async () => {
hoisted.configOverride = createConfigOverride({
tools: { swarm: true },
agents: {
defaults: {
workspace: os.tmpdir(),
subagents: { maxChildrenPerAgent: 1 },
},
list: [{ id: "main", workspace: "/tmp/workspace-main" }],
},
});
hoisted.countActiveRunsForSessionMock.mockReturnValueOnce(0).mockReturnValueOnce(1);
const rejected = await spawnSubagentDirect(
{ task: "concurrent collector", collect: true, groupId: "fresh" },
{ agentSessionKey: "agent:main:main", requesterRunId: "parent-run" },
);
expect(rejected.status).toBe("forbidden");
expect(rejected.error).toContain("max active children");
expect(hoisted.registerSubagentRunMock).not.toHaveBeenCalled();
});
it("rejects invalid collector output schemas before creating a child session", async () => {
hoisted.configOverride = createConfigOverride({ tools: { swarm: true } });
const rejected = await spawnSubagentDirect(
{
task: "invalid schema",
collect: true,
outputSchema: { type: "object", properties: "invalid" },
},
{ agentSessionKey: "agent:main:main", requesterRunId: "parent-run" },
);
expect(rejected.status).toBe("error");
expect(rejected.error).toContain("Invalid sessions_spawn outputSchema");
expect(hoisted.updateSessionStoreMock).not.toHaveBeenCalled();
});
it("rejects schema collection for a model that cannot call tools", async () => {
hoisted.configOverride = createConfigOverride({ tools: { swarm: true } });
hoisted.loadModelCatalogMock.mockResolvedValue([
{
provider: "openai",
id: "no-tools",
name: "No tools",
compat: { supportsTools: false },
},
]);
const rejected = await spawnSubagentDirect(
{
task: "structured result",
model: "openai/no-tools",
collect: true,
outputSchema: { type: "object" },
},
{ agentSessionKey: "agent:main:main", requesterRunId: "parent-run" },
);
expect(rejected.status).toBe("error");
expect(rejected.error).toContain("requires a tool-capable target model");
expect(hoisted.updateSessionStoreMock).not.toHaveBeenCalled();
expect(hoisted.registerSubagentRunMock).not.toHaveBeenCalled();
});
it("rejects a group id outside collector mode", async () => {
hoisted.configOverride = createConfigOverride({ tools: { swarm: true } });
const rejected = await spawnSubagentDirect(
{ task: "ordinary child", groupId: "swarm:custom" },
{ agentSessionKey: "agent:main:main", requesterRunId: "parent-run" },
);
expect(rejected.status).toBe("error");
expect(rejected.error).toContain("groupId requires collect=true");
expect(hoisted.updateSessionStoreMock).not.toHaveBeenCalled();
});
it("registers the target agent id for cross-agent task attribution", async () => {
hoisted.configOverride = createConfigOverride({
session: {
@@ -408,6 +937,50 @@ describe("spawnSubagentDirect seam flow", () => {
expect(persistedStore?.[childSessionKey]?.thinkingLevel).toBe("high");
});
it("inherits requester fast mode for collector children", async () => {
let persistedStore: Record<string, Record<string, unknown>> | undefined;
hoisted.configOverride = createConfigOverride({ tools: { swarm: true } });
hoisted.loadSessionStoreMock.mockReturnValue({
"agent:main:main": { fastMode: "auto" },
});
installSessionStoreCaptureMock(hoisted.updateSessionStoreMock, {
onStore: (store) => {
persistedStore = store;
},
});
const result = await spawnSubagentDirect(
{ task: "inherit fast mode", collect: true },
{ agentSessionKey: "agent:main:main", requesterRunId: "parent-run" },
);
expect(result.status).toBe("accepted");
const childSessionKey = result.childSessionKey as string;
expect(persistedStore?.[childSessionKey]?.fastMode).toBe("auto");
});
it("inherits requester fast mode for ordinary children when Swarm is enabled", async () => {
let persistedStore: Record<string, Record<string, unknown>> | undefined;
hoisted.configOverride = createConfigOverride({ tools: { swarm: true } });
hoisted.loadSessionStoreMock.mockReturnValue({
"agent:main:main": { fastMode: true },
});
installSessionStoreCaptureMock(hoisted.updateSessionStoreMock, {
onStore: (store) => {
persistedStore = store;
},
});
const result = await spawnSubagentDirect(
{ task: "inherit ordinary fast mode" },
{ agentSessionKey: "agent:main:main" },
);
expect(result.status).toBe("accepted");
const childSessionKey = result.childSessionKey as string;
expect(persistedStore?.[childSessionKey]?.fastMode).toBe(true);
});
it("persists inherited requester thinking off", async () => {
let persistedStore: Record<string, Record<string, unknown>> | undefined;
hoisted.loadSessionStoreMock.mockReturnValue({
@@ -855,6 +1428,26 @@ describe("spawnSubagentDirect seam flow", () => {
expect(registerInput.requesterDisplayKey).toBe("agent:main:main");
});
it("persists the spawning session as the stable swarm limit owner", async () => {
hoisted.configOverride = createConfigOverride({ tools: { swarm: true } });
const spawningSessionKey = "agent:main:telegram:default:direct:456";
await spawnSubagentDirect(
{ task: "collect for routed completion", collect: true, groupId: "routed" },
{
agentSessionKey: spawningSessionKey,
completionOwnerKey: "agent:main:main",
requesterRunId: "parent-run",
},
);
expect(firstRegisteredSubagentRun()).toMatchObject({
requesterSessionKey: "agent:main:main",
swarmRequesterSessionKey: spawningSessionKey,
});
expect(hoisted.listSwarmRunsForGroupMock).toHaveBeenCalledWith("routed", spawningSessionKey);
});
it("keeps spawn cwd separate from inherited agent workspace", async () => {
let persistedStore: Record<string, Record<string, unknown>> | undefined;
installSessionStoreCaptureMock(hoisted.updateSessionStoreMock, {
File diff suppressed because it is too large Load Diff
+57
View File
@@ -0,0 +1,57 @@
import { SUBAGENT_ENDED_REASON_KILLED } from "./subagent-lifecycle-events.js";
import type { SubagentRunRecord, SwarmCollectorStatus } from "./subagent-registry.types.js";
import { loadSubagentSessionEntry } from "./subagent-session-reconciliation.js";
import { consumeSwarmStructuredOutput } from "./tools/structured-output-tool.js";
function resolveStatus(entry: SubagentRunRecord): SwarmCollectorStatus {
if (entry.endedReason === SUBAGENT_ENDED_REASON_KILLED) {
return "killed";
}
if (entry.outcome?.status === "timeout") {
return "timeout";
}
return entry.outcome?.status === "ok" ? "done" : "failed";
}
/** Freeze the waitable collector record after raw completion capture. */
export function updateSwarmCollectorCompletion(entry: SubagentRunRecord): boolean {
if (!entry.collect) {
return false;
}
const clearedPendingLaunch = entry.swarmLaunchPending === true;
entry.swarmLaunchPending = false;
if (entry.collectorCompletion) {
return clearedPendingLaunch;
}
const executionCaptured = consumeSwarmStructuredOutput(entry.runId);
const publicCaptured =
entry.swarmRunId && entry.swarmRunId !== entry.runId
? consumeSwarmStructuredOutput(entry.swarmRunId)
: undefined;
const captured = executionCaptured ?? publicCaptured ?? entry.structuredOutput;
entry.structuredOutput = undefined;
const schemaError = entry.outputSchema
? (captured?.schemaError ??
(captured?.structured === undefined ? "structured_output was not called" : undefined))
: undefined;
const session = loadSubagentSessionEntry({ childSessionKey: entry.childSessionKey });
const usage =
typeof session?.inputTokens === "number" || typeof session?.outputTokens === "number"
? {
inputTokens: session.inputTokens ?? 0,
outputTokens: session.outputTokens ?? 0,
}
: undefined;
const resolvedStatus = resolveStatus(entry);
const next = {
status: schemaError && resolvedStatus === "done" ? ("failed" as const) : resolvedStatus,
...(captured?.structured !== undefined ? { structured: captured.structured } : {}),
...(schemaError ? { schemaError } : {}),
...(usage ? { usage } : {}),
};
if (JSON.stringify(entry.collectorCompletion) === JSON.stringify(next)) {
return false;
}
entry.collectorCompletion = next;
return true;
}
+48
View File
@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import { resolveSwarmConfig } from "./swarm-config.js";
describe("resolveSwarmConfig", () => {
it("defaults off with the frozen limits", () => {
expect(resolveSwarmConfig()).toEqual({
enabled: false,
maxConcurrent: 8,
maxChildrenPerGroup: 50,
maxTotalPerGroup: 200,
waitTimeoutSecondsMax: 600,
defaultAgentId: "",
});
});
it("merges per-agent values and clamps every number", () => {
expect(
resolveSwarmConfig(
{
tools: {
swarm: {
enabled: true,
maxConcurrent: 0,
maxChildrenPerGroup: 20_000,
maxTotalPerGroup: 2,
waitTimeoutSecondsMax: 100_000,
defaultAgentId: " reviewer ",
},
},
agents: { list: [{ id: "main", tools: { swarm: { maxConcurrent: 4 } } }] },
},
"main",
),
).toEqual({
enabled: true,
maxConcurrent: 4,
maxChildrenPerGroup: 10_000,
maxTotalPerGroup: 2,
waitTimeoutSecondsMax: 86_400,
defaultAgentId: "reviewer",
});
});
it("normalizes boolean forms", () => {
expect(resolveSwarmConfig({ tools: { swarm: true } }).enabled).toBe(true);
expect(resolveSwarmConfig({ tools: { swarm: false } }).enabled).toBe(false);
});
});
+72
View File
@@ -0,0 +1,72 @@
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { clampNumber } from "../utils.js";
import { resolveAgentConfig } from "./agent-scope-config.js";
type ResolvedSwarmConfig = {
enabled: boolean;
maxConcurrent: number;
maxChildrenPerGroup: number;
maxTotalPerGroup: number;
waitTimeoutSecondsMax: number;
defaultAgentId: string;
};
const DEFAULT_SWARM_CONFIG: ResolvedSwarmConfig = {
enabled: false,
maxConcurrent: 8,
maxChildrenPerGroup: 50,
maxTotalPerGroup: 200,
waitTimeoutSecondsMax: 600,
defaultAgentId: "",
};
function normalizeRawConfig(value: unknown): Record<string, unknown> | undefined {
if (value === true) {
return { enabled: true };
}
if (value === false) {
return { enabled: false };
}
return isRecord(value) ? value : undefined;
}
function readPositiveInteger(value: unknown, fallback: number): number {
return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : fallback;
}
/** Resolve global and per-agent Swarm configuration into bounded runtime values. */
export function resolveSwarmConfig(config?: OpenClawConfig, agentId?: string): ResolvedSwarmConfig {
const globalRaw = normalizeRawConfig(config?.tools?.swarm) ?? {};
const agentRaw =
config && agentId
? normalizeRawConfig(resolveAgentConfig(config, agentId)?.tools?.swarm)
: undefined;
const raw = agentRaw ? { ...globalRaw, ...agentRaw } : globalRaw;
const maxChildrenPerGroup = clampNumber(
readPositiveInteger(raw.maxChildrenPerGroup, DEFAULT_SWARM_CONFIG.maxChildrenPerGroup),
1,
10_000,
);
const maxTotalPerGroup = clampNumber(
readPositiveInteger(raw.maxTotalPerGroup, DEFAULT_SWARM_CONFIG.maxTotalPerGroup),
1,
100_000,
);
return {
enabled: typeof raw.enabled === "boolean" ? raw.enabled : DEFAULT_SWARM_CONFIG.enabled,
maxConcurrent: clampNumber(
readPositiveInteger(raw.maxConcurrent, DEFAULT_SWARM_CONFIG.maxConcurrent),
1,
1_000,
),
maxChildrenPerGroup,
maxTotalPerGroup,
waitTimeoutSecondsMax: clampNumber(
readPositiveInteger(raw.waitTimeoutSecondsMax, DEFAULT_SWARM_CONFIG.waitTimeoutSecondsMax),
1,
24 * 60 * 60,
),
defaultAgentId: typeof raw.defaultAgentId === "string" ? raw.defaultAgentId.trim() : "",
};
}
+18
View File
@@ -0,0 +1,18 @@
import { validateJsonSchemaValue } from "../plugins/schema-validator.js";
import type { JsonSchemaObject } from "../shared/json-schema.types.js";
export function validateStructuredOutputSchema(
schema: Record<string, unknown>,
): string | undefined {
try {
validateJsonSchemaValue({
schema: schema as JsonSchemaObject,
cacheKey: "swarm-output-schema-preflight",
value: {},
cache: false,
});
return undefined;
} catch (error) {
return `Invalid sessions_spawn outputSchema: ${error instanceof Error ? error.message : String(error)}`;
}
}
@@ -0,0 +1,15 @@
import "./swarm-scheduler.js";
type SwarmSchedulerTestApi = {
testing: {
reset(): void;
};
};
function getTestApi(): SwarmSchedulerTestApi {
return (globalThis as Record<PropertyKey, unknown>)[
Symbol.for("openclaw.swarmSchedulerTestApi")
] as SwarmSchedulerTestApi;
}
export const testing = getTestApi().testing;
+297
View File
@@ -0,0 +1,297 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
activateSwarmRun,
enqueueSwarmRun,
releaseSwarmRun,
removeQueuedSwarmRun,
reserveSwarmRun,
} from "./swarm-scheduler.js";
import { testing } from "./swarm-scheduler.test-support.js";
// queueMicrotask-driven starts need real microtask turns; fake timers'
// runAllTicks only drains nextTick, so flush the microtask queue explicitly.
const flushMicrotasks = async () => {
for (let i = 0; i < 8; i += 1) {
await Promise.resolve();
}
};
describe("swarm scheduler", () => {
beforeEach(() => {
testing.reset();
vi.stubEnv("OPENCLAW_TEST_FAST", "1");
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllEnvs();
});
it("starts collector runs FIFO as group slots free", async () => {
const started: string[] = [];
const start = (runId: string) => async () => {
started.push(runId);
};
const onStartFailure = vi.fn(() => true);
expect(
enqueueSwarmRun({
groupId: "group",
runId: "one",
maxConcurrent: 1,
activeRunIds: [],
start: start("one"),
onStartFailure,
}),
).toBe("started");
expect(
enqueueSwarmRun({
groupId: "group",
runId: "two",
maxConcurrent: 1,
activeRunIds: [],
start: start("two"),
onStartFailure,
}),
).toBe("queued");
expect(
enqueueSwarmRun({
groupId: "group",
runId: "three",
maxConcurrent: 1,
activeRunIds: [],
start: start("three"),
onStartFailure,
}),
).toBe("queued");
await vi.waitFor(() => expect(started).toEqual(["one"]));
expect(releaseSwarmRun("one")).toBe(true);
await vi.waitFor(() => expect(started).toEqual(["one", "two"]));
expect(releaseSwarmRun("two")).toBe(true);
await vi.waitFor(() => expect(started).toEqual(["one", "two", "three"]));
expect(onStartFailure).not.toHaveBeenCalled();
});
it("preserves admission order when later preparation finishes first", async () => {
const started: string[] = [];
const reserve = (runId: string) =>
reserveSwarmRun({
groupId: "group",
runId,
maxConcurrent: 1,
activeRunIds: [],
});
const activate = (runId: string) =>
activateSwarmRun({
groupId: "group",
runId,
start: async () => {
started.push(runId);
},
onStartFailure: vi.fn(() => true),
});
expect(reserve("one")).toBe(true);
expect(reserve("two")).toBe(true);
expect(activate("two")).toBe("queued");
await Promise.resolve();
expect(started).toEqual([]);
expect(activate("one")).toBe("started");
await vi.waitFor(() => expect(started).toEqual(["one"]));
expect(releaseSwarmRun("one")).toBe(true);
await vi.waitFor(() => expect(started).toEqual(["one", "two"]));
});
it("removes a cancelled queued run before the next slot opens", async () => {
const started: string[] = [];
const enqueue = (runId: string) =>
enqueueSwarmRun({
groupId: "group",
runId,
maxConcurrent: 1,
activeRunIds: [],
start: async () => {
started.push(runId);
},
onStartFailure: vi.fn(() => true),
});
enqueue("one");
enqueue("two");
enqueue("three");
expect(removeQueuedSwarmRun("two")).toBe(true);
await vi.waitFor(() => expect(started).toEqual(["one"]));
releaseSwarmRun("one");
await vi.waitFor(() => expect(started).toEqual(["one", "three"]));
});
it("retries the same queued run when failure persistence throws", async () => {
const started: string[] = [];
let brokenAttempts = 0;
enqueueSwarmRun({
groupId: "group",
runId: "broken",
maxConcurrent: 1,
activeRunIds: [],
start: async () => {
brokenAttempts += 1;
if (brokenAttempts === 1) {
throw new Error("launch failed");
}
started.push("broken");
},
onStartFailure: () => {
throw new Error("persistence failed");
},
});
enqueueSwarmRun({
groupId: "group",
runId: "next",
maxConcurrent: 1,
activeRunIds: [],
start: async () => {
started.push("next");
},
onStartFailure: vi.fn(() => true),
});
await vi.waitFor(() => expect(started).toEqual(["broken"]));
expect(brokenAttempts).toBe(2);
expect(releaseSwarmRun("broken")).toBe(true);
await vi.waitFor(() => expect(started).toEqual(["broken", "next"]));
});
it("does not let a stale retry release the successful replacement attempt", async () => {
vi.useFakeTimers();
const started: string[] = [];
let brokenAttempts = 0;
enqueueSwarmRun({
groupId: "group",
runId: "broken",
maxConcurrent: 2,
activeRunIds: [],
start: async () => {
brokenAttempts += 1;
if (brokenAttempts === 1) {
throw new Error("launch failed");
}
started.push("broken");
},
onStartFailure: () => {
throw new Error("persistence failed");
},
});
for (const runId of ["holding", "next"]) {
enqueueSwarmRun({
groupId: "group",
runId,
maxConcurrent: 2,
activeRunIds: [],
start: async () => {
started.push(runId);
},
onStartFailure: vi.fn(() => true),
});
}
await flushMicrotasks();
await flushMicrotasks();
expect(brokenAttempts).toBe(1);
expect(started).toEqual(["holding"]);
expect(releaseSwarmRun("holding")).toBe(true);
await flushMicrotasks();
expect(brokenAttempts).toBe(1);
expect(started).toEqual(["holding"]);
await vi.advanceTimersByTimeAsync(1);
expect(brokenAttempts).toBe(2);
expect(started).toEqual(["holding", "broken", "next"]);
expect(releaseSwarmRun("broken")).toBe(true);
});
it("holds the group slot until asynchronous failure cleanup finishes", async () => {
const started: string[] = [];
let finishCleanup: (() => void) | undefined;
const cleanup = new Promise<void>((resolve) => {
finishCleanup = resolve;
});
enqueueSwarmRun({
groupId: "group",
runId: "failed",
maxConcurrent: 1,
activeRunIds: [],
start: async () => {
started.push("failed");
throw new Error("launch failed");
},
onStartFailure: async () => {
await cleanup;
return true;
},
});
enqueueSwarmRun({
groupId: "group",
runId: "next",
maxConcurrent: 1,
activeRunIds: [],
start: async () => {
started.push("next");
},
onStartFailure: vi.fn(() => true),
});
await vi.waitFor(() => expect(started).toEqual(["failed"]));
finishCleanup?.();
await vi.waitFor(() => expect(started).toEqual(["failed", "next"]));
});
it("does not refill until active runs fall below a reduced limit", async () => {
const started: string[] = [];
const enqueue = (runId: string, maxConcurrent: number) =>
enqueueSwarmRun({
groupId: "group",
runId,
maxConcurrent,
activeRunIds: [],
start: async () => {
started.push(runId);
},
onStartFailure: vi.fn(() => true),
});
enqueue("one", 2);
enqueue("two", 2);
enqueue("three", 2);
enqueue("four", 1);
await vi.waitFor(() => expect(started).toEqual(["one", "two"]));
releaseSwarmRun("one");
await Promise.resolve();
expect(started).toEqual(["one", "two"]);
releaseSwarmRun("two");
await vi.waitFor(() => expect(started).toEqual(["one", "two", "three"]));
});
it("fills increased capacity from the existing FIFO queue", async () => {
const started: string[] = [];
const enqueue = (runId: string, maxConcurrent: number) =>
enqueueSwarmRun({
groupId: "group",
runId,
maxConcurrent,
activeRunIds: [],
start: async () => {
started.push(runId);
},
onStartFailure: vi.fn(() => true),
});
enqueue("one", 1);
enqueue("two", 1);
enqueue("three", 3);
await vi.waitFor(() => expect(started).toEqual(["one", "two", "three"]));
});
});
+190
View File
@@ -0,0 +1,190 @@
type QueuedSwarmRun = {
runId: string;
start?: () => Promise<void>;
/** True once failure is durable or the row no longer owns queued work. */
onStartFailure?: (error: unknown) => boolean | Promise<boolean>;
ready: boolean;
retryReady: boolean;
};
type SwarmGroupLane = {
limit: number;
active: Set<string>;
queue: QueuedSwarmRun[];
};
const lanes = new Map<string, SwarmGroupLane>();
function startQueuedRun(lane: SwarmGroupLane, item: QueuedSwarmRun) {
const start = item.start;
const onStartFailure = item.onStartFailure;
if (!start || !onStartFailure) {
return;
}
lane.active.add(item.runId);
queueMicrotask(() => {
void start().catch(async (error: unknown) => {
let failurePersisted = false;
try {
failurePersisted = await onStartFailure(error);
} catch {
// A durable queued row still owns this work; retry after a short backoff.
}
if (failurePersisted) {
releaseSwarmRun(item.runId);
return;
}
lane.active.delete(item.runId);
item.retryReady = false;
lane.queue.unshift(item);
const timer = setTimeout(
() => {
item.retryReady = true;
pumpLane(lane);
},
process.env.OPENCLAW_TEST_FAST === "1" ? 1 : 1_000,
);
timer.unref?.();
});
});
}
function pumpLane(lane: SwarmGroupLane) {
while (lane.active.size < lane.limit) {
const next = lane.queue[0];
if (!next || !next.ready || !next.retryReady) {
return;
}
lane.queue.shift();
startQueuedRun(lane, next);
}
}
function ensureLane(params: {
groupId: string;
maxConcurrent: number;
activeRunIds: readonly string[];
}): SwarmGroupLane {
const lane = lanes.get(params.groupId) ?? {
limit: params.maxConcurrent,
active: new Set<string>(),
queue: [],
};
lanes.set(params.groupId, lane);
lane.limit = params.maxConcurrent;
for (const runId of params.activeRunIds) {
lane.active.add(runId);
}
return lane;
}
/** Reserve FIFO position before asynchronous spawn preparation begins. */
export function reserveSwarmRun(params: {
groupId: string;
runId: string;
maxConcurrent: number;
activeRunIds: readonly string[];
}): boolean {
const lane = ensureLane(params);
if (lane.active.has(params.runId) || lane.queue.some((item) => item.runId === params.runId)) {
return false;
}
lane.queue.push({ runId: params.runId, ready: false, retryReady: true });
return true;
}
/** Attach launch work to an existing FIFO reservation. */
export function activateSwarmRun(params: {
groupId: string;
runId: string;
start: () => Promise<void>;
onStartFailure: (error: unknown) => boolean | Promise<boolean>;
}): "started" | "queued" {
const lane = lanes.get(params.groupId);
const item = lane?.queue.find((candidate) => candidate.runId === params.runId);
if (!lane || !item) {
throw new Error(`swarm scheduler reservation missing for run ${params.runId}`);
}
item.start = params.start;
item.onStartFailure = params.onStartFailure;
item.ready = true;
pumpLane(lane);
return lane.active.has(item.runId) ? "started" : "queued";
}
export function enqueueSwarmRun(params: {
groupId: string;
runId: string;
maxConcurrent: number;
activeRunIds: readonly string[];
start: () => Promise<void>;
onStartFailure: (error: unknown) => boolean | Promise<boolean>;
}): "started" | "queued" {
if (
!reserveSwarmRun({
groupId: params.groupId,
runId: params.runId,
maxConcurrent: params.maxConcurrent,
activeRunIds: params.activeRunIds,
})
) {
throw new Error(`swarm scheduler run already exists: ${params.runId}`);
}
return activateSwarmRun({
groupId: params.groupId,
runId: params.runId,
start: params.start,
onStartFailure: params.onStartFailure,
});
}
export function releaseSwarmRun(runId: string): boolean {
for (const [groupId, lane] of lanes) {
if (!lane.active.delete(runId)) {
continue;
}
pumpLane(lane);
if (lane.active.size === 0 && lane.queue.length === 0) {
lanes.delete(groupId);
}
return true;
}
return false;
}
export function removeQueuedSwarmRun(runId: string): boolean {
for (const [groupId, lane] of lanes) {
const index = lane.queue.findIndex((item) => item.runId === runId);
if (index < 0) {
continue;
}
lane.queue.splice(index, 1);
pumpLane(lane);
if (lane.active.size === 0 && lane.queue.length === 0) {
lanes.delete(groupId);
}
return true;
}
return false;
}
export function isSwarmRunQueued(runId: string): boolean {
for (const lane of lanes.values()) {
if (lane.queue.some((item) => item.runId === runId)) {
return true;
}
}
return false;
}
const testing = {
reset() {
lanes.clear();
},
};
if (process.env.VITEST || process.env.NODE_ENV === "test") {
(globalThis as Record<PropertyKey, unknown>)[Symbol.for("openclaw.swarmSchedulerTestApi")] = {
testing,
};
}
+10 -1
View File
@@ -3,7 +3,7 @@
* Verifies built-in profile allowlists include expected core tool groups.
*/
import { describe, expect, it } from "vitest";
import { resolveCoreToolProfilePolicy } from "./tool-catalog.js";
import { listCoreToolSections, resolveCoreToolProfilePolicy } from "./tool-catalog.js";
function requireCoreToolProfilePolicy(profile: Parameters<typeof resolveCoreToolProfilePolicy>[0]) {
const policy = resolveCoreToolProfilePolicy(profile);
@@ -22,6 +22,14 @@ function requirePolicyAllow(profile: Parameters<typeof resolveCoreToolProfilePol
}
describe("tool-catalog", () => {
it("lists agents_wait only for a Swarm-enabled catalog", () => {
const ids = (config?: Parameters<typeof listCoreToolSections>[0]) =>
listCoreToolSections(config).flatMap((section) => section.tools.map((tool) => tool.id));
expect(ids()).not.toContain("agents_wait");
expect(ids({ swarmEnabled: true })).toContain("agents_wait");
});
it("includes code_execution, web_search, x_search, web_fetch, and update_plan in the coding profile policy", () => {
const policy = requireCoreToolProfilePolicy("coding");
expect(policy.allow).toEqual([
@@ -46,6 +54,7 @@ describe("tool-catalog", () => {
"conversations_turn",
"sessions_send",
"sessions_spawn",
"agents_wait",
"sessions_yield",
"subagents",
"session_status",
+20 -2
View File
@@ -2,8 +2,13 @@
* Core tool catalog and profile defaults.
* Drives built-in profile allowlists, group expansion, and UI section metadata
* for OpenClaw-owned tools.
*
* This module is bundled into the Control UI via tool-policy-shared. Keep it
* pure data + tiny pure functions: a value import of server config/runtime
* modules here drags the whole gateway graph into the ui build and breaks it.
*/
import {
AGENTS_WAIT_TOOL_DISPLAY_SUMMARY,
ASK_USER_TOOL_DISPLAY_SUMMARY,
CRON_TOOL_DISPLAY_SUMMARY,
EXEC_TOOL_DISPLAY_SUMMARY,
@@ -224,6 +229,14 @@ const CORE_TOOL_DEFINITIONS: CoreToolDefinition[] = [
profiles: ["coding", "messaging"],
includeInOpenClawGroup: true,
},
{
id: "agents_wait",
label: "agents_wait",
description: AGENTS_WAIT_TOOL_DISPLAY_SUMMARY,
sectionId: "sessions",
profiles: ["coding"],
includeInOpenClawGroup: true,
},
{
id: "sessions_yield",
label: "sessions_yield",
@@ -530,11 +543,16 @@ export function resolveCoreToolProfilePolicy(profile?: string): ToolProfilePolic
}
/** Lists core tools grouped into UI sections. */
export function listCoreToolSections(): CoreToolSection[] {
export function listCoreToolSections(params?: { swarmEnabled?: boolean }): CoreToolSection[] {
// Callers resolve the swarm gate and pass the fact in; resolving config here
// would couple this ui-shared module to the server graph.
const swarmEnabled = params?.swarmEnabled === true;
return CORE_TOOL_SECTION_ORDER.map((section) => ({
id: section.id,
label: section.label,
tools: CORE_TOOL_DEFINITIONS.filter((tool) => tool.sectionId === section.id).map((tool) => ({
tools: CORE_TOOL_DEFINITIONS.filter(
(tool) => tool.sectionId === section.id && (tool.id !== "agents_wait" || swarmEnabled),
).map((tool) => ({
id: tool.id,
label: tool.label,
description: tool.description,
+1
View File
@@ -9,6 +9,7 @@ export const SESSIONS_SEARCH_TOOL_DISPLAY_SUMMARY = "Search past session transcr
export const SESSIONS_SEND_TOOL_DISPLAY_SUMMARY = "Run same-Gateway session/agent.";
export const SESSIONS_SPAWN_TOOL_DISPLAY_SUMMARY = "Spawn subagent or ACP session.";
export const SESSIONS_SPAWN_SUBAGENT_TOOL_DISPLAY_SUMMARY = "Spawn subagent session.";
export const AGENTS_WAIT_TOOL_DISPLAY_SUMMARY = "Wait for collector subagents.";
export const SESSION_STATUS_TOOL_DISPLAY_SUMMARY = "Show session status/model/usage.";
export const UPDATE_PLAN_TOOL_DISPLAY_SUMMARY = "Track short work plan.";
export const ASK_USER_TOOL_DISPLAY_SUMMARY = "Ask the user and wait for an answer.";
+4 -10
View File
@@ -452,6 +452,8 @@ export const TOOL_DISPLAY_CONFIG: ToolDisplayConfig = {
title: "Sub-agent",
detailKeys: ["label", "task", "agentId", "model", "thinking", "runTimeoutSeconds", "cleanup"],
},
agents_wait: { emoji: "⏳", title: "Wait for Agents", detailKeys: ["ids", "timeoutSeconds"] },
structured_output: { emoji: "🧾", title: "Structured Output", detailKeys: ["result"] },
subagents: {
emoji: "🤖",
title: "Subagents",
@@ -695,15 +697,7 @@ export const TOOL_DISPLAY_CONFIG: ToolDisplayConfig = {
title: "PDF",
detailKeys: ["path", "paths", "url", "urls", "prompt", "pageRange", "model"],
},
sessions_yield: {
emoji: "⏸️",
title: "Yield",
detailKeys: ["message"],
},
tts: {
emoji: "🔊",
title: "TTS",
detailKeys: ["text", "channel"],
},
sessions_yield: { emoji: "⏸️", title: "Yield", detailKeys: ["message"] },
tts: { emoji: "🔊", title: "TTS", detailKeys: ["text", "channel"] },
},
};
@@ -0,0 +1,30 @@
import type { SubagentRunRecord } from "../subagent-registry.types.js";
import "./agents-wait-tool.js";
type WaitError = { runId: string; error: "not_found" | "not_owner" };
type WaitTarget = { runId: string; entry: SubagentRunRecord };
type AgentsWaitToolTestApi = {
testing: {
ownsRun(entry: SubagentRunRecord, currentSessionKeys: ReadonlySet<string>): boolean;
readResolvedWaitState(targets: readonly WaitTarget[], errors: readonly WaitError[]): unknown;
readWaitState(ids: readonly string[], currentSessionKeys: ReadonlySet<string>): unknown;
resolveWaitTargets(
ids: readonly string[],
currentSessionKeys: ReadonlySet<string>,
): { targets: WaitTarget[]; errors: WaitError[] };
waitForCollector(params: {
ids: readonly string[];
currentSessionKeys: ReadonlySet<string>;
timeoutMs: number;
signal?: AbortSignal;
}): Promise<unknown>;
};
};
function getTestApi(): AgentsWaitToolTestApi {
return (globalThis as Record<PropertyKey, unknown>)[
Symbol.for("openclaw.agentsWaitToolTestApi")
] as AgentsWaitToolTestApi;
}
export const testing = getTestApi().testing;
+259
View File
@@ -0,0 +1,259 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { SubagentRunRecord } from "../subagent-registry.types.js";
const records = new Map<string, SubagentRunRecord>();
vi.mock("../subagent-registry.js", () => ({
getSubagentRunsByRunIds: (runIds: readonly string[]) => ({
entries: new Map(
runIds.flatMap((runId) => {
const entry =
records.get(runId) ??
[...records.values()].find((candidate) => candidate.swarmRunId === runId);
return entry ? [[runId, entry] as const] : [];
}),
),
latestByChildSessionKey: new Map(
[...records.values()].map((entry) => [entry.childSessionKey, entry]),
),
}),
}));
import { createAgentsWaitTool } from "./agents-wait-tool.js";
import { testing } from "./agents-wait-tool.test-support.js";
function collectorRun(
runId: string,
requesterSessionKey: string,
completion?: SubagentRunRecord["collectorCompletion"],
): SubagentRunRecord {
return {
runId,
childSessionKey: `agent:worker:subagent:${runId}`,
controllerSessionKey: requesterSessionKey,
requesterSessionKey,
requesterDisplayKey: requesterSessionKey,
task: runId,
cleanup: "keep",
createdAt: Date.now(),
collect: true,
swarmRequesterSessionKey: requesterSessionKey,
groupId: "group",
completion: { required: false, resultText: completion ? `result-${runId}` : undefined },
collectorCompletion: completion,
};
}
describe("agents_wait", () => {
beforeEach(() => records.clear());
it("exposes ownership helpers through test support", () => {
const entry = collectorRun("owned", "agent:main:main");
expect(testing.ownsRun(entry, new Set(["agent:main:main"]))).toBe(true);
});
it("returns the first completed child and leaves siblings pending", async () => {
records.set("one", collectorRun("one", "agent:main:main"));
records.set("two", collectorRun("two", "agent:main:main"));
const tool = createAgentsWaitTool({
agentSessionKey: "agent:main:main",
agentId: "main",
config: { tools: { swarm: true } },
});
setTimeout(() => {
const entry = records.get("two");
if (!entry) {
return;
}
entry.completion = { required: false, resultText: "result-two" };
entry.collectorCompletion = {
status: "done",
structured: { winner: 2 },
};
}, 5);
const result = await tool.execute("call", { ids: ["one", "two"], timeoutSeconds: 1 });
expect(result.details).toEqual({
completed: [
{
runId: "two",
status: "done",
result: "result-two",
structured: { winner: 2 },
sessionKey: "agent:worker:subagent:two",
},
],
pending: ["one"],
});
});
it("orders completions by their durable capture time instead of input order", async () => {
const later = collectorRun("later", "agent:main:main", { status: "done" });
later.completion = { required: false, resultText: "later", capturedAt: 10 };
const earlier = collectorRun("earlier", "agent:main:main", { status: "done" });
earlier.completion = { required: false, resultText: "earlier", capturedAt: 5 };
records.set(later.runId, later);
records.set(earlier.runId, earlier);
const tool = createAgentsWaitTool({
agentSessionKey: "agent:main:main",
agentId: "main",
config: { tools: { swarm: true } },
});
const result = await tool.execute("call", {
ids: ["later", "earlier"],
timeoutSeconds: 0,
});
expect(result.details).toMatchObject({
completed: [{ runId: "earlier" }, { runId: "later" }],
pending: [],
});
});
it("is idempotent and returns per-id ownership and unknown errors", async () => {
const done = collectorRun("done", "agent:worker:subagent:owner", { status: "done" });
done.swarmWaitOwnerSessionKeys = ["agent:worker:subagent:owner", "agent:main:main"];
records.set("done", done);
records.set("owner", collectorRun("owner", "agent:main:main"));
records.set("foreign", collectorRun("foreign", "agent:other:main", { status: "failed" }));
const tool = createAgentsWaitTool({
agentSessionKey: "agent:main:main",
agentId: "main",
config: { tools: { swarm: { enabled: true, waitTimeoutSecondsMax: 1 } } },
});
const first = await tool.execute("call", {
ids: ["done", "foreign", "missing"],
timeoutSeconds: 5,
});
const second = await tool.execute("call", {
ids: ["done", "foreign", "missing"],
timeoutSeconds: 5,
});
expect(second.details).toEqual(first.details);
expect(first.details).toMatchObject({
completed: [{ runId: "done", status: "done" }],
pending: [],
errors: [
{ runId: "foreign", error: "not_owner" },
{ runId: "missing", error: "not_found" },
],
});
});
it("authorizes a snapshotted ancestor after the ordinary spawner row is archived", async () => {
const ownerSessionKey = "agent:worker:subagent:ordinary-owner";
records.set("ordinary-owner", {
runId: "ordinary-owner",
childSessionKey: ownerSessionKey,
controllerSessionKey: "agent:main:main",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "spawn collector",
cleanup: "delete",
createdAt: Date.now(),
});
const completed = collectorRun("nested", ownerSessionKey, { status: "done" });
completed.swarmWaitOwnerSessionKeys = [ownerSessionKey, "agent:main:main"];
records.set(completed.runId, completed);
records.delete("ordinary-owner");
const tool = createAgentsWaitTool({
agentSessionKey: "agent:main:main",
agentId: "main",
config: { tools: { swarm: true } },
});
const result = await tool.execute("call", { ids: ["nested"], timeoutSeconds: 0 });
expect(result.details).toMatchObject({
completed: [{ runId: "nested", status: "done" }],
pending: [],
});
});
it("keeps the public collector id after gateway run replacement", async () => {
const remapped = collectorRun("gateway-run", "agent:main:main", { status: "done" });
remapped.swarmRunId = "collector-run";
records.set(remapped.runId, remapped);
const tool = createAgentsWaitTool({
agentSessionKey: "agent:main:main",
agentId: "main",
config: { tools: { swarm: true } },
});
const result = await tool.execute("call", { ids: ["collector-run"], timeoutSeconds: 0 });
expect(result.details).toMatchObject({
completed: [{ runId: "collector-run", status: "done" }],
pending: [],
});
});
it("re-resolves a collector replaced while waiting", async () => {
const pending = collectorRun("old-gateway-run", "agent:main:main");
pending.swarmRunId = "collector-run";
records.set(pending.runId, pending);
const tool = createAgentsWaitTool({
agentSessionKey: "agent:main:main",
agentId: "main",
config: { tools: { swarm: true } },
});
setTimeout(() => {
records.delete(pending.runId);
const completed = collectorRun("new-gateway-run", "agent:main:main", { status: "done" });
completed.swarmRunId = "collector-run";
records.set(completed.runId, completed);
}, 5);
const result = await tool.execute("call", { ids: ["collector-run"], timeoutSeconds: 1 });
expect(result.details).toMatchObject({
completed: [{ runId: "collector-run", status: "done" }],
pending: [],
});
});
it("does not treat a routed completion owner as the spawning session", async () => {
const routed = collectorRun("routed", "agent:main:main", { status: "done" });
routed.controllerSessionKey = "agent:worker:route-a";
routed.swarmRequesterSessionKey = "agent:worker:route-a";
records.set(routed.runId, routed);
const routedOwner = createAgentsWaitTool({
agentSessionKey: "agent:worker:route-a",
agentId: "worker",
config: { tools: { swarm: true } },
});
const completionOwner = createAgentsWaitTool({
agentSessionKey: "agent:main:main",
agentId: "main",
config: { tools: { swarm: true } },
});
const allowed = await routedOwner.execute("owner", { ids: ["routed"], timeoutSeconds: 0 });
const denied = await completionOwner.execute("proxy", {
ids: ["routed"],
timeoutSeconds: 0,
});
expect(allowed.details).toMatchObject({ completed: [{ runId: "routed" }] });
expect(denied.details).toEqual({
completed: [],
pending: [],
errors: [{ runId: "routed", error: "not_owner" }],
});
});
it("rejects oversized wait batches before polling", async () => {
const tool = createAgentsWaitTool({
agentSessionKey: "agent:main:main",
agentId: "main",
config: { tools: { swarm: true } },
});
await expect(
tool.execute("call", {
ids: Array.from({ length: 1_001 }, (_, index) => `run-${index}`),
}),
).rejects.toThrow("at most 1000 ids");
});
});
+180
View File
@@ -0,0 +1,180 @@
import { Type } from "typebox";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { getSubagentRunsByRunIds } from "../subagent-registry.js";
import type { SubagentRunRecord } from "../subagent-registry.types.js";
import { resolveSwarmConfig } from "../swarm-config.js";
import type { AnyAgentTool } from "./common.js";
import { jsonResult, ToolInputError } from "./common.js";
const MAX_WAIT_IDS = 1_000;
const AgentsWaitToolSchema = Type.Object({
ids: Type.Array(Type.String({ minLength: 1 }), { minItems: 1, maxItems: MAX_WAIT_IDS }),
timeoutSeconds: Type.Optional(Type.Number({ minimum: 0 })),
});
type WaitError = { runId: string; error: "not_found" | "not_owner" };
type WaitTarget = { runId: string; entry: SubagentRunRecord };
function ownsRun(entry: SubagentRunRecord, currentSessionKeys: ReadonlySet<string>): boolean {
const owner = entry.swarmRequesterSessionKey?.trim();
if (!owner) {
return false;
}
const authorizedSessionKeys =
entry.swarmWaitOwnerSessionKeys && entry.swarmWaitOwnerSessionKeys.length > 0
? entry.swarmWaitOwnerSessionKeys
: [owner];
return authorizedSessionKeys.some((sessionKey) => currentSessionKeys.has(sessionKey));
}
function completionResult(entry: SubagentRunRecord) {
const completion = entry.collectorCompletion;
if (!completion) {
return undefined;
}
return {
runId: entry.swarmRunId ?? entry.runId,
status: completion.status,
result: entry.completion?.resultText ?? entry.completion?.fallbackResultText ?? "",
...(completion.structured !== undefined ? { structured: completion.structured } : {}),
...(completion.schemaError ? { schemaError: completion.schemaError } : {}),
sessionKey: entry.childSessionKey,
...(entry.label ? { label: entry.label } : {}),
...(completion.usage ? { usage: completion.usage } : {}),
};
}
function resolveWaitTargets(ids: readonly string[], currentSessionKeys: ReadonlySet<string>) {
const targets: WaitTarget[] = [];
const errors: WaitError[] = [];
const snapshot = getSubagentRunsByRunIds(ids);
for (const runId of ids) {
const entry = snapshot.entries.get(runId);
if (!entry?.collect) {
errors.push({ runId, error: "not_found" });
} else if (!ownsRun(entry, currentSessionKeys)) {
errors.push({ runId, error: "not_owner" });
} else {
targets.push({ runId, entry });
}
}
return { targets, errors };
}
function readResolvedWaitState(targets: readonly WaitTarget[], errors: readonly WaitError[]) {
const completed: Array<{
result: NonNullable<ReturnType<typeof completionResult>>;
completedAt: number;
inputIndex: number;
}> = [];
const pending: string[] = [];
for (const [inputIndex, { runId, entry }] of targets.entries()) {
const result = completionResult(entry);
if (result) {
completed.push({
result,
completedAt: entry.completion?.capturedAt ?? entry.endedAt ?? Number.MAX_SAFE_INTEGER,
inputIndex,
});
} else {
pending.push(runId);
}
}
completed.sort(
(left, right) => left.completedAt - right.completedAt || left.inputIndex - right.inputIndex,
);
return {
completed: completed.map((entry) => entry.result),
pending,
...(errors.length > 0 ? { errors } : {}),
};
}
function readWaitState(ids: readonly string[], currentSessionKeys: ReadonlySet<string>) {
const resolved = resolveWaitTargets(ids, currentSessionKeys);
return readResolvedWaitState(resolved.targets, resolved.errors);
}
async function waitForCollector(params: {
ids: readonly string[];
currentSessionKeys: ReadonlySet<string>;
timeoutMs: number;
signal?: AbortSignal;
}) {
const deadline = Date.now() + params.timeoutMs;
for (;;) {
// Recovery can replace a registry row while preserving its stable swarm id.
// Re-resolve ownership and completion on every poll instead of retaining old objects.
const state = readWaitState(params.ids, params.currentSessionKeys);
if (state.completed.length > 0 || state.pending.length === 0 || Date.now() >= deadline) {
return state;
}
await new Promise<void>((resolve) => {
const finish = () => {
clearTimeout(timer);
params.signal?.removeEventListener("abort", finish);
resolve();
};
const timer = setTimeout(finish, Math.min(25, Math.max(0, deadline - Date.now())));
params.signal?.addEventListener("abort", finish, { once: true });
});
if (params.signal?.aborted) {
return readWaitState(params.ids, params.currentSessionKeys);
}
}
}
export function createAgentsWaitTool(opts: {
agentSessionKey?: string;
runSessionKey?: string;
agentId?: string;
config?: OpenClawConfig;
}): AnyAgentTool {
const swarm = resolveSwarmConfig(opts.config, opts.agentId);
return {
label: "Wait for Agents",
name: "agents_wait",
displaySummary: "Wait for collector children.",
description: "Wait until one collector child completes, or until timeout.",
parameters: AgentsWaitToolSchema,
execute: async (_toolCallId, args, signal) => {
const params = args as { ids: string[]; timeoutSeconds?: number };
if (params.ids.length > MAX_WAIT_IDS) {
throw new ToolInputError(`agents_wait supports at most ${MAX_WAIT_IDS} ids.`);
}
const ids = [...new Set(params.ids.map((id) => id.trim()).filter(Boolean))];
const currentSessionKeys = new Set(
[opts.runSessionKey, opts.agentSessionKey].filter((key): key is string =>
Boolean(key?.trim()),
),
);
const requestedTimeout =
typeof params.timeoutSeconds === "number" && Number.isFinite(params.timeoutSeconds)
? params.timeoutSeconds
: 30;
const timeoutSeconds = Math.min(Math.max(0, requestedTimeout), swarm.waitTimeoutSecondsMax);
const result = await waitForCollector({
ids,
currentSessionKeys,
timeoutMs: timeoutSeconds * 1_000,
signal,
});
return jsonResult(result);
},
};
}
const testing = {
ownsRun,
readResolvedWaitState,
readWaitState,
resolveWaitTargets,
waitForCollector,
};
if (process.env.VITEST || process.env.NODE_ENV === "test") {
(globalThis as Record<PropertyKey, unknown>)[Symbol.for("openclaw.agentsWaitToolTestApi")] = {
testing,
};
}
@@ -261,6 +261,99 @@ describe("sessions_spawn tool", () => {
expect(schema.properties?.timeoutSeconds).toBeUndefined();
});
it("hides and rejects swarm parameters while tools.swarm is disabled", async () => {
const tool = createSessionsSpawnTool({ agentSessionKey: "agent:main:main" });
const schema = tool.parameters as { properties?: Record<string, unknown> };
expect(schema.properties?.collect).toBeUndefined();
expect(schema.properties?.outputSchema).toBeUndefined();
expect(schema.properties?.fastMode).toBeUndefined();
expect(schema.properties?.groupId).toBeUndefined();
await expect(tool.execute("disabled", { task: "collect", collect: true })).rejects.toThrow(
"tools.swarm.enabled=true",
);
expect(hoisted.spawnSubagentDirectMock).not.toHaveBeenCalled();
});
it("requires collector children to delegate only through collect mode", async () => {
const tool = createSessionsSpawnTool({
agentSessionKey: "agent:worker:subagent:collector",
swarmCollector: true,
config: { tools: { swarm: true } },
});
await expect(tool.execute("normal-child", { task: "ask for approval" })).rejects.toThrow(
"requires collect=true",
);
await tool.execute("collector-child", { task: "collect safely", collect: true });
expect(hoisted.spawnSubagentDirectMock).toHaveBeenCalledOnce();
expect(hoisted.spawnSubagentDirectMock).toHaveBeenCalledWith(
expect.objectContaining({ collect: true }),
expect.any(Object),
);
});
it("forwards collector parameters and requesting run identity when enabled", async () => {
const tool = createSessionsSpawnTool({
agentSessionKey: "agent:main:main",
requesterRunId: "parent-run",
config: { tools: { swarm: true } },
});
const schema = tool.parameters as { properties?: Record<string, unknown> };
expect(schema.properties?.collect).toBeDefined();
expect(schema.properties?.outputSchema).toBeDefined();
expect(schema.properties?.fastMode).toBeDefined();
expect(schema.properties?.groupId).toBeDefined();
await tool.execute("collector", {
task: "collect",
collect: true,
outputSchema: { type: "object", required: ["answer"] },
fastMode: "auto",
groupId: "swarm:custom",
});
const spawnArgs = mockCallArg(hoisted.spawnSubagentDirectMock, 0, 0, "spawnSubagentDirect");
expect(spawnArgs).toMatchObject({
collect: true,
outputSchema: { type: "object", required: ["answer"] },
fastMode: "auto",
groupId: "swarm:custom",
expectsCompletionMessage: false,
});
const spawnContext = mockCallArg(hoisted.spawnSubagentDirectMock, 0, 1, "spawnSubagentDirect");
expect(spawnContext.requesterRunId).toBe("parent-run");
});
it("requires collect=true for outputSchema", async () => {
const tool = createSessionsSpawnTool({
agentSessionKey: "agent:main:main",
config: { tools: { swarm: true } },
});
await expect(
tool.execute("schema-without-collect", {
task: "collect",
outputSchema: { type: "object" },
}),
).rejects.toThrow("requires collect=true");
expect(hoisted.spawnSubagentDirectMock).not.toHaveBeenCalled();
});
it("requires collect=true for groupId", async () => {
const tool = createSessionsSpawnTool({
agentSessionKey: "agent:main:main",
config: { tools: { swarm: true } },
});
await expect(
tool.execute("group-without-collect", {
task: "ordinary child",
groupId: "swarm:custom",
}),
).rejects.toThrow("requires collect=true");
expect(hoisted.spawnSubagentDirectMock).not.toHaveBeenCalled();
});
it("advertises visible sessions with terse UI guidance", () => {
const tool = createSessionsSpawnTool();
const schema = tool.parameters as {
+65 -2
View File
@@ -12,6 +12,7 @@ import {
import { getRuntimeConfig } from "../../config/config.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { resolveSnakeCaseParamKey } from "../../param-key.js";
import { parseAgentSessionKey } from "../../routing/session-key.js";
import { createLazyImportLoader } from "../../shared/lazy-promise.js";
import type { GatewayMessageChannel } from "../../utils/message-channel.js";
import {
@@ -29,6 +30,7 @@ import {
spawnSubagentDirect,
} from "../subagent-spawn.js";
import { normalizeSubagentTaskName } from "../subagent-task-name.js";
import { resolveSwarmConfig } from "../swarm-config.js";
import {
describeSessionsSpawnTool,
SESSIONS_SPAWN_SUBAGENT_TOOL_DISPLAY_SUMMARY,
@@ -123,6 +125,7 @@ function resolveSessionsSpawnThreadAvailability(opts?: {
function createSessionsSpawnToolSchema(params: {
acpAvailable: boolean;
threadAvailable: boolean;
swarmEnabled: boolean;
}) {
const spawnModes = params.threadAvailable ? SUBAGENT_SPAWN_MODES : (["run"] as const);
const schema = {
@@ -161,6 +164,14 @@ function createSessionsSpawnToolSchema(params: {
description: "Light bootstrap; subagent only.",
}),
),
...(params.swarmEnabled
? {
collect: Type.Optional(Type.Boolean()),
outputSchema: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
fastMode: Type.Optional(Type.Union([Type.Boolean(), Type.Literal("auto")])),
groupId: Type.Optional(Type.String()),
}
: {}),
...VISIBLE_SESSIONS_SPAWN_SCHEMA,
// Inline attachments (snapshot-by-value).
@@ -226,6 +237,8 @@ export function createSessionsSpawnTool(
config?: OpenClawConfig;
/** Explicit agent ID override for cron/hook sessions where session key parsing may not work. */
requesterAgentIdOverride?: string;
requesterRunId?: string;
swarmCollector?: boolean;
} & VisibleSessionsSpawnDeps &
SpawnedToolContext,
): AnyAgentTool {
@@ -235,6 +248,9 @@ export function createSessionsSpawnTool(
});
const threadAvailability = resolveSessionsSpawnThreadAvailability(opts);
const threadAvailable = hasAnyThreadAvailability(threadAvailability);
const requesterAgentId =
opts?.requesterAgentIdOverride ?? parseAgentSessionKey(opts?.agentSessionKey)?.agentId;
const swarmConfig = resolveSwarmConfig(opts?.config, requesterAgentId);
return {
label: "Sessions",
name: "sessions_spawn",
@@ -242,9 +258,42 @@ export function createSessionsSpawnTool(
? SESSIONS_SPAWN_TOOL_DISPLAY_SUMMARY
: SESSIONS_SPAWN_SUBAGENT_TOOL_DISPLAY_SUMMARY,
description: describeSessionsSpawnTool({ acpAvailable, threadAvailable }),
parameters: createSessionsSpawnToolSchema({ acpAvailable, threadAvailable }),
parameters: createSessionsSpawnToolSchema({
acpAvailable,
threadAvailable,
swarmEnabled: swarmConfig.enabled,
}),
execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>;
if (opts?.swarmCollector && params.collect !== true) {
throw new ToolInputError(
"sessions_spawn from a collector requires collect=true so approvals stay non-interactive.",
);
}
const swarmParam = ["collect", "outputSchema", "fastMode", "groupId"].find((key) =>
Object.hasOwn(params, key),
);
if (swarmParam && !swarmConfig.enabled) {
throw new ToolInputError(
`sessions_spawn parameter "${swarmParam}" requires tools.swarm.enabled=true.`,
);
}
const hasCollectParam = Object.hasOwn(params, "collect");
const collect = params.collect === true;
if (params.outputSchema !== undefined && !collect) {
throw new ToolInputError('sessions_spawn "outputSchema" requires collect=true.');
}
if (params.groupId !== undefined && !collect) {
throw new ToolInputError('sessions_spawn "groupId" requires collect=true.');
}
if (
collect &&
(params.thread === true || params.visible === true || params.mode === "session")
) {
throw new ToolInputError(
"sessions_spawn collect=true does not support thread, visible, or session mode.",
);
}
const unsupportedParam = UNSUPPORTED_SESSIONS_SPAWN_PARAM_KEYS.find((key) =>
Object.hasOwn(params, key),
);
@@ -274,6 +323,9 @@ export function createSessionsSpawnTool(
const taskName = taskNameResult.taskName;
const label = readStringParam(params, "label") ?? "";
const runtime = params.runtime === "acp" ? "acp" : "subagent";
if (collect && runtime === "acp") {
throw new ToolInputError('sessions_spawn collect=true supports runtime="subagent" only.');
}
const requestedAgentId = readStringParam(params, "agentId");
const resumeSessionId = readStringParam(params, "resumeSessionId");
const modelOverride = normalizeToolModelOverride(readStringParam(params, "model"));
@@ -282,7 +334,7 @@ export function createSessionsSpawnTool(
const mode = params.mode === "run" || params.mode === "session" ? params.mode : undefined;
const cleanup =
params.cleanup === "keep" || params.cleanup === "delete" ? params.cleanup : "keep";
const expectsCompletionMessage = params.expectsCompletionMessage !== false;
const expectsCompletionMessage = collect ? false : params.expectsCompletionMessage !== false;
const sandbox = params.sandbox === "require" ? "require" : "inherit";
const context =
params.context === "fork" || params.context === "isolated" ? params.context : undefined;
@@ -411,6 +463,16 @@ export function createSessionsSpawnTool(
agentId: requestedAgentId,
model: modelOverride,
thinking: thinkingOverrideRaw,
collect: hasCollectParam ? collect : undefined,
outputSchema:
params.outputSchema && typeof params.outputSchema === "object"
? (params.outputSchema as Record<string, unknown>)
: undefined,
fastMode:
params.fastMode === true || params.fastMode === false || params.fastMode === "auto"
? params.fastMode
: undefined,
groupId: readStringParam(params, "groupId"),
cwd,
thread,
mode,
@@ -444,6 +506,7 @@ export function createSessionsSpawnTool(
workspaceDir: opts?.workspaceDir,
inheritedToolAllowlist: opts?.inheritedToolAllowlist,
inheritedToolDenylist: opts?.inheritedToolDenylist,
requesterRunId: opts?.requesterRunId,
},
);
@@ -0,0 +1,17 @@
import type { SwarmStructuredOutputState } from "../subagent-registry.types.js";
import "./structured-output-tool.js";
type StructuredOutputToolTestApi = {
testing: {
readSwarmStructuredOutput(runId: string): SwarmStructuredOutputState | undefined;
reset(): void;
};
};
function getTestApi(): StructuredOutputToolTestApi {
return (globalThis as Record<PropertyKey, unknown>)[
Symbol.for("openclaw.structuredOutputToolTestApi")
] as StructuredOutputToolTestApi;
}
export const testing = getTestApi().testing;
@@ -0,0 +1,88 @@
import { Value } from "typebox/value";
import { beforeEach, describe, expect, it } from "vitest";
import { validateStructuredOutputSchema } from "../swarm-output-schema.js";
import { createStructuredOutputTool } from "./structured-output-tool.js";
import { testing } from "./structured-output-tool.test-support.js";
describe("structured_output", () => {
beforeEach(() => testing.reset());
it("records a valid structured result", async () => {
const tool = createStructuredOutputTool({
runId: "run-1",
schema: {
type: "object",
properties: { answer: { type: "string" } },
required: ["answer"],
additionalProperties: false,
},
});
await expect(tool.execute("call-1", { result: { answer: "yes" } })).resolves.toBeDefined();
expect(testing.readSwarmStructuredOutput("run-1")?.structured).toEqual({ answer: "yes" });
});
it("nudges once then freezes schemaError", async () => {
const tool = createStructuredOutputTool({
runId: "run-2",
schema: {
type: "object",
properties: { count: { type: "number" } },
required: ["count"],
},
});
expect(Value.Check(tool.parameters, { result: { count: "bad" } })).toBe(true);
expect(tool.description).toContain('"count"');
await expect(tool.execute("call-1", { result: { count: "bad" } })).rejects.toThrow(
"Retry once",
);
await expect(tool.execute("call-2", { result: { count: "still bad" } })).resolves.toBeDefined();
await expect(tool.execute("call-3", { result: { count: 3 } })).resolves.toBeDefined();
expect(testing.readSwarmStructuredOutput("run-2")).toMatchObject({
structured: undefined,
invalidAttempts: 2,
});
expect(testing.readSwarmStructuredOutput("run-2")?.schemaError).toBeTruthy();
});
it("accepts general JSON Schemas and rejects malformed schemas before spawn", async () => {
const arraySchema = { type: "array", items: { type: "string" } };
expect(validateStructuredOutputSchema({})).toBeUndefined();
expect(validateStructuredOutputSchema(arraySchema)).toBeUndefined();
expect(validateStructuredOutputSchema({ type: "object", properties: "invalid" })).toContain(
"Invalid sessions_spawn outputSchema",
);
const tool = createStructuredOutputTool({ runId: "run-array", schema: arraySchema });
await expect(tool.execute("call-array", { result: ["one", "two"] })).resolves.toBeDefined();
expect(testing.readSwarmStructuredOutput("run-array")?.structured).toEqual(["one", "two"]);
});
it("resumes the one-retry budget from durable state", async () => {
let durableState: ReturnType<typeof testing.readSwarmStructuredOutput>;
const schema = {
type: "object",
properties: { count: { type: "number" } },
required: ["count"],
};
const first = createStructuredOutputTool({
runId: "run-restart",
schema,
onStateChange: (state) => {
durableState = state;
},
});
await expect(first.execute("call-1", { result: { count: "bad" } })).rejects.toThrow(
"Retry once",
);
testing.reset();
const restored = createStructuredOutputTool({
runId: "run-restart",
schema,
initialState: durableState,
});
await expect(
restored.execute("call-2", { result: { count: "still bad" } }),
).resolves.toBeDefined();
expect(testing.readSwarmStructuredOutput("run-restart")?.invalidAttempts).toBe(2);
});
});
+111
View File
@@ -0,0 +1,111 @@
import { Type } from "typebox";
import { validateJsonSchemaValue } from "../../plugins/schema-validator.js";
import type { JsonSchemaObject } from "../../shared/json-schema.types.js";
import type { SwarmStructuredOutputState } from "../subagent-registry.types.js";
import type { AnyAgentTool } from "./common.js";
import { jsonResult, ToolInputError } from "./common.js";
const states = new Map<string, SwarmStructuredOutputState>();
function formatSchemaError(errors: Array<{ text: string }>): string {
return errors
.slice(0, 3)
.map((error) => error.text)
.join("; ");
}
function readSwarmStructuredOutput(runId: string): SwarmStructuredOutputState | undefined {
const state = states.get(runId);
return state ? structuredClone(state) : undefined;
}
export function consumeSwarmStructuredOutput(
runId: string,
): SwarmStructuredOutputState | undefined {
const state = readSwarmStructuredOutput(runId);
states.delete(runId);
return state;
}
export function createStructuredOutputTool(params: {
runId: string;
schema: Record<string, unknown>;
initialState?: SwarmStructuredOutputState;
onStateChange?: (state: SwarmStructuredOutputState) => void;
}): AnyAgentTool {
const requestedSchema = JSON.stringify(params.schema);
if (params.initialState && !states.has(params.runId)) {
states.set(params.runId, structuredClone(params.initialState));
}
const commitState = (next: SwarmStructuredOutputState) => {
const previous = states.get(params.runId);
states.set(params.runId, next);
try {
params.onStateChange?.(structuredClone(next));
} catch (error) {
if (previous) {
states.set(params.runId, previous);
} else {
states.delete(params.runId);
}
throw new ToolInputError(
`Failed to persist structured_output: ${error instanceof Error ? error.message : String(error)}`,
);
}
};
return {
label: "Structured Output",
name: "structured_output",
displaySummary: "Record the collector result.",
description: `Call exactly once as {"result": ...}, where result matches this JSON Schema: ${requestedSchema}`,
// Runtime argument validation must reach execute so invalid attempts consume
// the durable one-retry budget. The requested schema remains model-visible above.
parameters: Type.Object({ result: Type.Unknown() }, { additionalProperties: false }),
execute: async (_toolCallId, args) => {
const prior = states.get(params.runId);
if (prior?.structured !== undefined) {
throw new ToolInputError("structured_output already recorded for this run");
}
if (prior && prior.invalidAttempts >= 2) {
return jsonResult({ status: "rejected", schemaError: prior.schemaError });
}
let validation: ReturnType<typeof validateJsonSchemaValue>;
try {
validation = validateJsonSchemaValue({
schema: params.schema as JsonSchemaObject,
cacheKey: `swarm-structured-output:${params.runId}`,
value: (args as { result: unknown }).result,
});
} catch (error) {
throw new ToolInputError(
`Invalid sessions_spawn outputSchema: ${error instanceof Error ? error.message : String(error)}`,
);
}
if (validation.ok) {
commitState({ structured: validation.value, invalidAttempts: 0 });
return jsonResult({ status: "recorded" });
}
const invalidAttempts = (prior?.invalidAttempts ?? 0) + 1;
const schemaError = formatSchemaError(validation.errors);
commitState({ structured: undefined, invalidAttempts, schemaError });
if (invalidAttempts === 1) {
throw new ToolInputError(
`structured_output validation failed: ${schemaError}. Retry once with a corrected final result.`,
);
}
return jsonResult({ status: "rejected", schemaError });
},
};
}
const testing = {
readSwarmStructuredOutput,
reset() {
states.clear();
},
};
if (process.env.VITEST || process.env.NODE_ENV === "test") {
(globalThis as Record<PropertyKey, unknown>)[Symbol.for("openclaw.structuredOutputToolTestApi")] =
{ testing };
}
@@ -0,0 +1,201 @@
import os from "node:os";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { withTempDir } from "../../test-helpers/temp-dir.js";
import { createOpenClawTools } from "../openclaw-tools.js";
import {
resetSubagentRegistryForTests,
testing as registryTesting,
} from "../subagent-registry.test-helpers.js";
import "../subagent-registry.mocks.shared.js";
import { testing as spawnTesting } from "../subagent-spawn.test-support.js";
import { testing as swarmSchedulerTesting } from "../swarm-scheduler.test-support.js";
import { createAgentsWaitTool } from "./agents-wait-tool.js";
import { createSessionsSpawnTool } from "./sessions-spawn-tool.js";
import { testing as structuredOutputTesting } from "./structured-output-tool.test-support.js";
const requesterSessionKey = "agent:main:main";
const config: OpenClawConfig = {
session: { mainKey: "main", scope: "per-sender" },
tools: { swarm: true },
agents: {
defaults: {
workspace: os.tmpdir(),
model: { primary: "openai/gpt-5.4" },
subagents: { archiveAfterMinutes: 0 },
},
},
};
function requestParams(value: unknown): Record<string, unknown> {
if (!value || typeof value !== "object") {
return {};
}
const params = (value as { params?: unknown }).params;
return params && typeof params === "object" ? (params as Record<string, unknown>) : {};
}
describe("swarm tools integration", () => {
const completionResolvers = new Map<string, () => void>();
beforeEach(() => {
completionResolvers.clear();
resetSubagentRegistryForTests({ persist: false });
structuredOutputTesting.reset();
swarmSchedulerTesting.reset();
});
afterEach(() => {
spawnTesting.setDepsForTest();
registryTesting.setDepsForTest();
resetSubagentRegistryForTests({ persist: false });
structuredOutputTesting.reset();
swarmSchedulerTesting.reset();
vi.unstubAllEnvs();
});
it("spawns three mock-model collectors and drains them in first-completion order", async () => {
await withTempDir({ prefix: "openclaw-swarm-tools-" }, async (stateDir) => {
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
const publicToGateway = new Map<string, string>();
const resultTextBySession = new Map<string, string>();
const modelStructuredCalls: number[] = [];
let launchCount = 0;
const launchGateway = vi.fn(async (request: unknown) => {
const method =
request && typeof request === "object"
? (request as { method?: unknown }).method
: undefined;
if (method !== "agent") {
return {};
}
const params = requestParams(request);
const publicRunId = String(params.idempotencyKey);
const childSessionKey = String(params.sessionKey);
const outputSchema = params.swarmOutputSchema as Record<string, unknown>;
const index = ++launchCount;
const gatewayRunId = `gateway-${index}`;
const structuredOutput = createOpenClawTools({
agentSessionKey: childSessionKey,
runId: publicRunId,
config,
disableMessageTool: true,
disablePluginTools: true,
wrapBeforeToolCallHook: false,
swarmCollector: true,
swarmOutputSchema: outputSchema,
}).find((tool) => tool.name === "structured_output");
expect(structuredOutput).toBeDefined();
await structuredOutput?.execute("mock-model-output", { result: { index } });
modelStructuredCalls.push(index);
publicToGateway.set(publicRunId, gatewayRunId);
resultTextBySession.set(childSessionKey, `result-${index}`);
return { runId: gatewayRunId, status: "accepted", acceptedAt: Date.now() };
});
spawnTesting.setDepsForTest({
callGateway: launchGateway as never,
getGlobalHookRunner: () => null,
getRuntimeConfig: () => config,
hasInProcessGatewayContext: () => false,
ensureContextEnginesInitialized: vi.fn(),
loadModelCatalog: vi.fn(async () => []),
resolveContextEngine: vi.fn(async () => ({
info: { id: "test", name: "Test", version: "0.0.1" },
ingest: vi.fn(async () => ({ ingested: false })),
assemble: vi.fn(async ({ messages }: { messages: unknown[] }) => ({
messages,
estimatedTokens: 0,
})),
compact: vi.fn(async () => ({ ok: false, compacted: false })),
})) as never,
});
registryTesting.setDepsForTest({
callGateway: vi.fn(async (request: unknown) => {
const runId = String(requestParams(request).runId);
await new Promise<void>((resolve) => {
completionResolvers.set(runId, resolve);
});
return { status: "ok", startedAt: 1, endedAt: Date.now() };
}) as never,
captureSubagentCompletionReply: vi.fn(async (sessionKey: string) => {
return resultTextBySession.get(sessionKey) ?? "";
}) as never,
cleanupBrowserSessionsForLifecycleEnd: vi.fn(async () => undefined),
getGatewayRecoveryRuntime: () => undefined,
getRuntimeConfig: () => config,
maybeWakeRequesterAfterAllChildrenSettled: vi.fn(async () => false),
onAgentEvent: vi.fn(() => () => undefined) as never,
persistSubagentRunsToDisk: vi.fn(),
persistSubagentRunsToDiskOrThrow: vi.fn(),
resolveAgentTimeoutMs: () => 1_000,
restoreSubagentRunsFromDisk: vi.fn(() => 0),
runSubagentAnnounceFlow: vi.fn(async () => true),
ensureContextEnginesInitialized: vi.fn(),
ensureRuntimePluginsLoaded: vi.fn(),
resolveContextEngine: vi.fn(async () => ({
info: { id: "test", name: "Test", version: "0.0.1" },
ingest: vi.fn(async () => ({ ingested: false })),
assemble: vi.fn(async ({ messages }: { messages: unknown[] }) => ({
messages,
estimatedTokens: 0,
})),
compact: vi.fn(async () => ({ ok: false, compacted: false })),
})) as never,
});
const spawn = createSessionsSpawnTool({
agentSessionKey: requesterSessionKey,
requesterRunId: "parent-run",
config,
});
const runIds: string[] = [];
for (const index of [1, 2, 3]) {
const result = await spawn.execute(`spawn-${index}`, {
task: `collector-${index}`,
collect: true,
outputSchema: {
type: "object",
properties: { index: { type: "number" } },
required: ["index"],
},
});
const details = result.details as { status: string; runId?: string };
expect(details.status).toBe("accepted");
expect(details.runId).toBeTruthy();
runIds.push(details.runId ?? "");
}
await vi.waitFor(() => expect(completionResolvers.size).toBe(3));
expect(modelStructuredCalls).toEqual([1, 2, 3]);
const wait = createAgentsWaitTool({
agentSessionKey: requesterSessionKey,
agentId: "main",
config,
});
const pending = new Set(runIds);
const completionOrder: string[] = [];
for (const publicRunId of [runIds[1] ?? "", runIds[2] ?? "", runIds[0] ?? ""]) {
const gatewayRunId = publicToGateway.get(publicRunId);
expect(gatewayRunId).toBeTruthy();
completionResolvers.get(gatewayRunId ?? "")?.();
const result = await wait.execute("wait", {
ids: [...pending],
timeoutSeconds: 1,
});
const details = result.details as {
completed: Array<{ runId: string; structured?: unknown }>;
};
for (const completed of details.completed) {
completionOrder.push(completed.runId);
pending.delete(completed.runId);
}
}
expect(completionOrder).toEqual([runIds[1], runIds[2], runIds[0]]);
expect(pending.size).toBe(0);
for (const runId of runIds) {
expect(structuredOutputTesting.readSwarmStructuredOutput(runId)).toBeUndefined();
}
});
});
});
+11
View File
@@ -163,6 +163,15 @@ export const RUNTIME_FIELD_HELP: Record<string, string> = {
"Default number of hidden catalog search results returned by `tools.search` inside code mode.",
"tools.codeMode.maxSearchLimit":
"Maximum number of hidden catalog search results a code-mode program can request.",
"tools.swarm":
"Collector-mode subagent orchestration. Default is off; enable it to expose agents_wait and swarm spawn options.",
"tools.swarm.enabled": "Enables collector-mode subagents and agents_wait. Default is off.",
"tools.swarm.maxConcurrent": "Maximum concurrently running collector children per swarm group.",
"tools.swarm.maxChildrenPerGroup": "Maximum live collector children per swarm group.",
"tools.swarm.maxTotalPerGroup": "Maximum lifetime collector spawns per swarm group.",
"tools.swarm.waitTimeoutSecondsMax": "Maximum timeout accepted by agents_wait, in seconds.",
"tools.swarm.defaultAgentId":
"Default target agent for swarm spawns that omit agentId. The subagent allowlist still applies.",
"tools.elevated":
"Elevated tool access controls for privileged command surfaces that should only be reachable from trusted senders. Keep disabled unless operator workflows explicitly require elevated actions.",
"tools.elevated.enabled":
@@ -481,6 +490,8 @@ export const RUNTIME_FIELD_HELP: Record<string, string> = {
"Per-agent additive allowlist for tools on top of global and profile policy. Keep narrow to avoid accidental privilege expansion on specialized agents.",
"agents.list[].tools.codeMode":
"Per-agent code mode override. Use this to test or roll out exec/wait tool-surface mode for one agent without enabling it fleet-wide.",
"agents.list[].tools.swarm":
"Per-agent swarm override. Values merge over the top-level tools.swarm configuration.",
"agents.list[].tools.byProvider":
"Per-agent provider-specific tool policy overrides for channel-scoped capability control. Use this when a single agent needs tighter restrictions on one provider than others.",
"agents.list[].tools.message.crossContext.allowWithinProvider":
+8
View File
@@ -257,6 +257,7 @@ export const FIELD_LABELS: Record<string, string> = {
"agents.list[].tools.profile": "Agent Tool Profile",
"agents.list[].tools.alsoAllow": "Agent Tool Allowlist Additions",
"agents.list[].tools.codeMode": "Agent Code Mode",
"agents.list[].tools.swarm": "Agent Swarm",
"tools.byProvider": "Tool Policy by Provider",
"agents.list[].tools.byProvider": "Agent Tool Policy by Provider",
"agents.list[].tools.message.crossContext.allowWithinProvider":
@@ -314,6 +315,13 @@ export const FIELD_LABELS: Record<string, string> = {
"tools.codeMode.snapshotTtlSeconds": "Code Mode Snapshot TTL",
"tools.codeMode.searchDefaultLimit": "Code Mode Default Search Results",
"tools.codeMode.maxSearchLimit": "Code Mode Max Search Results",
"tools.swarm": "Swarm",
"tools.swarm.enabled": "Enable Swarm",
"tools.swarm.maxConcurrent": "Swarm Concurrent Children",
"tools.swarm.maxChildrenPerGroup": "Swarm Live Children per Group",
"tools.swarm.maxTotalPerGroup": "Swarm Total Children per Group",
"tools.swarm.waitTimeoutSecondsMax": "Swarm Maximum Wait Timeout",
"tools.swarm.defaultAgentId": "Swarm Default Agent",
"tools.elevated": "Elevated Tool Access",
"tools.elevated.enabled": "Enable Elevated Tool Access",
"tools.elevated.allowFrom": "Elevated Tool Allow Rules",
+24
View File
@@ -914,6 +914,30 @@ describe("config schema", () => {
).toBe(false);
});
it("accepts strict Swarm config in the runtime zod schema", () => {
expect(ToolsSchema.parse({ swarm: true })?.swarm).toBe(true);
expect(
ToolsSchema.parse({
swarm: {
enabled: true,
maxConcurrent: 8,
maxChildrenPerGroup: 50,
maxTotalPerGroup: 200,
waitTimeoutSecondsMax: 600,
defaultAgentId: "reviewer",
},
})?.swarm,
).toEqual({
enabled: true,
maxConcurrent: 8,
maxChildrenPerGroup: 50,
maxTotalPerGroup: 200,
waitTimeoutSecondsMax: 600,
defaultAgentId: "reviewer",
});
expect(ToolsSchema.safeParse({ swarm: { unknownKey: true } }).success).toBe(false);
});
it("accepts web fetch maxResponseBytes in the runtime zod schema", () => {
const parsed = ToolsSchema.parse({
web: {
+6
View File
@@ -362,6 +362,12 @@ export type SessionEntry = SessionRestartRecoveryState &
};
};
fastMode?: FastMode;
/** Swarm group for collector-mode child sessions. */
swarmGroupId?: string;
/** Marks non-interactive collector-mode child sessions. */
swarmCollector?: boolean;
/** JSON Schema exposed through the synthetic structured_output tool. */
swarmOutputSchema?: Record<string, unknown>;
verboseLevel?: string;
traceLevel?: string;
reasoningLevel?: string;

Some files were not shown because too many files have changed in this diff Show More