mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
7055ed578d
commit
ccb147518c
@@ -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 d’ouvrir 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 d’exé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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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: {} } },
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>) =>
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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 } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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, {
|
||||
|
||||
+977
-508
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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() : "",
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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"]));
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.";
|
||||
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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":
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user