diff --git a/apps/android/app/src/main/java/ai/openclaw/app/i18n/NativeStringResources.kt b/apps/android/app/src/main/java/ai/openclaw/app/i18n/NativeStringResources.kt index 4ac0b18f732d..9087ca81a406 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/i18n/NativeStringResources.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/i18n/NativeStringResources.kt @@ -1188,6 +1188,7 @@ internal val nativeStringResourceIds: Map = "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 = "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, diff --git a/apps/android/app/src/main/res/values-ar/strings.xml b/apps/android/app/src/main/res/values-ar/strings.xml index ab32cf38dc0b..66442ae0d446 100644 --- a/apps/android/app/src/main/res/values-ar/strings.xml +++ b/apps/android/app/src/main/res/values-ar/strings.xml @@ -513,6 +513,7 @@ "حسنًا" "تعذّر فتح دليل الإعداد." "اسأل OpenClaw %1$s" + "Wait for Agents" "العنوان" "ستظهر هنا الأعمال المجدولة التي تم إنشاؤها على Gateway." "يتم عرض أحدث جزء من السجل." @@ -1028,6 +1029,7 @@ "تضمين حزم Android وحزم الخلفية." "Gateway جاهز." "تم التشغيل" + "Structured Output" "يستغرق هذا وقتًا أطول من المتوقع.\nتحقق من أن Gateway قيد التشغيل ويمكن الوصول إليه." "لا توجد مهام في الخلفية لهذا الوكيل." "جارٍ إعادة الاتصال" diff --git a/apps/android/app/src/main/res/values-de/strings.xml b/apps/android/app/src/main/res/values-de/strings.xml index 6533f99fe127..0d91da3189d5 100644 --- a/apps/android/app/src/main/res/values-de/strings.xml +++ b/apps/android/app/src/main/res/values-de/strings.xml @@ -513,6 +513,7 @@ "OK" "Einrichtungsanleitung konnte nicht geöffnet werden." "OpenClaw %1$s fragen" + "Wait for Agents" "Adresse" "Auf dem Gateway erstellte geplante Aufgaben werden hier angezeigt." "Der neueste Protokollausschnitt wird angezeigt." @@ -1028,6 +1029,7 @@ "Android- und Hintergrundpakete einschließen." "Ihr Gateway ist bereit." "Ausgelöst" + "Structured Output" "Dies dauert länger als erwartet.\nPrüfen Sie, ob das Gateway ausgeführt wird und erreichbar ist." "Keine Hintergrundaufgaben für diesen Agenten." "Verbindung wird wiederhergestellt" diff --git a/apps/android/app/src/main/res/values-es/strings.xml b/apps/android/app/src/main/res/values-es/strings.xml index 81d491021365..22242d99f627 100644 --- a/apps/android/app/src/main/res/values-es/strings.xml +++ b/apps/android/app/src/main/res/values-es/strings.xml @@ -513,6 +513,7 @@ "Aceptar" "No se pudo abrir la guía de configuración." "preguntar a OpenClaw %1$s" + "Wait for Agents" "Dirección" "El trabajo programado creado en el gateway aparecerá aquí." "Mostrando el fragmento de registro más reciente." @@ -1028,6 +1029,7 @@ "Incluye Android y paquetes en segundo plano." "Tu Gateway está listo." "Activado" + "Structured Output" "Esto está tardando más de lo esperado.\nComprueba que el Gateway esté en ejecución y accesible." "No hay tareas en segundo plano para este agente." "Reconectando" diff --git a/apps/android/app/src/main/res/values-fa/strings.xml b/apps/android/app/src/main/res/values-fa/strings.xml index 961327ebf0ff..a06561419559 100644 --- a/apps/android/app/src/main/res/values-fa/strings.xml +++ b/apps/android/app/src/main/res/values-fa/strings.xml @@ -513,6 +513,7 @@ "تأیید" "راهنمای راه‌اندازی باز نشد." "از OpenClaw بپرس %1$s" + "Wait for Agents" "نشانی" "کارهای زمان‌بندی‌شده ایجادشده در Gateway اینجا نمایش داده می‌شوند." "در حال نمایش آخرین بخش گزارش." @@ -1028,6 +1029,7 @@ "بسته‌های Android و پس‌زمینه را شامل کنید." "Gateway شما آماده است." "فعال شد" + "Structured Output" "این فرایند بیش از حد انتظار طول کشیده است.\nبررسی کنید که Gateway در حال اجرا و در دسترس باشد." "هیچ وظیفه پس‌زمینه‌ای برای این عامل وجود ندارد." "در حال اتصال مجدد" diff --git a/apps/android/app/src/main/res/values-fr/strings.xml b/apps/android/app/src/main/res/values-fr/strings.xml index 16290886c548..1d0d2d4bab0a 100644 --- a/apps/android/app/src/main/res/values-fr/strings.xml +++ b/apps/android/app/src/main/res/values-fr/strings.xml @@ -513,6 +513,7 @@ "OK" "Impossible d’ouvrir le guide de configuration." "demander à OpenClaw %1$s" + "Wait for Agents" "Adresse" "Les tâches planifiées créées sur le Gateway apparaîtront ici." "Affichage du dernier bloc de journal." @@ -1028,6 +1029,7 @@ "Inclure Android et les packages en arrière-plan." "Votre Gateway est prête." "Déclenché" + "Structured Output" "Cela prend plus de temps que prévu.\nVérifiez que la Gateway est en cours d’exécution et accessible." "Aucune tâche en arrière-plan pour cet agent." "Reconnexion" diff --git a/apps/android/app/src/main/res/values-hi/strings.xml b/apps/android/app/src/main/res/values-hi/strings.xml index d5673a24ce57..56924ab61140 100644 --- a/apps/android/app/src/main/res/values-hi/strings.xml +++ b/apps/android/app/src/main/res/values-hi/strings.xml @@ -513,6 +513,7 @@ "ठीक है" "सेटअप गाइड नहीं खुल सका।" "OpenClaw से %1$s पूछें" + "Wait for Agents" "पता" "Gateway पर बनाया गया शेड्यूल किया हुआ कार्य यहाँ दिखाई देगा।" "नवीनतम लॉग खंड दिखाया जा रहा है।" @@ -1028,6 +1029,7 @@ "Android और बैकग्राउंड पैकेज शामिल करें।" "आपका Gateway तैयार है।" "ट्रिगर किया गया" + "Structured Output" "इसमें अपेक्षा से अधिक समय लग रहा है।\nजाँचें कि Gateway चल रहा है और उस तक पहुँचा जा सकता है।" "इस एजेंट के लिए कोई बैकग्राउंड टास्क नहीं है।" "दोबारा कनेक्ट हो रहा है" diff --git a/apps/android/app/src/main/res/values-in/strings.xml b/apps/android/app/src/main/res/values-in/strings.xml index 90df1bf2cc49..379ab242d261 100644 --- a/apps/android/app/src/main/res/values-in/strings.xml +++ b/apps/android/app/src/main/res/values-in/strings.xml @@ -513,6 +513,7 @@ "OK" "Tidak dapat membuka panduan penyiapan." "tanyakan OpenClaw %1$s" + "Wait for Agents" "Alamat" "Pekerjaan terjadwal yang dibuat di Gateway akan muncul di sini." "Menampilkan potongan log terbaru." @@ -1028,6 +1029,7 @@ "Sertakan paket Android dan latar belakang." "Gateway Anda siap." "Terpicu" + "Structured Output" "Proses ini memakan waktu lebih lama dari yang diperkirakan.\nPastikan Gateway berjalan dan dapat dijangkau." "Tidak ada tugas latar belakang untuk agen ini." "Menyambungkan kembali" diff --git a/apps/android/app/src/main/res/values-it/strings.xml b/apps/android/app/src/main/res/values-it/strings.xml index da47f28cc679..efd231f09159 100644 --- a/apps/android/app/src/main/res/values-it/strings.xml +++ b/apps/android/app/src/main/res/values-it/strings.xml @@ -513,6 +513,7 @@ "OK" "Impossibile aprire la guida alla configurazione." "chiedi a OpenClaw %1$s" + "Wait for Agents" "Indirizzo" "Il lavoro pianificato creato sul gateway apparirà qui." "Visualizzazione dell\'ultimo blocco di log." @@ -1028,6 +1029,7 @@ "Includi Android e i pacchetti in background." "Il tuo Gateway è pronto." "Attivato" + "Structured Output" "L\'operazione sta richiedendo più tempo del previsto.\nVerifica che il Gateway sia in esecuzione e raggiungibile." "Nessuna attività in background per questo agente." "Riconnessione" diff --git a/apps/android/app/src/main/res/values-ja/strings.xml b/apps/android/app/src/main/res/values-ja/strings.xml index 03dc98c8108f..60c7888bcadd 100644 --- a/apps/android/app/src/main/res/values-ja/strings.xml +++ b/apps/android/app/src/main/res/values-ja/strings.xml @@ -513,6 +513,7 @@ "OK" "セットアップガイドを開けませんでした。" "OpenClaw に %1$s を尋ねる" + "Wait for Agents" "アドレス" "Gateway で作成されたスケジュール済みの作業がここに表示されます。" "最新のログチャンクを表示しています。" @@ -1028,6 +1029,7 @@ "Android とバックグラウンドのパッケージを含めます。" "Gatewayの準備ができました。" "トリガーされました" + "Structured Output" "予想より時間がかかっています。\nGatewayが起動していて、接続可能であることを確認してください。" "このエージェントにはバックグラウンドタスクがありません。" "再接続中" diff --git a/apps/android/app/src/main/res/values-ko/strings.xml b/apps/android/app/src/main/res/values-ko/strings.xml index 5ff7ad2e916b..81adec8c8898 100644 --- a/apps/android/app/src/main/res/values-ko/strings.xml +++ b/apps/android/app/src/main/res/values-ko/strings.xml @@ -513,6 +513,7 @@ "확인" "설정 가이드를 열 수 없습니다." "OpenClaw에 %1$s 요청" + "Wait for Agents" "주소" "Gateway에서 생성된 예약 작업이 여기에 표시됩니다." "최신 로그 청크를 표시하고 있습니다." @@ -1028,6 +1029,7 @@ "Android 및 백그라운드 패키지를 포함합니다." "Gateway가 준비되었습니다." "트리거됨" + "Structured Output" "예상보다 오래 걸리고 있습니다.\nGateway가 실행 중이며 연결 가능한지 확인하세요." "이 에이전트에는 백그라운드 작업이 없습니다." "다시 연결 중" diff --git a/apps/android/app/src/main/res/values-nl/strings.xml b/apps/android/app/src/main/res/values-nl/strings.xml index 86caf5f9b7d4..4dc056301bb2 100644 --- a/apps/android/app/src/main/res/values-nl/strings.xml +++ b/apps/android/app/src/main/res/values-nl/strings.xml @@ -513,6 +513,7 @@ "OK" "Kan de installatiehandleiding niet openen." "vraag OpenClaw %1$s" + "Wait for Agents" "Adres" "Gepland werk dat op de Gateway is aangemaakt, verschijnt hier." "De nieuwste logchunk wordt weergegeven." @@ -1028,6 +1029,7 @@ "Neem Android- en achtergrondpakketten op." "Je Gateway is klaar." "Geactiveerd" + "Structured Output" "Dit duurt langer dan verwacht.\nControleer of de Gateway actief en bereikbaar is." "Geen achtergrondtaken voor deze agent." "Opnieuw verbinding maken" diff --git a/apps/android/app/src/main/res/values-pl/strings.xml b/apps/android/app/src/main/res/values-pl/strings.xml index 5ff1fae5fad6..74cc3f94fc73 100644 --- a/apps/android/app/src/main/res/values-pl/strings.xml +++ b/apps/android/app/src/main/res/values-pl/strings.xml @@ -513,6 +513,7 @@ "OK" "Nie udało się otworzyć przewodnika konfiguracji." "zapytaj OpenClaw %1$s" + "Wait for Agents" "Adres" "Zaplanowane zadania utworzone w Gateway pojawią się tutaj." "Wyświetlany jest najnowszy fragment logu." @@ -1028,6 +1029,7 @@ "Uwzględnij pakiety Androida i działające w tle." "Gateway jest gotowy." "Aktywowano" + "Structured Output" "Trwa to dłużej niż oczekiwano.\nSprawdź, czy Gateway jest uruchomiony i osiągalny." "Brak zadań w tle dla tego agenta." "Ponowne łączenie" diff --git a/apps/android/app/src/main/res/values-pt-rBR/strings.xml b/apps/android/app/src/main/res/values-pt-rBR/strings.xml index fcfc6ff65a10..b5f1f79fefc8 100644 --- a/apps/android/app/src/main/res/values-pt-rBR/strings.xml +++ b/apps/android/app/src/main/res/values-pt-rBR/strings.xml @@ -513,6 +513,7 @@ "OK" "Não foi possível abrir o guia de configuração." "perguntar ao OpenClaw %1$s" + "Wait for Agents" "Endereço" "As tarefas agendadas criadas no Gateway aparecerão aqui." "Mostrando o bloco de log mais recente." @@ -1028,6 +1029,7 @@ "Incluir pacotes do Android e em segundo plano." "Seu Gateway está pronto." "Acionado" + "Structured Output" "Isso está demorando mais do que o esperado.\nVerifique se o Gateway está em execução e acessível." "Não há tarefas em segundo plano para este agente." "Reconectando" diff --git a/apps/android/app/src/main/res/values-ru/strings.xml b/apps/android/app/src/main/res/values-ru/strings.xml index e10db1e2d23b..f5cba7baf5aa 100644 --- a/apps/android/app/src/main/res/values-ru/strings.xml +++ b/apps/android/app/src/main/res/values-ru/strings.xml @@ -513,6 +513,7 @@ "ОК" "Не удалось открыть руководство по настройке." "спросить OpenClaw %1$s" + "Wait for Agents" "Адрес" "Запланированные задачи, созданные на Gateway, появятся здесь." "Показан последний фрагмент журнала." @@ -1028,6 +1029,7 @@ "Включить Android и фоновые пакеты." "Ваш Gateway готов." "Активировано" + "Structured Output" "Это занимает больше времени, чем ожидалось.\nУбедитесь, что Gateway запущен и доступен." "У этого агента нет фоновых задач." "Повторное подключение" diff --git a/apps/android/app/src/main/res/values-sv/strings.xml b/apps/android/app/src/main/res/values-sv/strings.xml index ef1b3df9d8ef..99bdcad1cce5 100644 --- a/apps/android/app/src/main/res/values-sv/strings.xml +++ b/apps/android/app/src/main/res/values-sv/strings.xml @@ -513,6 +513,7 @@ "OK" "Det gick inte att öppna konfigurationsguiden." "fråga OpenClaw %1$s" + "Wait for Agents" "Adress" "Schemalagda uppgifter som skapas på Gateway visas här." "Visar det senaste loggavsnittet." @@ -1028,6 +1029,7 @@ "Inkludera Android och bakgrundspaket." "Din Gateway är redo." "Aktiverad" + "Structured Output" "Det här tar längre tid än väntat.\nKontrollera att Gateway körs och går att nå." "Inga bakgrundsuppgifter för den här agenten." "Återansluter" diff --git a/apps/android/app/src/main/res/values-th/strings.xml b/apps/android/app/src/main/res/values-th/strings.xml index 78726713814f..463b82c2fc21 100644 --- a/apps/android/app/src/main/res/values-th/strings.xml +++ b/apps/android/app/src/main/res/values-th/strings.xml @@ -513,6 +513,7 @@ "ตกลง" "ไม่สามารถเปิดคู่มือการตั้งค่าได้" "ถาม OpenClaw %1$s" + "Wait for Agents" "ที่อยู่" "งานตามกำหนดการที่สร้างบน Gateway จะปรากฏที่นี่" "กำลังแสดงส่วนบันทึกล่าสุด" @@ -1028,6 +1029,7 @@ "รวมแพ็กเกจของ Android และแพ็กเกจเบื้องหลัง" "Gateway ของคุณพร้อมใช้งานแล้ว" "ทริกเกอร์แล้ว" + "Structured Output" "ใช้เวลานานกว่าที่คาดไว้\nตรวจสอบว่า Gateway กำลังทำงานและสามารถเข้าถึงได้" "ไม่มีงานเบื้องหลังสำหรับเอเจนต์นี้" "กำลังเชื่อมต่อใหม่" diff --git a/apps/android/app/src/main/res/values-tr/strings.xml b/apps/android/app/src/main/res/values-tr/strings.xml index c05738e4b514..4cf65561c86b 100644 --- a/apps/android/app/src/main/res/values-tr/strings.xml +++ b/apps/android/app/src/main/res/values-tr/strings.xml @@ -513,6 +513,7 @@ "Tamam" "Kurulum kılavuzu açılamadı." "OpenClaw\'a %1$s sor" + "Wait for Agents" "Adres" "Gateway üzerinde oluşturulan zamanlanmış işler burada görünür." "En son günlük parçası gösteriliyor." @@ -1028,6 +1029,7 @@ "Android ve arka plan paketlerini dahil edin." "Gateway\'iniz hazır." "Tetiklendi" + "Structured Output" "Bu işlem beklenenden uzun sürüyor.\nGateway\'in çalıştığını ve erişilebilir olduğunu kontrol edin." "Bu temsilci için arka plan görevi yok." "Yeniden bağlanıyor" diff --git a/apps/android/app/src/main/res/values-uk/strings.xml b/apps/android/app/src/main/res/values-uk/strings.xml index 8df622f61f5e..b9573d887dcb 100644 --- a/apps/android/app/src/main/res/values-uk/strings.xml +++ b/apps/android/app/src/main/res/values-uk/strings.xml @@ -513,6 +513,7 @@ "ОК" "Не вдалося відкрити посібник із налаштування." "запитати OpenClaw %1$s" + "Wait for Agents" "Адреса" "Заплановані завдання, створені на Gateway, з’являться тут." "Показано останній фрагмент журналу." @@ -1028,6 +1029,7 @@ "Включати Android і фонові пакети." "Ваш Gateway готовий." "Активовано" + "Structured Output" "Це займає більше часу, ніж очікувалося.\nПереконайтеся, що Gateway запущено й він доступний." "Для цього агента немає фонових завдань." "Повторне підключення" diff --git a/apps/android/app/src/main/res/values-vi/strings.xml b/apps/android/app/src/main/res/values-vi/strings.xml index 40e1e5c871dc..d38953778be6 100644 --- a/apps/android/app/src/main/res/values-vi/strings.xml +++ b/apps/android/app/src/main/res/values-vi/strings.xml @@ -513,6 +513,7 @@ "OK" "Không thể mở hướng dẫn thiết lập." "hỏi OpenClaw %1$s" + "Wait for Agents" "Địa chỉ" "Công việc theo lịch được tạo trên Gateway sẽ xuất hiện tại đây." "Đang hiển thị phần nhật ký mới nhất." @@ -1028,6 +1029,7 @@ "Bao gồm Android và các gói chạy nền." "Gateway của bạn đã sẵn sàng." "Đã kích hoạt" + "Structured Output" "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." "Không có tác vụ nền nào cho tác nhân này." "Đang kết nối lại" diff --git a/apps/android/app/src/main/res/values-zh-rCN/strings.xml b/apps/android/app/src/main/res/values-zh-rCN/strings.xml index 7c2132e25e5f..b2277acf9276 100644 --- a/apps/android/app/src/main/res/values-zh-rCN/strings.xml +++ b/apps/android/app/src/main/res/values-zh-rCN/strings.xml @@ -513,6 +513,7 @@ "确定" "无法打开设置指南。" "询问 OpenClaw %1$s" + "Wait for Agents" "地址" "在 Gateway 上创建的计划任务将显示在此处。" "正在显示最新的日志块。" @@ -1028,6 +1029,7 @@ "包括 Android 和后台包。" "您的 Gateway 已准备就绪。" "已触发" + "Structured Output" "所需时间比预期更长。\n请检查 Gateway 是否正在运行且可访问。" "此代理没有后台任务。" "正在重新连接" diff --git a/apps/android/app/src/main/res/values-zh-rTW/strings.xml b/apps/android/app/src/main/res/values-zh-rTW/strings.xml index 0bd12d1b7b28..504632d054a5 100644 --- a/apps/android/app/src/main/res/values-zh-rTW/strings.xml +++ b/apps/android/app/src/main/res/values-zh-rTW/strings.xml @@ -513,6 +513,7 @@ "確定" "無法開啟設定指南。" "詢問 OpenClaw %1$s" + "Wait for Agents" "位址" "在 Gateway 上建立的排程工作將顯示於此。" "正在顯示最新的記錄區塊。" @@ -1028,6 +1029,7 @@ "包含 Android 和背景套件。" "您的 Gateway 已準備就緒。" "已觸發" + "Structured Output" "所需時間比預期更長。\n請確認 Gateway 正在執行且可連線。" "此代理程式沒有背景任務。" "正在重新連線" diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml index e176ba23bcc6..386b48d31a1c 100644 --- a/apps/android/app/src/main/res/values/strings.xml +++ b/apps/android/app/src/main/res/values/strings.xml @@ -513,6 +513,7 @@ "OK" "Could not open setup guide." "ask OpenClaw %1$s" + "Wait for Agents" "Address" "Scheduled work created on the gateway will appear here." "Showing the latest log chunk." @@ -1028,6 +1029,7 @@ "Include Android and background packages." "Your Gateway is ready." "Triggered" + "Structured Output" "This is taking longer than expected.\nCheck that the Gateway is running and reachable." "No background tasks for this agent." "Reconnecting" diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/tool-display.json b/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/tool-display.json index 3da7628c8195..621e8adfac69 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/tool-display.json +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/tool-display.json @@ -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", diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 0fc21f30a4bb..6efa9f512ac4 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -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" diff --git a/docs/docs_map.md b/docs/docs_map.md index f58fbae52412..b3cd9bdd01a6 100644 --- a/docs/docs_map.md +++ b/docs/docs_map.md @@ -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 diff --git a/docs/plan/swarms.md b/docs/plan/swarms.md new file mode 100644 index 000000000000..af3e14a5f28c --- /dev/null +++ b/docs/plan/swarms.md @@ -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::`. 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` — 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.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::`. +- 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:"` 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. diff --git a/packages/gateway-protocol/src/schema/agent.ts b/packages/gateway-protocol/src/schema/agent.ts index c70f6ce571ac..316c4a033fd6 100644 --- a/packages/gateway-protocol/src/schema/agent.ts +++ b/packages/gateway-protocol/src/schema/agent.ts @@ -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()), diff --git a/scripts/plugin-sdk-surface-report.mjs b/scripts/plugin-sdk-surface-report.mjs index 4c554a86a53b..2b52779737f2 100644 --- a/scripts/plugin-sdk-surface-report.mjs +++ b/scripts/plugin-sdk-surface-report.mjs @@ -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( diff --git a/src/agents/agent-tools.before-tool-call.e2e.test.ts b/src/agents/agent-tools.before-tool-call.e2e.test.ts index 254676d4c96f..4ecc122b6843 100644 --- a/src/agents/agent-tools.before-tool-call.e2e.test.ts +++ b/src/agents/agent-tools.before-tool-call.e2e.test.ts @@ -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", diff --git a/src/agents/agent-tools.before-tool-call.ts b/src/agents/agent-tools.before-tool-call.ts index 69e4db387b35..f61086635f85 100644 --- a/src/agents/agent-tools.before-tool-call.ts +++ b/src/agents/agent-tools.before-tool-call.ts @@ -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 { 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; const source = taggedTool[BEFORE_TOOL_CALL_SOURCE_TOOL]; diff --git a/src/agents/agent-tools.ts b/src/agents/agent-tools.ts index 236659bec7f1..9a7fbfeeeb18 100644 --- a/src/agents/agent-tools.ts +++ b/src/agents/agent-tools.ts @@ -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; /** 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) diff --git a/src/agents/bash-tools.exec-host-gateway.ts b/src/agents/bash-tools.exec-host-gateway.ts index 05b4dbd84cec..b3c9e3e48447 100644 --- a/src/agents/bash-tools.exec-host-gateway.ts +++ b/src/agents/bash-tools.exec-host-gateway.ts @@ -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 && diff --git a/src/agents/bash-tools.exec-host-node.test.ts b/src/agents/bash-tools.exec-host-node.test.ts index 865553907132..c321e957317f 100644 --- a/src/agents/bash-tools.exec-host-node.test.ts +++ b/src/agents/bash-tools.exec-host-node.test.ts @@ -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: {} } }, diff --git a/src/agents/bash-tools.exec-host-node.ts b/src/agents/bash-tools.exec-host-node.ts index ff254dc07145..64b13c15f4b4 100644 --- a/src/agents/bash-tools.exec-host-node.ts +++ b/src/agents/bash-tools.exec-host-node.ts @@ -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.", diff --git a/src/agents/bash-tools.exec-host-node.types.ts b/src/agents/bash-tools.exec-host-node.types.ts index ee8572b7e626..88957a3c4262 100644 --- a/src/agents/bash-tools.exec-host-node.types.ts +++ b/src/agents/bash-tools.exec-host-node.types.ts @@ -22,6 +22,7 @@ export type ExecuteNodeHostCommandParams = { sessionStore?: string; bashElevated?: ExecElevatedDefaults; approvalReviewerDeviceId?: string; + nonInteractiveApproval?: boolean; turnSourceChannel?: string; turnSourceTo?: string; turnSourceAccountId?: string; diff --git a/src/agents/bash-tools.exec-types.ts b/src/agents/bash-tools.exec-types.ts index 3011d5470299..9907173c0b9d 100644 --- a/src/agents/bash-tools.exec-types.ts +++ b/src/agents/bash-tools.exec-types.ts @@ -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; diff --git a/src/agents/bash-tools.exec.ts b/src/agents/bash-tools.exec.ts index b247c0e74ac0..fd5d336baeae 100644 --- a/src/agents/bash-tools.exec.ts +++ b/src/agents/bash-tools.exec.ts @@ -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, diff --git a/src/agents/command/attempt-execution.ts b/src/agents/command/attempt-execution.ts index f848a82aaa5a..238cd6095ccf 100644 --- a/src/agents/command/attempt-execution.ts +++ b/src/agents/command/attempt-execution.ts @@ -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, diff --git a/src/agents/command/types.ts b/src/agents/command/types.ts index 9ca2403775d8..ba04affe5b2c 100644 --- a/src/agents/command/types.ts +++ b/src/agents/command/types.ts @@ -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; /** Restrict this reconstructed run to restart-safe tools. */ forceRestartSafeTools?: boolean; /** Host-owned exact media set for a scoped automatic recovery delivery. */ diff --git a/src/agents/core-tool-factory-descriptors.ts b/src/agents/core-tool-factory-descriptors.ts index 9fbaf96a00fe..4f244bd2b7d3 100644 --- a/src/agents/core-tool-factory-descriptors.ts +++ b/src/agents/core-tool-factory-descriptors.ts @@ -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" }, diff --git a/src/agents/embedded-agent-runner/run/attempt-tool-base-prepare.ts b/src/agents/embedded-agent-runner/run/attempt-tool-base-prepare.ts index 52a4d3547cd7..c2d14080f7ac 100644 --- a/src/agents/embedded-agent-runner/run/attempt-tool-base-prepare.ts +++ b/src/agents/embedded-agent-runner/run/attempt-tool-base-prepare.ts @@ -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, diff --git a/src/agents/embedded-agent-runner/run/attempt-tool-construction-plan.test.ts b/src/agents/embedded-agent-runner/run/attempt-tool-construction-plan.test.ts index 5795d0887f09..3cec6d02397a 100644 --- a/src/agents/embedded-agent-runner/run/attempt-tool-construction-plan.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt-tool-construction-plan.test.ts @@ -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" }]; diff --git a/src/agents/embedded-agent-runner/run/attempt-tool-construction-plan.ts b/src/agents/embedded-agent-runner/run/attempt-tool-construction-plan.ts index 87a86030ff1a..77f9b0d5c71c 100644 --- a/src/agents/embedded-agent-runner/run/attempt-tool-construction-plan.ts +++ b/src/agents/embedded-agent-runner/run/attempt-tool-construction-plan.ts @@ -82,26 +82,26 @@ export function applyEmbeddedAttemptToolsAllow( } /** - * 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( diff --git a/src/agents/embedded-agent-runner/run/params.ts b/src/agents/embedded-agent-runner/run/params.ts index acfd46ba079c..9e8b7ad6f695 100644 --- a/src/agents/embedded-agent-runner/run/params.ts +++ b/src/agents/embedded-agent-runner/run/params.ts @@ -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; /** Restrict this reconstructed run to restart-safe tools. */ forceRestartSafeTools?: boolean; /** Internal one-shot model probe mode: no tools, no workspace/chat prompt policy. */ diff --git a/src/agents/embedded-agent-runner/run/run-attempt-dispatch.ts b/src/agents/embedded-agent-runner/run/run-attempt-dispatch.ts index 249d9723e118..ebf1454aba2e 100644 --- a/src/agents/embedded-agent-runner/run/run-attempt-dispatch.ts +++ b/src/agents/embedded-agent-runner/run/run-attempt-dispatch.ts @@ -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, diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn.model.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn.model.test.ts index c884e18d192e..a50a2fc5cd07 100644 --- a/src/agents/openclaw-tools.subagents.sessions-spawn.model.test.ts +++ b/src/agents/openclaw-tools.subagents.sessions-spawn.model.test.ts @@ -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(), diff --git a/src/agents/openclaw-tools.swarm.test.ts b/src/agents/openclaw-tools.swarm.test.ts new file mode 100644 index 000000000000..d9204a2035f1 --- /dev/null +++ b/src/agents/openclaw-tools.swarm.test.ts @@ -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[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"); + }); +}); diff --git a/src/agents/openclaw-tools.swarm.ts b/src/agents/openclaw-tools.swarm.ts new file mode 100644 index 000000000000..79b55ca90fbb --- /dev/null +++ b/src/agents/openclaw-tools.swarm.ts @@ -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; +}): { 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 }; +} diff --git a/src/agents/openclaw-tools.ts b/src/agents/openclaw-tools.ts index ed846e79bee9..c7e7c23ab3f1 100644 --- a/src/agents/openclaw-tools.ts +++ b/src/agents/openclaw-tools.ts @@ -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; /** 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: diff --git a/src/agents/spawn-pipeline.ts b/src/agents/spawn-pipeline.ts index f81d017f6747..20702cb6f43c 100644 --- a/src/agents/spawn-pipeline.ts +++ b/src/agents/spawn-pipeline.ts @@ -64,8 +64,11 @@ export async function runSpawnPipeline(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 }); diff --git a/src/agents/subagent-control.test.ts b/src/agents/subagent-control.test.ts index aa8de8a86b55..159c57eda194 100644 --- a/src/agents/subagent-control.test.ts +++ b/src/agents/subagent-control.test.ts @@ -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", diff --git a/src/agents/subagent-control.ts b/src/agents/subagent-control.ts index 0ed5b1ae4cb5..6d8f6e3857e1 100644 --- a/src/agents/subagent-control.ts +++ b/src/agents/subagent-control.ts @@ -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, diff --git a/src/agents/subagent-orphan-recovery.test.ts b/src/agents/subagent-orphan-recovery.test.ts index 3d34652ed5ac..6bf95eb42184 100644 --- a/src/agents/subagent-orphan-recovery.test.ts +++ b/src/agents/subagent-orphan-recovery.test.ts @@ -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 { @@ -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(); 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( diff --git a/src/agents/subagent-orphan-recovery.ts b/src/agents/subagent-orphan-recovery.ts index 63ff4ecef230..8ec22052d62d 100644 --- a/src/agents/subagent-orphan-recovery.ts +++ b/src/agents/subagent-orphan-recovery.ts @@ -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, diff --git a/src/agents/subagent-registry-helpers.test.ts b/src/agents/subagent-registry-helpers.test.ts index 2cd29204fdf1..a1e08ca7ea2a 100644 --- a/src/agents/subagent-registry-helpers.test.ts +++ b/src/agents/subagent-registry-helpers.test.ts @@ -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(); diff --git a/src/agents/subagent-registry-helpers.ts b/src/agents/subagent-registry-helpers.ts index 75701de7053a..96da4ffa78fa 100644 --- a/src/agents/subagent-registry-helpers.ts +++ b/src/agents/subagent-registry-helpers.ts @@ -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 { +export async function safeRemoveAttachmentsDir(entry: SubagentRunRecord): Promise { if (!entry.attachmentsDir || !entry.attachmentsRootDir) { - return; + return true; } const resolveReal = async (targetPath: string): Promise => { @@ -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; } } diff --git a/src/agents/subagent-registry-lifecycle.test.ts b/src/agents/subagent-registry-lifecycle.test.ts index 4d496c20e076..a86876675d61 100644 --- a/src/agents/subagent-registry-lifecycle.test.ts +++ b/src/agents/subagent-registry-lifecycle.test.ts @@ -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({ diff --git a/src/agents/subagent-registry-lifecycle.ts b/src/agents/subagent-registry-lifecycle.ts index c1509d52cb43..a5d32db2ca96 100644 --- a/src/agents/subagent-registry-lifecycle.ts +++ b/src/agents/subagent-registry-lifecycle.ts @@ -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 diff --git a/src/agents/subagent-registry-memory.ts b/src/agents/subagent-registry-memory.ts index 973d8c3da6d9..4e0cbf88408f 100644 --- a/src/agents/subagent-registry-memory.ts +++ b/src/agents/subagent-registry-memory.ts @@ -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(); +// 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(); + +class SubagentRunMap extends Map { + 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 = 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; +}): 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; +} diff --git a/src/agents/subagent-registry-queries.test.ts b/src/agents/subagent-registry-queries.test.ts index b2c92944fb90..6926b6b3b3c2 100644 --- a/src/agents/subagent-registry-queries.test.ts +++ b/src/agents/subagent-registry-queries.test.ts @@ -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"; diff --git a/src/agents/subagent-registry-queries.ts b/src/agents/subagent-registry-queries.ts index 29427ddea84d..d8067e49cf7a 100644 --- a/src/agents/subagent-registry-queries.ts +++ b/src/agents/subagent-registry-queries.ts @@ -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, @@ -390,7 +396,7 @@ export function countActiveRunsForSessionFromRuns( const latestByChildSessionKey = new Map(); for (const entry of runs.values()) { - if (resolveControllerSessionKey(entry) !== key) { + if (resolveConcurrencyOwnerSessionKey(entry) !== key) { continue; } const existing = latestByChildSessionKey.get(entry.childSessionKey); diff --git a/src/agents/subagent-registry-run-manager.ts b/src/agents/subagent-registry-run-manager.ts index c017edad6392..480cb6fa27b6 100644 --- a/src/agents/subagent-registry-run-manager.ts +++ b/src/agents/subagent-registry-run-manager.ts @@ -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, + requesterSessionKey: string, +): string[] { + const ownerSessionKeys: string[] = []; + const visited = new Set(); + 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; + 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; + 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; + 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(); + const queuedCollectorRunIds: string[] = []; const entrySnapshots = new Map(); 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, diff --git a/src/agents/subagent-registry-steer-runtime.ts b/src/agents/subagent-registry-steer-runtime.ts index a149c5e5b1d4..395a93b451ea 100644 --- a/src/agents/subagent-registry-steer-runtime.ts +++ b/src/agents/subagent-registry-steer-runtime.ts @@ -34,17 +34,21 @@ type FinalizeInterruptedSubagentRunParams = { type FinalizeInterruptedSubagentRunFn = ( params: FinalizeInterruptedSubagentRunParams, ) => Promise; +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; +} diff --git a/src/agents/subagent-registry.persistence.test.ts b/src/agents/subagent-registry.persistence.test.ts index 5584b52f7d51..a62d23116309 100644 --- a/src/agents/subagent-registry.persistence.test.ts +++ b/src/agents/subagent-registry.persistence.test.ts @@ -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", diff --git a/src/agents/subagent-registry.store.sqlite.ts b/src/agents/subagent-registry.store.sqlite.ts index 27161d60dd84..84f8ece84ce0 100644 --- a/src/agents/subagent-registry.store.sqlite.ts +++ b/src/agents/subagent-registry.store.sqlite.ts @@ -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 } : {}), + ...(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), }; } diff --git a/src/agents/subagent-registry.test-helpers.ts b/src/agents/subagent-registry.test-helpers.ts index 374b32b5d6f6..f4bf11b522c3 100644 --- a/src/agents/subagent-registry.test-helpers.ts +++ b/src/agents/subagent-registry.test-helpers.ts @@ -28,6 +28,7 @@ type RegistryTestApi = { releaseSubagentRun(runId: string): void; resetSubagentRegistryForTests(opts?: { persist?: boolean }): void; testing: { + failQueuedSubagentRun(runId: string, error: string): boolean; sweepOnceForTests(): Promise; runSweeperTickForTests(): Promise; setDepsForTest(overrides?: Partial): 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) => diff --git a/src/agents/subagent-registry.test.ts b/src/agents/subagent-registry.test.ts index 56c49a9a6c0f..47ea99ae9f5e 100644 --- a/src/agents/subagent-registry.test.ts +++ b/src/agents/subagent-registry.test.ts @@ -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 = (callback: () => T | Promise) => @@ -108,7 +110,7 @@ const mocks = vi.hoisted(() => ({ callGateway: vi.fn<(request: { method?: string }) => Promise>>(), 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; + }) => { + 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; + }) => { + 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>((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; + }) => { + 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>((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; + }) => { + 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; + }) => { + 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>((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({ diff --git a/src/agents/subagent-registry.ts b/src/agents/subagent-registry.ts index 7024a7a1c99f..c6f603cbd13c 100644 --- a/src/agents/subagent-registry.ts +++ b/src/agents/subagent-registry.ts @@ -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 { + 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 { 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 { + try { + await runContextEngineSubagentEnded(params); + return true; + } catch (err) { + log.warn("context-engine collector cleanup failed", { err }); + return false; + } +} + +async function cleanupCollectorLaunchResources(entry: SubagentRunRecord): Promise { + 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 { + // 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((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 { + 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((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((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(); 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; +} { + const snapshot = subagentRegistryDeps.getSubagentRunsSnapshotForRead(subagentRuns); + const byId = new Map(); + 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), diff --git a/src/agents/subagent-registry.types.ts b/src/agents/subagent-registry.types.ts index 7754055a576b..c1bf8a7d40f3 100644 --- a/src/agents/subagent-registry.types.ts +++ b/src/agents/subagent-registry.types.ts @@ -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; + 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; + 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; }; diff --git a/src/agents/subagent-spawn-plan.ts b/src/agents/subagent-spawn-plan.ts index cb8fa4936ffa..657da958cf77 100644 --- a/src/agents/subagent-spawn-plan.ts +++ b/src/agents/subagent-spawn-plan.ts @@ -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 } : {}), }, }; } diff --git a/src/agents/subagent-spawn.runtime.ts b/src/agents/subagent-spawn.runtime.ts index c4fb05b37da8..23a18931d4ad 100644 --- a/src/agents/subagent-spawn.runtime.ts +++ b/src/agents/subagent-spawn.runtime.ts @@ -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"; diff --git a/src/agents/subagent-spawn.test-helpers.ts b/src/agents/subagent-spawn.test-helpers.ts index 7a88b6c37802..222d1e5511b8 100644 --- a/src/agents/subagent-spawn.test-helpers.ts +++ b/src/agents/subagent-spawn.test-helpers.ts @@ -133,6 +133,7 @@ export async function loadSubagentSpawnModuleForTest(params: { hasInProcessGatewayContextMock?: MockFn; getRuntimeConfig?: () => Record; 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, 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; 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) => undefined), resetSubagentRegistryForTests, + settleFailedQueuedSubagentLaunch: + params.settleFailedQueuedSubagentLaunchMock ?? vi.fn(() => true), + startQueuedSubagentRun: params.startQueuedSubagentRunMock ?? vi.fn(() => true), })); const subagentSpawnModule = await import("./subagent-spawn.js"); diff --git a/src/agents/subagent-spawn.test-support.ts b/src/agents/subagent-spawn.test-support.ts index 2db198d7285f..c06a62bce19d 100644 --- a/src/agents/subagent-spawn.test-support.ts +++ b/src/agents/subagent-spawn.test-support.ts @@ -11,6 +11,7 @@ type SpawnDeps = Omit< | "getGlobalHookRunner" | "getRuntimeConfig" | "hasInProcessGatewayContext" + | "loadModelCatalog" | "resolveContextEngine" >, "getGlobalHookRunner" diff --git a/src/agents/subagent-spawn.test.ts b/src/agents/subagent-spawn.test.ts index 862c13f175e4..7bf29f2f5d7a 100644 --- a/src/agents/subagent-spawn.test.ts +++ b/src/agents/subagent-spawn.test.ts @@ -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, })); @@ -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[] = []; + hoisted.updateSessionStoreMock.mockImplementation( + async ( + _storePath: string, + mutator: (store: Record>) => unknown, + ) => { + const store: Record> = {}; + 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>((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> | 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> | 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> | 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> | undefined; installSessionStoreCaptureMock(hoisted.updateSessionStoreMock, { diff --git a/src/agents/subagent-spawn.ts b/src/agents/subagent-spawn.ts index c9149bc8e13c..8d6353292436 100644 --- a/src/agents/subagent-spawn.ts +++ b/src/agents/subagent-spawn.ts @@ -26,22 +26,26 @@ import { listRegisteredPluginAgentPromptGuidance } from "../plugins/command-regi import type { SubagentLifecycleHookRunner } from "../plugins/hooks.js"; import { isValidAgentId, normalizeAgentId, parseAgentSessionKey } from "../routing/session-key.js"; import { recordSubagentSpawned } from "../sessions/session-state-events.js"; +import type { FastMode } from "../shared/fast-mode.js"; import { resolveUserPath } from "../utils.js"; import type { DeliveryContext } from "../utils/delivery-context.types.js"; import { listAgentIds, resolveAgentDir } from "./agent-scope-config.js"; import type { BootstrapContextMode } from "./bootstrap-files.js"; +import { resolveFastModeState } from "./fast-mode.js"; import { inheritedToolAllowPatch, inheritedToolDenyPatch, normalizeInheritedToolAllowlist, normalizeInheritedToolDenylist, } from "./inherited-tool-deny.js"; +import { findModelCatalogEntry } from "./model-catalog-lookup.js"; import { normalizeStoredOverrideModel, resolveDefaultModelForAgent, resolvePersistedSelectedModelRef, } from "./model-selection.js"; import { resolveThinkingDefault } from "./model-thinking-default.js"; +import { supportsModelTools } from "./model-tool-support.js"; import { runSpawnPipeline, type SpawnBackendAdapter, @@ -65,6 +69,12 @@ import { type SubagentAttachmentReceiptFile, } from "./subagent-attachments.js"; import { buildSubagentInitialUserMessage } from "./subagent-initial-user-message.js"; +import { + completeCollectorLaunchCleanup, + listSwarmRunsForGroup, + settleFailedQueuedSubagentLaunch, + startQueuedSubagentRun, +} from "./subagent-registry.js"; import { resolveSubagentRunTimerDelayMs } from "./subagent-run-timeout.js"; import { resolveSubagentSpawnAcceptedNote } from "./subagent-spawn-accepted-note.js"; import { resolveSubagentSpawnOwnership } from "./subagent-spawn-ownership.js"; @@ -88,6 +98,7 @@ import { mergeDeliveryContext, normalizeDeliveryContext, ensureContextEnginesInitialized, + loadModelCatalog, resolveAgentConfig, resolveContextEngine, resolveGatewaySessionStoreTarget, @@ -104,6 +115,9 @@ import type { SpawnSubagentSandboxMode, } from "./subagent-spawn.types.js"; import { normalizeSubagentTaskName } from "./subagent-task-name.js"; +import { resolveSwarmConfig } from "./swarm-config.js"; +import { validateStructuredOutputSchema } from "./swarm-output-schema.js"; +import { activateSwarmRun, removeQueuedSwarmRun, reserveSwarmRun } from "./swarm-scheduler.js"; export { SUBAGENT_SPAWN_CONTEXT_MODES, SUBAGENT_SPAWN_MODES } from "./subagent-spawn.types.js"; @@ -119,6 +133,7 @@ type SubagentSpawnDeps = { getRuntimeConfig: typeof getRuntimeConfig; hasInProcessGatewayContext: typeof hasInProcessGatewayContext; ensureContextEnginesInitialized: typeof ensureContextEnginesInitialized; + loadModelCatalog: typeof loadModelCatalog; resolveContextEngine: typeof resolveContextEngine; }; @@ -130,6 +145,7 @@ const defaultSubagentSpawnDeps: SubagentSpawnDeps = { getRuntimeConfig, hasInProcessGatewayContext, ensureContextEnginesInitialized, + loadModelCatalog, resolveContextEngine, }; @@ -145,6 +161,10 @@ type SpawnSubagentParams = { model?: string; taskName?: string; thinking?: string; + fastMode?: FastMode; + collect?: boolean; + outputSchema?: Record; + groupId?: string; cwd?: string; runTimeoutSeconds?: number; thread?: boolean; @@ -184,11 +204,13 @@ type SpawnSubagentContext = { workspaceDir?: string; inheritedToolAllowlist?: string[]; inheritedToolDenylist?: string[]; + requesterRunId?: string; }; type SpawnSubagentResult = { status: "accepted" | "forbidden" | "error"; childSessionKey?: string; + sessionKey?: string; runId?: string; mode?: SpawnSubagentMode; taskName?: string; @@ -274,6 +296,40 @@ function buildResolvedSubagentModelMetadata( }; } +async function resolveCollectorOutputModelError(params: { + cfg: OpenClawConfig; + targetAgentId: string; + targetAgentDir: string; + workspaceDir?: string; + resolvedModel?: string; +}): Promise { + const selected = splitModelRef(params.resolvedModel); + const fallback = resolveDefaultModelForAgent({ + cfg: params.cfg, + agentId: params.targetAgentId, + }); + const provider = selected.provider ?? fallback.provider; + const model = selected.model ?? fallback.model; + if (!provider || !model) { + return undefined; + } + let catalog: Awaited>; + try { + catalog = await subagentSpawnDeps.loadModelCatalog({ + config: params.cfg, + agentDir: params.targetAgentDir, + workspaceDir: params.workspaceDir, + }); + } catch (error) { + return `sessions_spawn could not verify outputSchema model capabilities: ${summarizeError(error)}`; + } + const entry = findModelCatalogEntry(catalog, { provider, modelId: model }); + if (!entry || supportsModelTools(entry)) { + return undefined; + } + return `sessions_spawn outputSchema requires a tool-capable target model; "${provider}/${model}" declares compat.supportsTools=false.`; +} + function resolveSubagentAgentGatewayTimeoutMs(runTimeoutSeconds: number): number { const runTimeoutMs = resolveSubagentRunTimerDelayMs(runTimeoutSeconds) ?? 0; if (runTimeoutMs <= 0) { @@ -317,6 +373,18 @@ function buildDirectChildSessionPatch(patch: Record): Partial; + } if (typeof patch.model === "string" && patch.model.trim()) { const { provider, model } = splitModelRef(patch.model.trim()); if (model) { @@ -433,6 +501,53 @@ function readRequesterThinkingLevel(params: { }); } +function readRequesterFastMode(params: { + cfg: OpenClawConfig; + requesterInternalKey: string; + requesterAgentId?: string; +}): FastMode { + let entry: SessionEntry | undefined; + try { + const target = resolveGatewaySessionStoreTarget({ + cfg: params.cfg, + key: params.requesterInternalKey, + }); + entry = loadSessionEntry({ + storePath: target.storePath, + sessionKey: target.canonicalKey, + clone: false, + }); + } catch { + entry = undefined; + } + const defaultModel = resolveDefaultModelForAgent({ + cfg: params.cfg, + agentId: params.requesterAgentId, + }); + const normalizedOverride = entry + ? normalizeStoredOverrideModel({ + providerOverride: entry.providerOverride, + modelOverride: entry.modelOverride, + }) + : {}; + const selectedModel = entry + ? resolvePersistedSelectedModelRef({ + defaultProvider: defaultModel.provider, + runtimeProvider: entry.modelProvider, + runtimeModel: entry.model, + overrideProvider: normalizedOverride.providerOverride, + overrideModel: normalizedOverride.modelOverride, + }) + : undefined; + return resolveFastModeState({ + cfg: params.cfg, + provider: selectedModel?.provider ?? defaultModel.provider, + model: selectedModel?.model ?? defaultModel.model, + agentId: params.requesterAgentId, + sessionEntry: entry, + }).mode; +} + type PreparedSpawnContext = | { status: "ok"; @@ -592,11 +707,13 @@ async function prepareContextEngineSubagentSpawn(params: { async function rollbackPreparedContextEngine( preparation?: SubagentSpawnPreparation, -): Promise { +): Promise { try { await preparation?.rollback(); + return true; } catch { // Best-effort cleanup only. + return false; } } @@ -630,7 +747,7 @@ async function cleanupProvisionalSession( emitLifecycleHooks?: boolean; deleteTranscript?: boolean; }, -): Promise { +): Promise { try { await callSubagentGateway({ method: "sessions.delete", @@ -641,8 +758,28 @@ async function cleanupProvisionalSession( }, timeoutMs: SUBAGENT_CONTROL_GATEWAY_TIMEOUT_MS, }); + return true; } catch { // Best-effort cleanup only. + return false; + } +} + +async function waitForProvisionalSessionDeletion( + childSessionKey: string, + options?: { + emitLifecycleHooks?: boolean; + deleteTranscript?: boolean; + }, +): Promise { + for (;;) { + if (await cleanupProvisionalSession(childSessionKey, options)) { + return; + } + await new Promise((resolve) => { + const timer = setTimeout(resolve, process.env.OPENCLAW_TEST_FAST === "1" ? 1 : 1_000); + timer.unref?.(); + }); } } @@ -651,18 +788,56 @@ async function cleanupFailedSpawnBeforeAgentStart(params: { attachmentAbsDir?: string; emitLifecycleHooks?: boolean; deleteTranscript?: boolean; -}): Promise { + waitForSessionDeletion?: boolean; +}): Promise<{ attachmentsRemoved: boolean; sessionDeleted: boolean }> { + let attachmentsRemoved = true; if (params.attachmentAbsDir) { try { await fs.rm(params.attachmentAbsDir, { recursive: true, force: true }); } catch { - // Best-effort cleanup only. + attachmentsRemoved = false; } } - await cleanupProvisionalSession(params.childSessionKey, { + const sessionCleanupOptions = { emitLifecycleHooks: params.emitLifecycleHooks, deleteTranscript: params.deleteTranscript, - }); + }; + if (params.waitForSessionDeletion) { + await waitForProvisionalSessionDeletion(params.childSessionKey, sessionCleanupOptions); + return { attachmentsRemoved, sessionDeleted: true }; + } + return { + attachmentsRemoved, + sessionDeleted: await cleanupProvisionalSession(params.childSessionKey, sessionCleanupOptions), + }; +} + +async function terminateAcceptedCollectorRun(params: { + childSessionKey: string; + gatewayRunId: string; +}): Promise { + for (;;) { + try { + await callSubagentGateway({ + method: "chat.abort", + params: { sessionKey: params.childSessionKey, runId: params.gatewayRunId }, + timeoutMs: SUBAGENT_CONTROL_GATEWAY_TIMEOUT_MS, + }); + return; + } catch { + if ( + await cleanupProvisionalSession(params.childSessionKey, { + deleteTranscript: true, + }) + ) { + return; + } + } + await new Promise((resolve) => { + const timer = setTimeout(resolve, process.env.OPENCLAW_TEST_FAST === "1" ? 1 : 1_000); + timer.unref?.(); + }); + } } function resolveSubagentContextMode(params: { @@ -813,7 +988,7 @@ export async function spawnSubagentDirect( } const taskName = taskNameResult.taskName; const label = params.label?.trim() || ""; - const requestedAgentId = params.agentId?.trim(); + let requestedAgentId = params.agentId?.trim(); // Reject malformed agentId before normalizeAgentId can mangle it. // Without this gate, error-message strings like "Agent not found: xyz" pass @@ -833,6 +1008,12 @@ export async function spawnSubagentDirect( requestedMode: params.mode, threadRequested: requestThreadBinding, }); + if (params.collect && (requestThreadBinding || spawnMode === "session")) { + return { + status: "error", + error: "sessions_spawn collect=true requires mode=run and thread=false.", + }; + } if (spawnMode === "session" && !requestThreadBinding) { return { status: "error", @@ -847,7 +1028,9 @@ export async function spawnSubagentDirect( : params.cleanup === "keep" || params.cleanup === "delete" ? params.cleanup : "keep"; - const expectsCompletionMessage = params.expectsCompletionMessage !== false; + const expectsCompletionMessage = params.collect + ? false + : params.expectsCompletionMessage !== false; const hookRunner = subagentSpawnDeps.getGlobalHookRunner(); const cfg = loadSubagentConfig(); @@ -888,542 +1071,828 @@ export async function spawnSubagentDirect( const requesterAgentId = normalizeAgentId( ctx.requesterAgentIdOverride ?? parseAgentSessionKey(requesterInternalKey)?.agentId, ); + const swarmConfig = resolveSwarmConfig(cfg, requesterAgentId); + const hasSwarmParams = + params.collect !== undefined || + params.outputSchema !== undefined || + params.fastMode !== undefined || + params.groupId !== undefined; + if (hasSwarmParams && !swarmConfig.enabled) { + return { + status: "forbidden", + error: "sessions_spawn swarm parameters require tools.swarm.enabled=true.", + }; + } + if (params.outputSchema && !params.collect) { + return { status: "error", error: "sessions_spawn outputSchema requires collect=true." }; + } + if (params.groupId !== undefined && !params.collect) { + return { status: "error", error: "sessions_spawn groupId requires collect=true." }; + } + if (params.outputSchema) { + const schemaError = validateStructuredOutputSchema(params.outputSchema); + if (schemaError) { + return { status: "error", error: schemaError }; + } + } + + const usingDefaultAgentId = + params.collect === true && !requestedAgentId && Boolean(swarmConfig.defaultAgentId); + if (usingDefaultAgentId) { + requestedAgentId = swarmConfig.defaultAgentId; + if (!isValidAgentId(requestedAgentId)) { + return { + status: "error", + error: `tools.swarm.defaultAgentId contains invalid agentId "${requestedAgentId}".`, + }; + } + } const targetAgentId = requestedAgentId ? normalizeAgentId(requestedAgentId) : requesterAgentId; - const admission = resolveSpawnAdmission({ - cfg, - requesterSessionKey: requesterInternalKey, - requesterAgentId, - targetAgentId, - requestedAgentId, - configuredAgentIds: resolveConfiguredAgentIds(cfg), - }); + const configuredAgentIds = resolveConfiguredAgentIds(cfg); + const resolveAdmission = () => + resolveSpawnAdmission({ + cfg, + requesterSessionKey: requesterInternalKey, + requesterAgentId, + targetAgentId, + requestedAgentId, + configuredAgentIds, + }); + const admission = resolveAdmission(); if (!admission.ok) { - return { status: "forbidden", error: admission.error }; + return { + status: "forbidden", + error: usingDefaultAgentId + ? `tools.swarm.defaultAgentId is unavailable: ${admission.error}` + : admission.error, + }; } const childDepth = admission.childSessionPatch?.spawnDepth ?? 1; const maxSpawnDepth = admission.maxSpawnDepth ?? childDepth; - const requestedCwd = normalizeOptionalString(params.cwd); - const spawnedCwd = requestedCwd ? resolveUserPath(requestedCwd) : undefined; - const toolSpawnMetadata = mapToolContextToSpawnedRunMetadata({ - agentGroupId: ctx.agentGroupId, - agentGroupChannel: ctx.agentGroupChannel, - agentGroupSpace: ctx.agentGroupSpace, - workspaceDir: ctx.workspaceDir, - }); - const inheritedWorkspaceDir = - targetAgentId !== requesterAgentId ? undefined : toolSpawnMetadata.workspaceDir; - const spawnedWorkspaceDir = resolveSpawnedWorkspaceInheritance({ - config: cfg, - targetAgentId, - explicitWorkspaceDir: inheritedWorkspaceDir, - }); - const requesterOrigin = normalizeDeliveryContext({ - channel: ctx.agentChannel, - accountId: ctx.agentAccountId, - to: ctx.agentTo, - ...(ctx.agentThreadId != null && ctx.agentThreadId !== "" - ? { threadId: ctx.agentThreadId } - : {}), - }); - let childSessionOrigin = resolveRequesterOriginForChild({ - cfg, - targetAgentId, - requesterAgentId, - requesterChannel: ctx.agentChannel, - requesterAccountId: ctx.agentAccountId, - requesterTo: ctx.agentTo, - requesterThreadId: ctx.agentThreadId, - requesterGroupSpace: ctx.agentGroupSpace, - requesterMemberRoleIds: ctx.agentMemberRoleIds, - }); - const childSessionKey = mintSpawnSessionKey({ targetAgentId, backend: "subagent" }); - const requesterRuntime = resolveSandboxRuntimeStatus({ - cfg, - sessionKey: requesterInternalKey, - }); - const childRuntime = resolveSandboxRuntimeStatus({ - cfg, - sessionKey: childSessionKey, - }); - const sandboxError = resolveSpawnSandboxError({ - backend: "subagent", - requesterSandboxed: requesterRuntime.sandboxed, - childSandboxed: childRuntime.sandboxed, - sandbox: sandboxMode, - }); - if (sandboxError) { - return { status: "forbidden", error: sandboxError }; - } - const spawnedWorkspaceCwd = spawnedWorkspaceDir - ? resolveUserPath(spawnedWorkspaceDir) - : undefined; - if (childRuntime.sandboxed && spawnedCwd && spawnedCwd !== spawnedWorkspaceCwd) { - return { - status: "forbidden", - error: - "cwd override is not supported for sandboxed subagent runs; omit cwd or use the target agent workspace as cwd", - }; - } - const spawnedByKey = requesterInternalKey; - const targetAgentDir = resolveAgentDir(cfg, targetAgentId); - const requesterAgentConfig = resolveAgentConfig(cfg, requesterAgentId); - const targetAgentConfig = resolveAgentConfig(cfg, targetAgentId); - const callerThinkingRaw = readRequesterThinkingLevel({ - cfg, - requesterInternalKey, - requesterAgentId, - }); - const plan = resolveSubagentModelAndThinkingPlan({ - cfg, - targetAgentId, - requesterAgentConfig, - targetAgentConfig, - modelOverride, - thinkingOverrideRaw, - callerThinkingRaw, - }); - if (plan.status === "error") { + const explicitSwarmGroupId = normalizeOptionalString(params.groupId); + const requesterRunId = normalizeOptionalString(ctx.requesterRunId); + if (params.collect && !explicitSwarmGroupId && !requesterRunId) { return { status: "error", - error: plan.error, + error: "sessions_spawn collect=true requires a requesting run id when groupId is omitted.", }; } - const { resolvedModel, thinkingOverride } = plan; - const resolvedModelMetadata = buildResolvedSubagentModelMetadata(resolvedModel); - const patchChildSession = async (patch: Record): Promise => { - try { - const target = resolveGatewaySessionStoreTarget({ - cfg, - key: childSessionKey, - }); - await upsertSessionEntry( - { - storePath: target.storePath, - sessionKey: target.canonicalKey, - }, - buildDirectChildSessionPatch(patch), - ); + const swarmGroupId = params.collect + ? (explicitSwarmGroupId ?? `swarm:${requesterInternalKey}:${requesterRunId}`) + : undefined; + const swarmSchedulerGroupKey = swarmGroupId + ? JSON.stringify([requesterInternalKey, swarmGroupId]) + : undefined; + const resolveSwarmCapError = () => { + if (!swarmGroupId) { return undefined; - } catch (err) { - const message = err instanceof Error ? err.message : typeof err === "string" ? err : "error"; - return `child session patch failed: ${message}`; } + const groupRuns = listSwarmRunsForGroup(swarmGroupId, requesterInternalKey); + if (groupRuns.length >= swarmConfig.maxTotalPerGroup) { + return `sessions_spawn reached tools.swarm.maxTotalPerGroup (${groupRuns.length}/${swarmConfig.maxTotalPerGroup}).`; + } + const liveRuns = groupRuns.filter((entry) => !entry.collectorCompletion); + return liveRuns.length >= swarmConfig.maxChildrenPerGroup + ? `sessions_spawn reached tools.swarm.maxChildrenPerGroup (${liveRuns.length}/${swarmConfig.maxChildrenPerGroup}).` + : undefined; }; - - const initialChildSessionPatch: Record = { - ...admission.childSessionPatch, - ...inheritedToolAllowPatch(ctx.inheritedToolAllowlist), - ...inheritedToolDenyPatch(ctx.inheritedToolDenylist), - ...plan.initialSessionPatch, - }; - - const initialPatchError = await patchChildSession(initialChildSessionPatch); - if (initialPatchError) { - return { - status: "error", - error: initialPatchError, - childSessionKey, - }; + const initialSwarmCapError = resolveSwarmCapError(); + if (initialSwarmCapError) { + return { status: "forbidden", error: initialSwarmCapError }; } - const preparedSpawnContext = await prepareSubagentSessionContext({ - cfg, - contextMode, - requesterAgentId, - targetAgentId, - requesterInternalKey, - childSessionKey, - }); - if (preparedSpawnContext.status === "error") { - await cleanupProvisionalSession(childSessionKey, { - emitLifecycleHooks: false, - deleteTranscript: true, - }); - return { - status: "error", - error: preparedSpawnContext.error, - childSessionKey, - }; - } - if (resolvedModel) { - const runtimeModelPersistError = await persistInitialChildSessionRuntimeModel({ - cfg, - childSessionKey, - resolvedModel, - }); - if (runtimeModelPersistError) { - try { - await callSubagentGateway({ - method: "sessions.delete", - params: { key: childSessionKey, emitLifecycleHooks: false }, - timeoutMs: SUBAGENT_CONTROL_GATEWAY_TIMEOUT_MS, - }); - } catch { - // Best-effort cleanup only. - } - return { - status: "error", - error: runtimeModelPersistError, - childSessionKey, - }; - } - modelApplied = true; - } - if (requestThreadBinding) { - const bindResult = await bindThreadForSubagentSpawn({ - cfg, - childSessionKey, - agentId: targetAgentId, - label: label || undefined, - mode: spawnMode, - requesterSessionKey: ownership.threadBindingRequesterSessionKey, - requester: { - channel: childSessionOrigin?.channel, - accountId: childSessionOrigin?.accountId, - to: childSessionOrigin?.to, - threadId: childSessionOrigin?.threadId, - }, - }); - if (bindResult.status === "error") { - try { - await callSubagentGateway({ - method: "sessions.delete", - params: { key: childSessionKey, deleteTranscript: true, emitLifecycleHooks: false }, - timeoutMs: SUBAGENT_CONTROL_GATEWAY_TIMEOUT_MS, - }); - } catch { - // Best-effort cleanup only. - } - return { - status: "error", - error: bindResult.error, - childSessionKey, - }; - } - threadBindingReady = true; - hasBoundThreadDeliveryOrigin = hasRoutableDeliveryOrigin(bindResult.deliveryOrigin); - childSessionOrigin = - mergeDeliveryContext(bindResult.deliveryOrigin, childSessionOrigin) ?? childSessionOrigin; - } - const mountPathHint = sanitizeMountPathHint(params.attachMountPath); - - let childSystemPrompt = buildSubagentSystemPrompt({ - requesterSessionKey, - requesterOrigin: childSessionOrigin, - childSessionKey, - label: label || undefined, - task, - acpEnabled: isAcpRuntimeSpawnAvailable({ - config: cfg, - sandboxed: childRuntime.sandboxed, - }), - nativeCommandGuidanceLines: listRegisteredPluginAgentPromptGuidance({ - surface: "subagent", - }), - childDepth, - maxSpawnDepth, - }); - - let retainOnSessionKeep = false; - let attachmentsReceipt: - | { - count: number; - totalBytes: number; - files: SubagentAttachmentReceiptFile[]; - relDir: string; - } - | undefined; - let attachmentAbsDir: string | undefined; - let attachmentRootDir: string | undefined; - - const materializedAttachments = await materializeSubagentAttachments({ - config: cfg, - targetAgentId, - workspaceDir: spawnedCwd ?? spawnedWorkspaceDir, - attachments: params.attachments, - mountPathHint, - }); - if (materializedAttachments && materializedAttachments.status !== "ok") { - await cleanupProvisionalSession(childSessionKey, { - emitLifecycleHooks: threadBindingReady, - deleteTranscript: true, - }); - return { - status: materializedAttachments.status, - error: materializedAttachments.error, - }; - } - if (materializedAttachments?.status === "ok") { - retainOnSessionKeep = materializedAttachments.retainOnSessionKeep; - attachmentsReceipt = materializedAttachments.receipt; - attachmentAbsDir = materializedAttachments.absDir; - attachmentRootDir = materializedAttachments.rootDir; - childSystemPrompt = `${childSystemPrompt}\n\n${materializedAttachments.systemPromptSuffix}`; - } - - const bootstrapContextMode: BootstrapContextMode | undefined = params.lightContext - ? "lightweight" - : undefined; - - const childTaskMessage = buildSubagentInitialUserMessage({ - childDepth, - maxSpawnDepth, - persistentSession: spawnMode === "session", - task, - }); - const childIdem = crypto.randomUUID(); let childRunId: string = childIdem; - const spawnedMetadata = normalizeSpawnedRunMetadata({ - spawnedBy: spawnedByKey, - ...toolSpawnMetadata, - workspaceDir: spawnedWorkspaceDir, - }); - const spawnLineagePatchError = await patchChildSession({ - spawnedBy: spawnedByKey, - ...(spawnedMetadata.workspaceDir ? { spawnedWorkspaceDir: spawnedMetadata.workspaceDir } : {}), - ...(spawnedCwd ? { spawnedCwd } : {}), - }); - if (spawnLineagePatchError) { - await cleanupFailedSpawnBeforeAgentStart({ - childSessionKey, - attachmentAbsDir, - emitLifecycleHooks: threadBindingReady, - deleteTranscript: true, - }); - return { - status: "error", - error: spawnLineagePatchError, - childSessionKey, - }; + let swarmReservationPending = false; + if (params.collect && swarmGroupId && swarmSchedulerGroupKey) { + const groupRuns = listSwarmRunsForGroup(swarmGroupId, requesterInternalKey); + if ( + !reserveSwarmRun({ + groupId: swarmSchedulerGroupKey, + runId: childRunId, + maxConcurrent: swarmConfig.maxConcurrent, + activeRunIds: groupRuns + .filter((entry) => entry.execution?.status === "running") + .map((entry) => entry.schedulerSlotId ?? entry.runId), + }) + ) { + return { status: "error", error: "sessions_spawn could not reserve swarm FIFO order." }; + } + swarmReservationPending = true; } - recordSubagentSpawned({ - childSessionKey, - childRunId, - requesterSessionKey: requesterInternalKey, - agentId: targetAgentId, - }); - const deliverInitialChildRunDirectly = - requestThreadBinding && spawnMode === "session" && hasBoundThreadDeliveryOrigin; - const shouldAnnounceCompletion = deliverInitialChildRunDirectly - ? false - : expectsCompletionMessage; - const progressOrigin = { - channel: requesterOrigin?.channel, - accountId: requesterOrigin?.accountId, - to: ctx.currentMessagingTarget ?? requesterOrigin?.to, - threadId: requesterOrigin?.threadId, - channelId: ctx.currentChannelId, - messageId: ctx.currentMessageId, - }; - type SubagentBackendState = { contextEnginePreparation?: SubagentSpawnPreparation }; - const adapter: SpawnBackendAdapter = { - async initialize() { - const result = - params.lightContext && preparedSpawnContext.mode === "isolated" - ? ({ status: "ok", preparation: undefined } as const) - : await prepareContextEngineSubagentSpawn({ - cfg, - context: preparedSpawnContext, - requesterInternalKey, - childSessionKey, - runTimeoutSeconds, - }); - if (result.status === "error") { - throw new Error(result.error); - } - return { contextEnginePreparation: result.preparation }; - }, - async dispatchTurn() { - const { - spawnedBy: _spawnedBy, - workspaceDir: _workspaceDir, - ...publicSpawnedMetadata - } = spawnedMetadata; - const response = await callSubagentGateway({ - method: "agent", - params: { - message: childTaskMessage, - sessionKey: childSessionKey, - channel: childSessionOrigin?.channel, - to: childSessionOrigin?.to ?? undefined, - accountId: childSessionOrigin?.accountId ?? undefined, - threadId: - childSessionOrigin?.threadId != null - ? stringifyRouteThreadId(childSessionOrigin.threadId) - : undefined, - idempotencyKey: childIdem, - deliver: deliverInitialChildRunDirectly, - lane: AGENT_LANE_SUBAGENT, - disableMessageTool: true, - cleanupBundleMcpOnRunEnd: spawnMode !== "session", - extraSystemPrompt: childSystemPrompt, - thinking: thinkingOverride, - timeout: runTimeoutSeconds, - label: label || undefined, - ...(bootstrapContextMode - ? { - bootstrapContextMode, - bootstrapContextRunKind: "default" as const, - } - : {}), - ...publicSpawnedMetadata, - }, - timeoutMs: resolveSubagentAgentGatewayTimeoutMs(runTimeoutSeconds), + try { + const requestedCwd = normalizeOptionalString(params.cwd); + const spawnedCwd = requestedCwd ? resolveUserPath(requestedCwd) : undefined; + const toolSpawnMetadata = mapToolContextToSpawnedRunMetadata({ + agentGroupId: ctx.agentGroupId, + agentGroupChannel: ctx.agentGroupChannel, + agentGroupSpace: ctx.agentGroupSpace, + workspaceDir: ctx.workspaceDir, + }); + const inheritedWorkspaceDir = + targetAgentId !== requesterAgentId ? undefined : toolSpawnMetadata.workspaceDir; + const spawnedWorkspaceDir = resolveSpawnedWorkspaceInheritance({ + config: cfg, + targetAgentId, + explicitWorkspaceDir: inheritedWorkspaceDir, + }); + const requesterOrigin = normalizeDeliveryContext({ + channel: ctx.agentChannel, + accountId: ctx.agentAccountId, + to: ctx.agentTo, + ...(ctx.agentThreadId != null && ctx.agentThreadId !== "" + ? { threadId: ctx.agentThreadId } + : {}), + }); + let childSessionOrigin = resolveRequesterOriginForChild({ + cfg, + targetAgentId, + requesterAgentId, + requesterChannel: ctx.agentChannel, + requesterAccountId: ctx.agentAccountId, + requesterTo: ctx.agentTo, + requesterThreadId: ctx.agentThreadId, + requesterGroupSpace: ctx.agentGroupSpace, + requesterMemberRoleIds: ctx.agentMemberRoleIds, + }); + const childSessionKey = mintSpawnSessionKey({ targetAgentId, backend: "subagent" }); + const requesterRuntime = resolveSandboxRuntimeStatus({ + cfg, + sessionKey: requesterInternalKey, + }); + const childRuntime = resolveSandboxRuntimeStatus({ + cfg, + sessionKey: childSessionKey, + }); + const sandboxError = resolveSpawnSandboxError({ + backend: "subagent", + requesterSandboxed: requesterRuntime.sandboxed, + childSandboxed: childRuntime.sandboxed, + sandbox: sandboxMode, + }); + if (sandboxError) { + return { status: "forbidden", error: sandboxError }; + } + const spawnedWorkspaceCwd = spawnedWorkspaceDir + ? resolveUserPath(spawnedWorkspaceDir) + : undefined; + if (childRuntime.sandboxed && spawnedCwd && spawnedCwd !== spawnedWorkspaceCwd) { + return { + status: "forbidden", + error: + "cwd override is not supported for sandboxed subagent runs; omit cwd or use the target agent workspace as cwd", + }; + } + const spawnedByKey = requesterInternalKey; + const targetAgentDir = resolveAgentDir(cfg, targetAgentId); + const requesterAgentConfig = resolveAgentConfig(cfg, requesterAgentId); + const targetAgentConfig = resolveAgentConfig(cfg, targetAgentId); + const callerThinkingRaw = readRequesterThinkingLevel({ + cfg, + requesterInternalKey, + requesterAgentId, + }); + const inheritedFastMode = + swarmConfig.enabled && params.fastMode === undefined + ? readRequesterFastMode({ + cfg, + requesterInternalKey, + requesterAgentId, + }) + : params.fastMode; + const plan = resolveSubagentModelAndThinkingPlan({ + cfg, + targetAgentId, + requesterAgentConfig, + targetAgentConfig, + modelOverride, + thinkingOverrideRaw, + callerThinkingRaw, + fastMode: inheritedFastMode, + }); + if (plan.status === "error") { + return { + status: "error", + error: plan.error, + }; + } + const { resolvedModel, thinkingOverride } = plan; + if (params.outputSchema) { + const outputModelError = await resolveCollectorOutputModelError({ + cfg, + targetAgentId, + targetAgentDir, + workspaceDir: spawnedWorkspaceDir, + resolvedModel, }); - return { runId: readGatewayRunId(response) ?? childIdem }; - }, - async cleanupOnFailure({ phase, state }) { - if (phase === "initialize") { - await cleanupFailedSpawnBeforeAgentStart({ - childSessionKey, - attachmentAbsDir, - emitLifecycleHooks: threadBindingReady, - deleteTranscript: true, - }); - return; + if (outputModelError) { + return { status: "error", error: outputModelError, childSessionKey }; } - await rollbackPreparedContextEngine(state?.contextEnginePreparation); - if (attachmentAbsDir) { + } + const resolvedModelMetadata = buildResolvedSubagentModelMetadata(resolvedModel); + const patchChildSession = async ( + patch: Record, + ): Promise => { + try { + const target = resolveGatewaySessionStoreTarget({ + cfg, + key: childSessionKey, + }); + await upsertSessionEntry( + { + storePath: target.storePath, + sessionKey: target.canonicalKey, + }, + buildDirectChildSessionPatch(patch), + ); + return undefined; + } catch (err) { + const message = + err instanceof Error ? err.message : typeof err === "string" ? err : "error"; + return `child session patch failed: ${message}`; + } + }; + + const initialChildSessionPatch: Record = { + ...admission.childSessionPatch, + ...inheritedToolAllowPatch(ctx.inheritedToolAllowlist), + ...inheritedToolDenyPatch(ctx.inheritedToolDenylist), + ...plan.initialSessionPatch, + ...(swarmGroupId ? { swarmGroupId } : {}), + ...(params.collect ? { swarmCollector: true } : {}), + ...(params.outputSchema ? { swarmOutputSchema: params.outputSchema } : {}), + }; + + const initialPatchError = await patchChildSession(initialChildSessionPatch); + if (initialPatchError) { + return { + status: "error", + error: initialPatchError, + childSessionKey, + }; + } + const preparedSpawnContext = await prepareSubagentSessionContext({ + cfg, + contextMode, + requesterAgentId, + targetAgentId, + requesterInternalKey, + childSessionKey, + }); + if (preparedSpawnContext.status === "error") { + await cleanupProvisionalSession(childSessionKey, { + emitLifecycleHooks: false, + deleteTranscript: true, + }); + return { + status: "error", + error: preparedSpawnContext.error, + childSessionKey, + }; + } + if (resolvedModel) { + const runtimeModelPersistError = await persistInitialChildSessionRuntimeModel({ + cfg, + childSessionKey, + resolvedModel, + }); + if (runtimeModelPersistError) { try { - await fs.rm(attachmentAbsDir, { recursive: true, force: true }); + await callSubagentGateway({ + method: "sessions.delete", + params: { key: childSessionKey, emitLifecycleHooks: false }, + timeoutMs: SUBAGENT_CONTROL_GATEWAY_TIMEOUT_MS, + }); } catch { // Best-effort cleanup only. } + return { + status: "error", + error: runtimeModelPersistError, + childSessionKey, + }; } - let emitLifecycleHooks = threadBindingReady; - if (phase === "dispatch" && threadBindingReady) { - let endedHookEmitted = false; - if (hookRunner?.hasHooks("subagent_ended")) { - try { - await hookRunner.runSubagentEnded( - { - targetSessionKey: childSessionKey, - targetKind: "subagent", - reason: "spawn-failed", - sendFarewell: true, - accountId: childSessionOrigin?.accountId, - runId: childIdem, - outcome: "error", - error: "Session failed to start", - }, - { - runId: childIdem, - childSessionKey, - requesterSessionKey: requesterInternalKey, - }, - ); - endedHookEmitted = true; - } catch { - // Spawn cleanup continues even when presentation hooks fail. - } + modelApplied = true; + } + if (requestThreadBinding) { + const bindResult = await bindThreadForSubagentSpawn({ + cfg, + childSessionKey, + agentId: targetAgentId, + label: label || undefined, + mode: spawnMode, + requesterSessionKey: ownership.threadBindingRequesterSessionKey, + requester: { + channel: childSessionOrigin?.channel, + accountId: childSessionOrigin?.accountId, + to: childSessionOrigin?.to, + threadId: childSessionOrigin?.threadId, + }, + }); + if (bindResult.status === "error") { + try { + await callSubagentGateway({ + method: "sessions.delete", + params: { key: childSessionKey, deleteTranscript: true, emitLifecycleHooks: false }, + timeoutMs: SUBAGENT_CONTROL_GATEWAY_TIMEOUT_MS, + }); + } catch { + // Best-effort cleanup only. } - emitLifecycleHooks = !endedHookEmitted; + return { + status: "error", + error: bindResult.error, + childSessionKey, + }; } + threadBindingReady = true; + hasBoundThreadDeliveryOrigin = hasRoutableDeliveryOrigin(bindResult.deliveryOrigin); + childSessionOrigin = + mergeDeliveryContext(bindResult.deliveryOrigin, childSessionOrigin) ?? childSessionOrigin; + } + const mountPathHint = sanitizeMountPathHint(params.attachMountPath); + + let childSystemPrompt = buildSubagentSystemPrompt({ + requesterSessionKey, + requesterOrigin: childSessionOrigin, + childSessionKey, + label: label || undefined, + task, + acpEnabled: isAcpRuntimeSpawnAvailable({ + config: cfg, + sandboxed: childRuntime.sandboxed, + }), + nativeCommandGuidanceLines: listRegisteredPluginAgentPromptGuidance({ + surface: "subagent", + }), + childDepth, + maxSpawnDepth, + }); + if (params.outputSchema) { + childSystemPrompt = `${childSystemPrompt}\n\nCall structured_output with {"result": } until one payload is accepted, with at most one retry after a rejected attempt. The result value must match the requested JSON Schema. Do not call structured_output again after acceptance.`; + } + + let retainOnSessionKeep = false; + let attachmentsReceipt: + | { + count: number; + totalBytes: number; + files: SubagentAttachmentReceiptFile[]; + relDir: string; + } + | undefined; + let attachmentAbsDir: string | undefined; + let attachmentRootDir: string | undefined; + + const materializedAttachments = await materializeSubagentAttachments({ + config: cfg, + targetAgentId, + workspaceDir: spawnedCwd ?? spawnedWorkspaceDir, + attachments: params.attachments, + mountPathHint, + }); + if (materializedAttachments && materializedAttachments.status !== "ok") { await cleanupProvisionalSession(childSessionKey, { - emitLifecycleHooks, + emitLifecycleHooks: threadBindingReady, deleteTranscript: true, }); - }, - }; - const pipelineResult = await runSpawnPipeline({ - adapter, - hookRunner, - progressOrigin, - progressSessionKey: requesterInternalKey, - buildRegistration: (_state, runId) => ({ - runId, - requesterTurnRunId: ctx.requesterTurnRunId, - childSessionKey, - controllerSessionKey: ownership.controllerSessionKey, - requesterSessionKey: ownership.completionRequesterSessionKey, - requesterOrigin, - progressOrigin, - requesterDisplayKey: ownership.completionRequesterDisplayKey, - task, - taskName, - agentId: targetAgentId, - requesterAgentId, - cleanup, - label: label || undefined, - model: resolvedModel, - agentDir: targetAgentDir, - workspaceDir: spawnedMetadata.workspaceDir, - runTimeoutSeconds, - expectsCompletionMessage: shouldAnnounceCompletion, - spawnMode, - attachmentsDir: attachmentAbsDir, - attachmentsRootDir: attachmentRootDir, - retainAttachmentsOnKeep: retainOnSessionKeep, - }), - }); - if (!pipelineResult.ok) { - const runId = pipelineResult.runId ?? childIdem; - return { - status: "error", - error: - pipelineResult.phase === "register" - ? `Failed to register subagent run: ${summarizeSpawnError(pipelineResult.error)}` - : summarizeSpawnError(pipelineResult.error), - childSessionKey, - ...(pipelineResult.phase === "initialize" ? {} : { runId }), - }; - } - childRunId = pipelineResult.runId; + return { + status: materializedAttachments.status, + error: materializedAttachments.error, + }; + } + if (materializedAttachments?.status === "ok") { + retainOnSessionKeep = materializedAttachments.retainOnSessionKeep; + attachmentsReceipt = materializedAttachments.receipt; + attachmentAbsDir = materializedAttachments.absDir; + attachmentRootDir = materializedAttachments.rootDir; + childSystemPrompt = `${childSystemPrompt}\n\n${materializedAttachments.systemPromptSuffix}`; + } - if (hookRunner?.hasHooks("subagent_spawned")) { - try { - await hookRunner.runSubagentSpawned( - { - runId: childRunId, + const bootstrapContextMode: BootstrapContextMode | undefined = params.lightContext + ? "lightweight" + : undefined; + + const childTaskMessage = buildSubagentInitialUserMessage({ + childDepth, + maxSpawnDepth, + persistentSession: spawnMode === "session", + task, + }); + + const spawnedMetadata = normalizeSpawnedRunMetadata({ + spawnedBy: spawnedByKey, + ...toolSpawnMetadata, + workspaceDir: spawnedWorkspaceDir, + }); + const spawnLineagePatchError = await patchChildSession({ + spawnedBy: spawnedByKey, + ...(spawnedMetadata.workspaceDir + ? { spawnedWorkspaceDir: spawnedMetadata.workspaceDir } + : {}), + ...(spawnedCwd ? { spawnedCwd } : {}), + }); + if (spawnLineagePatchError) { + await cleanupFailedSpawnBeforeAgentStart({ + childSessionKey, + attachmentAbsDir, + emitLifecycleHooks: threadBindingReady, + deleteTranscript: true, + }); + return { + status: "error", + error: spawnLineagePatchError, + childSessionKey, + }; + } + recordSubagentSpawned({ + childSessionKey, + childRunId, + requesterSessionKey: requesterInternalKey, + agentId: targetAgentId, + }); + const deliverInitialChildRunDirectly = + requestThreadBinding && spawnMode === "session" && hasBoundThreadDeliveryOrigin; + const shouldAnnounceCompletion = deliverInitialChildRunDirectly + ? false + : expectsCompletionMessage; + const progressOrigin = { + channel: requesterOrigin?.channel, + accountId: requesterOrigin?.accountId, + to: ctx.currentMessagingTarget ?? requesterOrigin?.to, + threadId: requesterOrigin?.threadId, + channelId: ctx.currentChannelId, + messageId: ctx.currentMessageId, + }; + const { + spawnedBy: _spawnedBy, + workspaceDir: _workspaceDir, + ...publicSpawnedMetadata + } = spawnedMetadata; + const childLaunchRequest: Record = { + message: childTaskMessage, + sessionKey: childSessionKey, + ...(params.collect + ? {} + : { + channel: childSessionOrigin?.channel, + to: childSessionOrigin?.to ?? undefined, + accountId: childSessionOrigin?.accountId ?? undefined, + threadId: + childSessionOrigin?.threadId != null + ? stringifyRouteThreadId(childSessionOrigin.threadId) + : undefined, + }), + idempotencyKey: childIdem, + deliver: deliverInitialChildRunDirectly, + lane: AGENT_LANE_SUBAGENT, + disableMessageTool: true, + swarmCollector: params.collect === true, + swarmOutputSchema: params.outputSchema, + cleanupBundleMcpOnRunEnd: spawnMode !== "session", + extraSystemPrompt: childSystemPrompt, + thinking: thinkingOverride, + timeout: runTimeoutSeconds, + label: label || undefined, + ...(bootstrapContextMode + ? { + bootstrapContextMode, + bootstrapContextRunKind: "default" as const, + } + : {}), + ...publicSpawnedMetadata, + }; + const launchChildRun = async () => + await callSubagentGateway({ + method: "agent", + params: childLaunchRequest, + timeoutMs: resolveSubagentAgentGatewayTimeoutMs(runTimeoutSeconds), + }); + + // "spawned"/"started" hooks mean an accepted Gateway run. Direct runs emit + // after the shared pipeline; queued collectors emit from the scheduler start. + const emitSpawnLifecycleHooks = async (hookRunId: string) => { + if (hookRunner?.hasHooks("subagent_progress")) { + try { + await hookRunner.runSubagentProgress( + { + phase: "started", + runId: hookRunId, + childSessionKey, + requester: progressOrigin, + }, + { + runId: hookRunId, + childSessionKey, + requesterSessionKey: requesterInternalKey, + }, + ); + } catch { + // Presentation hooks are best-effort after durable registration. + } + } + if (hookRunner?.hasHooks("subagent_spawned")) { + try { + await hookRunner.runSubagentSpawned( + { + runId: hookRunId, + childSessionKey, + agentId: targetAgentId, + label: label || undefined, + requester: { + channel: requesterOrigin?.channel, + accountId: requesterOrigin?.accountId, + to: requesterOrigin?.to, + threadId: requesterOrigin?.threadId, + }, + threadRequested: requestThreadBinding, + mode: spawnMode, + ...resolvedModelMetadata, + }, + { + runId: hookRunId, + childSessionKey, + requesterSessionKey: requesterInternalKey, + }, + ); + } catch { + // Spawn stays accepted if lifecycle presentation fails. + } + } + }; + type SubagentBackendState = { contextEnginePreparation?: SubagentSpawnPreparation }; + const adapter: SpawnBackendAdapter = { + async initialize() { + const result = + params.lightContext && preparedSpawnContext.mode === "isolated" + ? ({ status: "ok", preparation: undefined } as const) + : await prepareContextEngineSubagentSpawn({ + cfg, + context: preparedSpawnContext, + requesterInternalKey, + childSessionKey, + runTimeoutSeconds, + }); + if (result.status === "error") { + throw new Error(result.error); + } + return { contextEnginePreparation: result.preparation }; + }, + async dispatchTurn() { + if (params.collect) { + return { runId: childIdem }; + } + const response = await launchChildRun(); + return { runId: readGatewayRunId(response) ?? childIdem }; + }, + async cleanupOnFailure({ phase, state }) { + if (phase === "initialize") { + await cleanupFailedSpawnBeforeAgentStart({ + childSessionKey, + attachmentAbsDir, + emitLifecycleHooks: threadBindingReady, + deleteTranscript: true, + }); + return; + } + await rollbackPreparedContextEngine(state?.contextEnginePreparation); + if (attachmentAbsDir) { + try { + await fs.rm(attachmentAbsDir, { recursive: true, force: true }); + } catch { + // Best-effort cleanup only. + } + } + let emitLifecycleHooks = threadBindingReady; + if (phase === "dispatch" && threadBindingReady) { + let endedHookEmitted = false; + if (hookRunner?.hasHooks("subagent_ended")) { + try { + await hookRunner.runSubagentEnded( + { + targetSessionKey: childSessionKey, + targetKind: "subagent", + reason: "spawn-failed", + sendFarewell: true, + accountId: childSessionOrigin?.accountId, + runId: childIdem, + outcome: "error", + error: "Session failed to start", + }, + { + runId: childIdem, + childSessionKey, + requesterSessionKey: requesterInternalKey, + }, + ); + endedHookEmitted = true; + } catch { + // Spawn cleanup continues even when presentation hooks fail. + } + } + emitLifecycleHooks = !endedHookEmitted; + } + await cleanupProvisionalSession(childSessionKey, { + emitLifecycleHooks, + deleteTranscript: true, + }); + }, + }; + const pipelineResult = await runSpawnPipeline({ + adapter, + progressOrigin, + progressSessionKey: requesterInternalKey, + buildRegistration: (_state, runId) => { + if (params.collect) { + const latestAdmission = resolveAdmission(); + const latestCapError = + (latestAdmission.ok ? undefined : latestAdmission.error) ?? resolveSwarmCapError(); + if (latestCapError) { + throw Object.assign(new Error(latestCapError), { spawnStatus: "forbidden" as const }); + } + } + return { + runId, + requesterTurnRunId: ctx.requesterTurnRunId, childSessionKey, + controllerSessionKey: ownership.controllerSessionKey, + requesterSessionKey: ownership.completionRequesterSessionKey, + requesterOrigin, + progressOrigin, + requesterDisplayKey: ownership.completionRequesterDisplayKey, + task, + taskName, agentId: targetAgentId, + requesterAgentId, + cleanup, label: label || undefined, - requester: { - channel: requesterOrigin?.channel, - accountId: requesterOrigin?.accountId, - to: requesterOrigin?.to, - threadId: requesterOrigin?.threadId, - }, - threadRequested: requestThreadBinding, - mode: spawnMode, - ...resolvedModelMetadata, + model: resolvedModel, + agentDir: targetAgentDir, + workspaceDir: spawnedMetadata.workspaceDir, + runTimeoutSeconds, + expectsCompletionMessage: shouldAnnounceCompletion, + spawnMode, + collect: params.collect === true, + swarmRequesterSessionKey: params.collect ? requesterInternalKey : undefined, + swarmLaunchIdempotencyKey: params.collect ? childIdem : undefined, + outputSchema: params.outputSchema, + groupId: swarmGroupId, + queuedLaunch: + params.collect && swarmSchedulerGroupKey + ? { + request: childLaunchRequest, + timeoutMs: resolveSubagentAgentGatewayTimeoutMs(runTimeoutSeconds), + schedulerGroupKey: swarmSchedulerGroupKey, + maxConcurrent: swarmConfig.maxConcurrent, + } + : undefined, + queued: params.collect === true, + attachmentsDir: attachmentAbsDir, + attachmentsRootDir: attachmentRootDir, + retainAttachmentsOnKeep: retainOnSessionKeep, + }; + }, + }); + if (!pipelineResult.ok) { + const runId = pipelineResult.runId ?? childIdem; + const spawnStatus = + pipelineResult.error && typeof pipelineResult.error === "object" + ? (pipelineResult.error as { spawnStatus?: unknown }).spawnStatus + : undefined; + return { + status: spawnStatus === "forbidden" ? "forbidden" : "error", + error: + pipelineResult.phase === "register" && spawnStatus !== "forbidden" + ? `Failed to register subagent run: ${summarizeSpawnError(pipelineResult.error)}` + : summarizeSpawnError(pipelineResult.error), + childSessionKey, + ...(pipelineResult.phase === "initialize" ? {} : { runId }), + }; + } + childRunId = pipelineResult.runId; + if (params.collect && swarmGroupId && swarmSchedulerGroupKey) { + let launchTerminationConfirmed = false; + activateSwarmRun({ + groupId: swarmSchedulerGroupKey, + runId: childRunId, + start: async () => { + const response = await launchChildRun(); + const gatewayRunId = readGatewayRunId(response) ?? childRunId; + try { + if (!startQueuedSubagentRun(childRunId, gatewayRunId)) { + throw new Error("collector registry row could not transition from queued to running"); + } + } catch (error) { + await terminateAcceptedCollectorRun({ childSessionKey, gatewayRunId }); + launchTerminationConfirmed = true; + throw error; + } + await emitSpawnLifecycleHooks(gatewayRunId); }, - { - runId: childRunId, - childSessionKey, - requesterSessionKey: requesterInternalKey, + onStartFailure: async (error) => { + const launchError = summarizeError(error); + const [contextRollback, sessionCleanup] = await Promise.allSettled([ + rollbackPreparedContextEngine(pipelineResult.state.contextEnginePreparation), + cleanupFailedSpawnBeforeAgentStart({ + childSessionKey, + attachmentAbsDir, + emitLifecycleHooks: threadBindingReady, + deleteTranscript: true, + // A launch RPC can fail after acceptance. Keep the FIFO slot until + // deleting the child session proves no accepted run remains active. + waitForSessionDeletion: !launchTerminationConfirmed, + }), + ]); + for (;;) { + try { + settleFailedQueuedSubagentLaunch(childRunId, launchError); + break; + } catch { + // The child is stopped; retry only the durable terminal write. + await new Promise((resolve) => { + const timer = setTimeout( + resolve, + process.env.OPENCLAW_TEST_FAST === "1" ? 1 : 1_000, + ); + timer.unref?.(); + }); + } + } + const cleanupComplete = + contextRollback.status === "fulfilled" && + contextRollback.value && + sessionCleanup.status === "fulfilled" && + sessionCleanup.value.attachmentsRemoved && + sessionCleanup.value.sessionDeleted; + if (cleanupComplete) { + emitSessionLifecycleEvent({ + sessionKey: childSessionKey, + reason: "delete", + parentSessionKey: requesterInternalKey, + }); + completeCollectorLaunchCleanup(childRunId); + } + return true; }, - ); - } catch { - // Spawn should still return accepted if spawn lifecycle hooks fail. + }); + swarmReservationPending = false; + emitSessionLifecycleEvent({ + sessionKey: childSessionKey, + reason: "create", + parentSessionKey: requesterInternalKey, + label: label || undefined, + }); + const acceptedNote = resolveSubagentSpawnAcceptedNote({ + spawnMode, + agentSessionKey: ctx.agentSessionKey, + }); + return { + status: "accepted", + childSessionKey, + sessionKey: childSessionKey, + runId: childRunId, + mode: spawnMode, + taskName, + note: preparedSpawnContext.forkFallbackNote + ? `${acceptedNote} ${preparedSpawnContext.forkFallbackNote}` + : acceptedNote, + ...resolvedModelMetadata, + modelApplied: resolvedModel ? modelApplied : undefined, + attachments: attachmentsReceipt, + }; + } + + await emitSpawnLifecycleHooks(childRunId); + + // Emit lifecycle event so the gateway can broadcast sessions.changed to SSE subscribers. + emitSessionLifecycleEvent({ + sessionKey: childSessionKey, + reason: "create", + parentSessionKey: requesterInternalKey, + label: label || undefined, + }); + + const acceptedNote = resolveSubagentSpawnAcceptedNote({ + spawnMode, + agentSessionKey: ctx.agentSessionKey, + }); + return { + status: "accepted", + childSessionKey, + runId: childRunId, + mode: spawnMode, + taskName, + note: preparedSpawnContext.forkFallbackNote + ? `${acceptedNote} ${preparedSpawnContext.forkFallbackNote}` + : acceptedNote, + ...resolvedModelMetadata, + modelApplied: resolvedModel ? modelApplied : undefined, + attachments: attachmentsReceipt, + }; + } finally { + if (swarmReservationPending) { + removeQueuedSwarmRun(childRunId); } } - - // Emit lifecycle event so the gateway can broadcast sessions.changed to SSE subscribers. - emitSessionLifecycleEvent({ - sessionKey: childSessionKey, - reason: "create", - parentSessionKey: requesterInternalKey, - label: label || undefined, - }); - - const acceptedNote = resolveSubagentSpawnAcceptedNote({ - spawnMode, - agentSessionKey: ctx.agentSessionKey, - }); - return { - status: "accepted", - childSessionKey, - runId: childRunId, - mode: spawnMode, - taskName, - note: preparedSpawnContext.forkFallbackNote - ? `${acceptedNote} ${preparedSpawnContext.forkFallbackNote}` - : acceptedNote, - ...resolvedModelMetadata, - modelApplied: resolvedModel ? modelApplied : undefined, - attachments: attachmentsReceipt, - }; } const testing = { diff --git a/src/agents/swarm-collector.ts b/src/agents/swarm-collector.ts new file mode 100644 index 000000000000..fa1abb1c3271 --- /dev/null +++ b/src/agents/swarm-collector.ts @@ -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; +} diff --git a/src/agents/swarm-config.test.ts b/src/agents/swarm-config.test.ts new file mode 100644 index 000000000000..574ad7fff06a --- /dev/null +++ b/src/agents/swarm-config.test.ts @@ -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); + }); +}); diff --git a/src/agents/swarm-config.ts b/src/agents/swarm-config.ts new file mode 100644 index 000000000000..7675ab8255c7 --- /dev/null +++ b/src/agents/swarm-config.ts @@ -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 | 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() : "", + }; +} diff --git a/src/agents/swarm-output-schema.ts b/src/agents/swarm-output-schema.ts new file mode 100644 index 000000000000..0c2428fe572a --- /dev/null +++ b/src/agents/swarm-output-schema.ts @@ -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 | 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)}`; + } +} diff --git a/src/agents/swarm-scheduler.test-support.ts b/src/agents/swarm-scheduler.test-support.ts new file mode 100644 index 000000000000..7a53c633ee6c --- /dev/null +++ b/src/agents/swarm-scheduler.test-support.ts @@ -0,0 +1,15 @@ +import "./swarm-scheduler.js"; + +type SwarmSchedulerTestApi = { + testing: { + reset(): void; + }; +}; + +function getTestApi(): SwarmSchedulerTestApi { + return (globalThis as Record)[ + Symbol.for("openclaw.swarmSchedulerTestApi") + ] as SwarmSchedulerTestApi; +} + +export const testing = getTestApi().testing; diff --git a/src/agents/swarm-scheduler.test.ts b/src/agents/swarm-scheduler.test.ts new file mode 100644 index 000000000000..ad61f56b06bf --- /dev/null +++ b/src/agents/swarm-scheduler.test.ts @@ -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((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"])); + }); +}); diff --git a/src/agents/swarm-scheduler.ts b/src/agents/swarm-scheduler.ts new file mode 100644 index 000000000000..1bead495d962 --- /dev/null +++ b/src/agents/swarm-scheduler.ts @@ -0,0 +1,190 @@ +type QueuedSwarmRun = { + runId: string; + start?: () => Promise; + /** True once failure is durable or the row no longer owns queued work. */ + onStartFailure?: (error: unknown) => boolean | Promise; + ready: boolean; + retryReady: boolean; +}; + +type SwarmGroupLane = { + limit: number; + active: Set; + queue: QueuedSwarmRun[]; +}; + +const lanes = new Map(); + +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(), + 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; + onStartFailure: (error: unknown) => boolean | Promise; +}): "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; + onStartFailure: (error: unknown) => boolean | Promise; +}): "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)[Symbol.for("openclaw.swarmSchedulerTestApi")] = { + testing, + }; +} diff --git a/src/agents/tool-catalog.test.ts b/src/agents/tool-catalog.test.ts index a2652c3389de..d2e8707f5247 100644 --- a/src/agents/tool-catalog.test.ts +++ b/src/agents/tool-catalog.test.ts @@ -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[0]) { const policy = resolveCoreToolProfilePolicy(profile); @@ -22,6 +22,14 @@ function requirePolicyAllow(profile: Parameters { + it("lists agents_wait only for a Swarm-enabled catalog", () => { + const ids = (config?: Parameters[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", diff --git a/src/agents/tool-catalog.ts b/src/agents/tool-catalog.ts index b61ba842333f..e4ee59951b30 100644 --- a/src/agents/tool-catalog.ts +++ b/src/agents/tool-catalog.ts @@ -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, diff --git a/src/agents/tool-description-presets.ts b/src/agents/tool-description-presets.ts index f7e7c3211841..2126ceb0e95e 100644 --- a/src/agents/tool-description-presets.ts +++ b/src/agents/tool-description-presets.ts @@ -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."; diff --git a/src/agents/tool-display-config.ts b/src/agents/tool-display-config.ts index 75e47b6fa524..2c2e419633f1 100644 --- a/src/agents/tool-display-config.ts +++ b/src/agents/tool-display-config.ts @@ -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"] }, }, }; diff --git a/src/agents/tools/agents-wait-tool.test-support.ts b/src/agents/tools/agents-wait-tool.test-support.ts new file mode 100644 index 000000000000..e7d494631e56 --- /dev/null +++ b/src/agents/tools/agents-wait-tool.test-support.ts @@ -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): boolean; + readResolvedWaitState(targets: readonly WaitTarget[], errors: readonly WaitError[]): unknown; + readWaitState(ids: readonly string[], currentSessionKeys: ReadonlySet): unknown; + resolveWaitTargets( + ids: readonly string[], + currentSessionKeys: ReadonlySet, + ): { targets: WaitTarget[]; errors: WaitError[] }; + waitForCollector(params: { + ids: readonly string[]; + currentSessionKeys: ReadonlySet; + timeoutMs: number; + signal?: AbortSignal; + }): Promise; + }; +}; + +function getTestApi(): AgentsWaitToolTestApi { + return (globalThis as Record)[ + Symbol.for("openclaw.agentsWaitToolTestApi") + ] as AgentsWaitToolTestApi; +} + +export const testing = getTestApi().testing; diff --git a/src/agents/tools/agents-wait-tool.test.ts b/src/agents/tools/agents-wait-tool.test.ts new file mode 100644 index 000000000000..1e865716340c --- /dev/null +++ b/src/agents/tools/agents-wait-tool.test.ts @@ -0,0 +1,259 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { SubagentRunRecord } from "../subagent-registry.types.js"; + +const records = new Map(); + +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"); + }); +}); diff --git a/src/agents/tools/agents-wait-tool.ts b/src/agents/tools/agents-wait-tool.ts new file mode 100644 index 000000000000..7f3b425c76bc --- /dev/null +++ b/src/agents/tools/agents-wait-tool.ts @@ -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): 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) { + 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>; + 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) { + const resolved = resolveWaitTargets(ids, currentSessionKeys); + return readResolvedWaitState(resolved.targets, resolved.errors); +} + +async function waitForCollector(params: { + ids: readonly string[]; + currentSessionKeys: ReadonlySet; + 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((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)[Symbol.for("openclaw.agentsWaitToolTestApi")] = { + testing, + }; +} diff --git a/src/agents/tools/sessions-spawn-tool.test.ts b/src/agents/tools/sessions-spawn-tool.test.ts index 0b30371b1d30..60b913b79bde 100644 --- a/src/agents/tools/sessions-spawn-tool.test.ts +++ b/src/agents/tools/sessions-spawn-tool.test.ts @@ -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 }; + + 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 }; + 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 { diff --git a/src/agents/tools/sessions-spawn-tool.ts b/src/agents/tools/sessions-spawn-tool.ts index 7d80469bd3db..332537eecb2a 100644 --- a/src/agents/tools/sessions-spawn-tool.ts +++ b/src/agents/tools/sessions-spawn-tool.ts @@ -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; + 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) + : 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, }, ); diff --git a/src/agents/tools/structured-output-tool.test-support.ts b/src/agents/tools/structured-output-tool.test-support.ts new file mode 100644 index 000000000000..620a1a8d2171 --- /dev/null +++ b/src/agents/tools/structured-output-tool.test-support.ts @@ -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)[ + Symbol.for("openclaw.structuredOutputToolTestApi") + ] as StructuredOutputToolTestApi; +} + +export const testing = getTestApi().testing; diff --git a/src/agents/tools/structured-output-tool.test.ts b/src/agents/tools/structured-output-tool.test.ts new file mode 100644 index 000000000000..4c0fbb91bae0 --- /dev/null +++ b/src/agents/tools/structured-output-tool.test.ts @@ -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; + 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); + }); +}); diff --git a/src/agents/tools/structured-output-tool.ts b/src/agents/tools/structured-output-tool.ts new file mode 100644 index 000000000000..6d6bd446caaa --- /dev/null +++ b/src/agents/tools/structured-output-tool.ts @@ -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(); + +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; + 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; + 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)[Symbol.for("openclaw.structuredOutputToolTestApi")] = + { testing }; +} diff --git a/src/agents/tools/swarm-tools.integration.test.ts b/src/agents/tools/swarm-tools.integration.test.ts new file mode 100644 index 000000000000..f6bb074f763d --- /dev/null +++ b/src/agents/tools/swarm-tools.integration.test.ts @@ -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 { + if (!value || typeof value !== "object") { + return {}; + } + const params = (value as { params?: unknown }).params; + return params && typeof params === "object" ? (params as Record) : {}; +} + +describe("swarm tools integration", () => { + const completionResolvers = new Map 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(); + const resultTextBySession = new Map(); + 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; + 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((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(); + } + }); + }); +}); diff --git a/src/config/schema.help.runtime.ts b/src/config/schema.help.runtime.ts index cf7e923008d7..5f049bd028df 100644 --- a/src/config/schema.help.runtime.ts +++ b/src/config/schema.help.runtime.ts @@ -163,6 +163,15 @@ export const RUNTIME_FIELD_HELP: Record = { "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 = { "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": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 4f466ecb9fef..4ce8c9ee02a7 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -257,6 +257,7 @@ export const FIELD_LABELS: Record = { "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 = { "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", diff --git a/src/config/schema.test.ts b/src/config/schema.test.ts index def143bb9ae7..714ac378b43e 100644 --- a/src/config/schema.test.ts +++ b/src/config/schema.test.ts @@ -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: { diff --git a/src/config/sessions/types.ts b/src/config/sessions/types.ts index 3de137e2d8c2..184c7b99cc65 100644 --- a/src/config/sessions/types.ts +++ b/src/config/sessions/types.ts @@ -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; verboseLevel?: string; traceLevel?: string; reasoningLevel?: string; diff --git a/src/config/types.tools.ts b/src/config/types.tools.ts index b9d2bd8c3dee..a3f37beb1921 100644 --- a/src/config/types.tools.ts +++ b/src/config/types.tools.ts @@ -243,6 +243,23 @@ export type CodeModeConfig = maxSearchLimit?: number; }; +export type SwarmConfig = + | boolean + | { + /** Enable collector-mode subagents and agents_wait. Default: false. */ + enabled?: boolean; + /** Maximum concurrently running collector children per swarm group. */ + maxConcurrent?: number; + /** Maximum live collector children per swarm group. */ + maxChildrenPerGroup?: number; + /** Maximum lifetime collector spawns per swarm group. */ + maxTotalPerGroup?: number; + /** Maximum agents_wait timeout in seconds. */ + waitTimeoutSecondsMax?: number; + /** Default child agent id when sessions_spawn omits agentId. */ + defaultAgentId?: string; + }; + export type SessionsToolsVisibility = "self" | "tree" | "agent" | "all"; export type ToolPolicyConfig = { @@ -408,6 +425,8 @@ export type AgentToolsConfig = { toolsBySender?: GroupToolPolicyBySenderConfig; /** Per-agent code mode override; merges over the top-level tools.codeMode config. */ codeMode?: CodeModeConfig; + /** Per-agent swarm override; merges over the top-level tools.swarm config. */ + swarm?: SwarmConfig; /** Per-agent elevated exec gate (can only further restrict global tools.elevated). */ elevated?: { /** Enable or disable elevated mode for this agent (default: true). */ @@ -727,6 +746,8 @@ export type ToolsConfig = { toolSearch?: ToolSearchConfig; /** Generic code mode: expose exec/wait and hide normal tools behind a QuickJS catalog bridge. */ codeMode?: CodeModeConfig; + /** Collector-mode subagents and wait controls. */ + swarm?: SwarmConfig; /** sessions_spawn tool configuration. */ sessions_spawn?: SessionsSpawnToolsConfig; /** Sub-agent tool policy defaults (deny wins). */ diff --git a/src/config/zod-schema.agent-defaults.test.ts b/src/config/zod-schema.agent-defaults.test.ts index 5bd085057bb5..866d9c39354b 100644 --- a/src/config/zod-schema.agent-defaults.test.ts +++ b/src/config/zod-schema.agent-defaults.test.ts @@ -519,6 +519,17 @@ describe("agent defaults schema", () => { ); }); + it("accepts per-agent tools.swarm config", () => { + expectSchemaSuccess( + AgentEntrySchema.safeParse({ id: "ops", tools: { swarm: { enabled: true } } }), + ); + expectSchemaSuccess(AgentEntrySchema.safeParse({ id: "ops", tools: { swarm: true } })); + expectSchemaFailurePath( + AgentEntrySchema.safeParse({ id: "ops", tools: { swarm: { unknownKey: 1 } } }), + "tools.swarm", + ); + }); + it("rejects non-positive contextTokens on agent entries and defaults", () => { expectSchemaFailurePath( AgentEntrySchema.safeParse({ id: "ops", contextTokens: 0 }), diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index b0f59420e574..df8f828c9fce 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -707,6 +707,22 @@ const CodeModeSchema = z ]) .optional(); +const SwarmSchema = z + .union([ + z.boolean(), + z + .object({ + enabled: z.boolean().optional(), + maxConcurrent: z.number().int().positive().optional(), + maxChildrenPerGroup: z.number().int().positive().optional(), + maxTotalPerGroup: z.number().int().positive().optional(), + waitTimeoutSecondsMax: z.number().int().positive().optional(), + defaultAgentId: z.string().optional(), + }) + .strict(), + ]) + .optional(); + const SandboxSshSchema = z .object({ target: z.string().min(1).optional(), @@ -802,6 +818,7 @@ const AgentToolsSchema = z .object({ ...CommonToolPolicyFields, codeMode: CodeModeSchema, + swarm: SwarmSchema, elevated: z .object({ enabled: z.boolean().optional(), @@ -1111,6 +1128,7 @@ export const ToolsSchema = z loopDetection: ToolLoopDetectionSchema, toolSearch: ToolSearchSchema, codeMode: CodeModeSchema, + swarm: SwarmSchema, message: MessageToolConfigSchema, agentToAgent: z .object({ diff --git a/src/gateway/server-methods/agent-request-preflight.test.ts b/src/gateway/server-methods/agent-request-preflight.test.ts new file mode 100644 index 000000000000..a56b9b28bcf4 --- /dev/null +++ b/src/gateway/server-methods/agent-request-preflight.test.ts @@ -0,0 +1,352 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { subagentRuns } from "../../agents/subagent-registry-memory.js"; +import * as sessionAccessor from "../../config/sessions/session-accessor.js"; +import { prepareAgentRequestPreflight } from "./agent-request-preflight.js"; + +function runPreflight( + swarmOutputSchema?: Record, + swarmCollector = true, + options?: { + enabled?: boolean; + requesterOnlyEnabled?: boolean; + backend?: boolean; + register?: boolean; + requesterAgentId?: string; + requesterSessionKey?: string; + idempotencyKey?: string; + includeCollectorFields?: boolean; + launchPending?: boolean; + cached?: boolean; + completed?: boolean; + ended?: boolean; + }, +) { + const sessionKey = "agent:worker:subagent:collector"; + if (options?.register) { + subagentRuns.set("collector-run", { + runId: "collector-run", + childSessionKey: sessionKey, + requesterSessionKey: options.requesterSessionKey ?? "agent:main:main", + requesterDisplayKey: "main", + requesterAgentId: options.requesterAgentId, + task: "collect", + cleanup: "keep", + createdAt: 1, + collect: true, + outputSchema: swarmOutputSchema, + swarmLaunchIdempotencyKey: "collector-run", + swarmLaunchPending: options?.launchPending ?? true, + execution: { status: options?.launchPending === false ? "running" : "queued" }, + collectorCompletion: options?.completed ? { status: "done" } : undefined, + endedAt: options?.ended ? 2 : undefined, + }); + } + const respond = vi.fn(); + const result = prepareAgentRequestPreflight({ + params: { + message: "collect", + sessionKey, + idempotencyKey: options?.idempotencyKey ?? "collector-run", + lane: "subagent", + ...(options?.includeCollectorFields === false ? {} : { swarmCollector, swarmOutputSchema }), + }, + respond, + context: { + getRuntimeConfig: () => + options?.requesterOnlyEnabled + ? { + agents: { + list: [{ id: "main", tools: { swarm: true } }, { id: "worker" }], + }, + } + : options?.enabled + ? { tools: { swarm: true } } + : {}, + dedupe: options?.cached + ? new Map([ + [ + "agent:collector-run", + { + ts: 1, + ok: true, + payload: { status: "accepted", runId: "gateway-run", sessionKey }, + }, + ], + ]) + : new Map(), + }, + client: options?.backend + ? { connect: { client: { mode: "backend" }, scopes: ["operator.write"] } } + : undefined, + } as never); + return { respond, result }; +} + +describe("agent request Swarm preflight", () => { + beforeEach(() => { + subagentRuns.clear(); + vi.spyOn(sessionAccessor, "loadSessionEntry").mockReturnValue(undefined); + }); + + it("rejects malformed and non-object structured output schemas", () => { + for (const schema of [ + { type: "array", items: { type: "string" } }, + { type: "object", properties: "invalid" }, + ]) { + const { respond, result } = runPreflight(schema); + expect(result).toBeUndefined(); + expect(respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ code: "INVALID_REQUEST" }), + ); + } + }); + + it("rejects a structured schema outside collector mode", () => { + const { respond, result } = runPreflight({ type: "object" }, false); + expect(result).toBeUndefined(); + expect(respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ + message: "active swarm collector sessions require swarmCollector=true", + }), + ); + }); + + it("rejects collector flags while Swarm is disabled", () => { + const { respond, result } = runPreflight(undefined, true, { + backend: true, + register: true, + }); + + expect(result).toBeUndefined(); + expect(respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ + message: "swarm collector fields require an enabled, host-registered collector run", + }), + ); + }); + + it("rejects unregistered or non-backend collector requests", () => { + const schema = { type: "object" }; + for (const options of [ + { enabled: true, backend: true, register: false }, + { enabled: true, backend: false, register: true }, + ]) { + const { respond, result } = runPreflight(schema, true, options); + expect(result).toBeUndefined(); + expect(respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ code: "INVALID_REQUEST" }), + ); + subagentRuns.clear(); + } + }); + + it("accepts an enabled backend request for a registered collector", () => { + const { respond, result } = runPreflight({ type: "object" }, true, { + enabled: true, + backend: true, + register: true, + }); + + expect(result).toBeDefined(); + expect(respond).not.toHaveBeenCalled(); + }); + + it("rejects ordinary turns and mismatched launch identities for an active collector", () => { + for (const options of [ + { includeCollectorFields: false }, + { idempotencyKey: "different-launch" }, + ]) { + const { respond, result } = runPreflight({ type: "object" }, true, { + enabled: true, + backend: true, + register: true, + ...options, + }); + + expect(result).toBeUndefined(); + expect(respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ code: "INVALID_REQUEST" }), + ); + subagentRuns.clear(); + } + }); + + it("keeps a retained collector session reserved from its persisted marker", () => { + vi.mocked(sessionAccessor.loadSessionEntry).mockReturnValue({ + sessionId: "collector-session", + updatedAt: 1, + swarmCollector: true, + }); + const { respond, result } = runPreflight({ type: "object" }, true, { + enabled: true, + backend: true, + includeCollectorFields: false, + }); + expect(result).toBeUndefined(); + expect(respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ code: "INVALID_REQUEST" }), + ); + }); + + it("keeps a provisionally ended collector session reserved until completion", () => { + const run = subagentRuns.get("collector-run"); + expect(run).toBeUndefined(); + const { respond, result } = runPreflight({ type: "object" }, true, { + enabled: true, + backend: true, + register: true, + includeCollectorFields: false, + }); + const registered = subagentRuns.get("collector-run"); + if (!registered) { + throw new Error("expected collector registration"); + } + registered.endedAt = 2; + + const retry = runPreflight({ type: "object" }, true, { + enabled: true, + backend: true, + includeCollectorFields: false, + }); + expect(result).toBeUndefined(); + expect(respond).toHaveBeenCalled(); + expect(retry.result).toBeUndefined(); + expect(retry.respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ code: "INVALID_REQUEST" }), + ); + }); + + it("allows an accepted collector launch identity to replay only from Gateway dedupe", () => { + const rejected = runPreflight({ type: "object" }, true, { + enabled: true, + backend: true, + register: true, + launchPending: false, + }); + expect(rejected.result).toBeUndefined(); + expect(rejected.respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ code: "INVALID_REQUEST" }), + ); + + subagentRuns.clear(); + const replayed = runPreflight({ type: "object" }, true, { + enabled: true, + backend: true, + register: true, + launchPending: false, + cached: true, + }); + expect(replayed.result).toBeUndefined(); + expect(replayed.respond).toHaveBeenCalledWith( + true, + expect.objectContaining({ runId: "gateway-run", status: "in_flight" }), + undefined, + expect.objectContaining({ cached: true, runId: "gateway-run" }), + ); + }); + + it("allows an exact cached collector replay after Swarm is disabled", () => { + const replayed = runPreflight({ type: "object" }, true, { + backend: true, + register: true, + launchPending: false, + cached: true, + }); + expect(replayed.result).toBeUndefined(); + expect(replayed.respond).toHaveBeenCalledWith( + true, + expect.objectContaining({ runId: "gateway-run", status: "in_flight" }), + undefined, + expect.objectContaining({ cached: true, runId: "gateway-run" }), + ); + }); + + it("rejects a terminal collector even when its pending launch flag remains set", () => { + const { respond, result } = runPreflight({ type: "object" }, true, { + enabled: true, + backend: true, + register: true, + ended: true, + }); + expect(result).toBeUndefined(); + expect(respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ code: "INVALID_REQUEST" }), + ); + }); + + it("keeps completed collector sessions closed while allowing their exact cached replay", () => { + const ordinary = runPreflight({ type: "object" }, true, { + enabled: true, + backend: true, + register: true, + includeCollectorFields: false, + launchPending: false, + completed: true, + }); + expect(ordinary.result).toBeUndefined(); + expect(ordinary.respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ code: "INVALID_REQUEST" }), + ); + + subagentRuns.clear(); + const replayed = runPreflight({ type: "object" }, true, { + enabled: true, + backend: true, + register: true, + launchPending: false, + completed: true, + cached: true, + }); + expect(replayed.result).toBeUndefined(); + expect(replayed.respond).toHaveBeenCalledWith( + true, + expect.objectContaining({ runId: "gateway-run", status: "in_flight" }), + undefined, + expect.objectContaining({ cached: true, runId: "gateway-run" }), + ); + }); + + it("uses the registered requester per-agent gate for a cross-agent collector", () => { + const { respond, result } = runPreflight({ type: "object" }, true, { + requesterOnlyEnabled: true, + backend: true, + register: true, + }); + + expect(result).toBeDefined(); + expect(respond).not.toHaveBeenCalled(); + }); + + it("uses the effective requester override when its session key names another agent", () => { + const { respond, result } = runPreflight({ type: "object" }, true, { + requesterOnlyEnabled: true, + backend: true, + register: true, + requesterAgentId: "main", + requesterSessionKey: "agent:cron:main", + }); + + expect(result).toBeDefined(); + expect(respond).not.toHaveBeenCalled(); + }); +}); diff --git a/src/gateway/server-methods/agent-request-preflight.ts b/src/gateway/server-methods/agent-request-preflight.ts index b2bc69fe64b5..f26f1469aaf3 100644 --- a/src/gateway/server-methods/agent-request-preflight.ts +++ b/src/gateway/server-methods/agent-request-preflight.ts @@ -8,12 +8,21 @@ import { } from "../../../packages/gateway-protocol/src/index.js"; import { parseExecApprovalFollowupApprovalId } from "../../agents/bash-tools.exec-approval-followup-state.js"; import { normalizeSpawnedRunMetadata } from "../../agents/spawned-context.js"; +import { + findAuthorizedSwarmCollectorRequest, + findSwarmCollectorSession, +} from "../../agents/subagent-registry-memory.js"; +import { resolveSwarmConfig } from "../../agents/swarm-config.js"; +import { validateStructuredOutputSchema } from "../../agents/swarm-output-schema.js"; +import { resolveAgentIdFromSessionKey, resolveStorePath } from "../../config/sessions.js"; +import { loadSessionEntry } from "../../config/sessions/session-accessor.js"; import { getAgentEventLifecycleGeneration } from "../../infra/agent-events.js"; import { isMainSessionRestartRecoveryInputProvenance, normalizeInputProvenance, shouldPreserveUserFacingSessionStateForInputProvenance, } from "../../sessions/input-provenance.js"; +import { isSubagentSessionKey } from "../../sessions/session-key-utils.js"; import { isAcceptedAgentDedupePayload, readGatewayDedupeEntry, @@ -71,6 +80,82 @@ export function prepareAgentRequestPreflight( return undefined; } const request = params.params as AgentRunRequest; + const cfg = params.context.getRuntimeConfig(); + const canUseInternalRuntimeHandoff = resolveCanUseInternalRuntimeHandoff(params.client); + const requestSessionKey = request.sessionKey?.trim(); + const collectorSession = findSwarmCollectorSession(requestSessionKey); + // Collector children always use subagent session keys, so ordinary traffic + // must never pay the persisted-store read. The store fallback only covers a + // freshly restarted gateway whose in-memory registry has not reloaded yet. + const persistedCollectorSession = + !collectorSession && requestSessionKey && isSubagentSessionKey(requestSessionKey) + ? loadSessionEntry({ + storePath: resolveStorePath(cfg.session?.store, { + agentId: resolveAgentIdFromSessionKey(requestSessionKey), + }), + sessionKey: requestSessionKey, + })?.swarmCollector === true + : false; + if ( + collectorSession || + persistedCollectorSession || + request.swarmCollector === true || + request.swarmOutputSchema !== undefined + ) { + const schemaError = request.swarmOutputSchema + ? validateStructuredOutputSchema(request.swarmOutputSchema) + : undefined; + if (request.swarmCollector !== true || schemaError) { + params.respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + schemaError ?? "active swarm collector sessions require swarmCollector=true", + ), + ); + return undefined; + } + const registeredCollector = findAuthorizedSwarmCollectorRequest({ + childSessionKey: request.sessionKey, + idempotencyKey: request.idempotencyKey, + outputSchema: request.swarmOutputSchema, + }); + const collectorDedupe = readGatewayDedupeEntry({ + dedupe: params.context.dedupe, + keys: resolveAgentDedupeKeys({ idempotencyKey: request.idempotencyKey }), + }); + const swarmRequesterSessionKey = + registeredCollector?.swarmRequesterSessionKey ?? registeredCollector?.requesterSessionKey; + const swarmEnabled = resolveSwarmConfig( + cfg, + registeredCollector?.requesterAgentId ?? + (swarmRequesterSessionKey + ? resolveAgentIdFromSessionKey(swarmRequesterSessionKey) + : undefined), + ).enabled; + const pendingCollectorLaunch = + registeredCollector?.swarmLaunchPending === true && + !registeredCollector.collectorCompletion && + typeof registeredCollector.endedAt !== "number"; + if ( + (!swarmEnabled && !collectorDedupe) || + !canUseInternalRuntimeHandoff || + request.lane !== "subagent" || + !registeredCollector || + (!pendingCollectorLaunch && !collectorDedupe) + ) { + params.respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + "swarm collector fields require an enabled, host-registered collector run", + ), + ); + return undefined; + } + } if (request.cwd && !path.isAbsolute(request.cwd)) { params.respond( false, @@ -88,7 +173,6 @@ export function prepareAgentRequestPreflight( return undefined; } const allowModelOverride = resolveAllowModelOverrideFromClient(params.client); - const canUseInternalRuntimeHandoff = resolveCanUseInternalRuntimeHandoff(params.client); const canUseCronRunContinuation = resolveCanUseCronRunContinuation(params.client); const expectedSessionResult = resolveExpectedExistingSessionConstraint({ canUseInternalRuntimeHandoff, @@ -144,7 +228,6 @@ export function prepareAgentRequestPreflight( ); return undefined; } - const cfg = params.context.getRuntimeConfig(); const runId = request.idempotencyKey; const execApprovalFollowupApprovalId = parseExecApprovalFollowupApprovalId(runId); if (execApprovalFollowupApprovalId && !canUseInternalRuntimeHandoff) { diff --git a/src/gateway/server-methods/agent-request-types.ts b/src/gateway/server-methods/agent-request-types.ts index ed4ee5a523e2..5ad3e4eea5fd 100644 --- a/src/gateway/server-methods/agent-request-types.ts +++ b/src/gateway/server-methods/agent-request-types.ts @@ -44,6 +44,8 @@ export type AgentRunRequest = { idempotencyKey: string; sourceReplyDeliveryMode?: "automatic" | "message_tool_only"; disableMessageTool?: boolean; + swarmCollector?: boolean; + swarmOutputSchema?: Record; forceRestartSafeTools?: boolean; timeout?: number; bestEffortDeliver?: boolean; diff --git a/src/gateway/server-methods/agent-run-execution-phase.ts b/src/gateway/server-methods/agent-run-execution-phase.ts index 44485a0fc128..cd64b2c856bb 100644 --- a/src/gateway/server-methods/agent-run-execution-phase.ts +++ b/src/gateway/server-methods/agent-run-execution-phase.ts @@ -360,6 +360,8 @@ export function startAgentRunExecution(params: { ? params.restoredCronContinuation.cliSessionBindingFacts?.sourceReplyDeliveryMode : params.request.sourceReplyDeliveryMode, disableMessageTool: params.request.disableMessageTool, + swarmCollector: params.request.swarmCollector, + swarmOutputSchema: params.request.swarmOutputSchema, forceRestartSafeTools: params.request.forceRestartSafeTools, internalDeliveryMediaUrls: params.client?.internal?.internalDeliveryMediaUrls, internalDeliverySuppressText: params.client?.internal?.internalDeliverySuppressText, diff --git a/src/gateway/server-methods/tools-catalog.test.ts b/src/gateway/server-methods/tools-catalog.test.ts index 0ecc11becc19..64ef4d6fa024 100644 --- a/src/gateway/server-methods/tools-catalog.test.ts +++ b/src/gateway/server-methods/tools-catalog.test.ts @@ -74,7 +74,7 @@ type CatalogPayload = { groups: CatalogGroup[]; }; -function createInvokeParams(params: Record) { +function createInvokeParams(params: Record, config: Record = {}) { const respond = vi.fn(); return { respond, @@ -85,7 +85,7 @@ function createInvokeParams(params: Record) { )({ params, respond: respond as never, - context: { getRuntimeConfig: () => ({}) } as never, + context: { getRuntimeConfig: () => config } as never, client: null, req: { type: "req", id: "req-1", method: "tools.catalog" }, isWebchatConnect: () => false, @@ -154,6 +154,24 @@ describe("tools.catalog handler", () => { expect(media?.tools.map((tool) => `${tool.source}:${tool.id}`) ?? []).toContain("core:tts"); }); + it("omits agents_wait until Swarm is enabled for the catalog agent", async () => { + const disabled = createInvokeParams({ includePlugins: false }); + await disabled.invoke(); + expect( + expectCatalogPayload(disabled.respond).groups.flatMap((group) => + group.tools.map((tool) => tool.id), + ), + ).not.toContain("agents_wait"); + + const enabled = createInvokeParams({ includePlugins: false }, { tools: { swarm: true } }); + await enabled.invoke(); + expect( + expectCatalogPayload(enabled.respond).groups.flatMap((group) => + group.tools.map((tool) => tool.id), + ), + ).toContain("agents_wait"); + }); + it("includes plugin groups with plugin metadata", async () => { const { respond, invoke } = createInvokeParams({}); await invoke(); diff --git a/src/gateway/server-methods/tools-catalog.ts b/src/gateway/server-methods/tools-catalog.ts index 5c651afa7a12..6d3b9b6b4cae 100644 --- a/src/gateway/server-methods/tools-catalog.ts +++ b/src/gateway/server-methods/tools-catalog.ts @@ -12,6 +12,7 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId, } from "../../agents/agent-scope.js"; +import { resolveSwarmConfig } from "../../agents/swarm-config.js"; import { listCoreToolSections, PROFILE_OPTIONS, @@ -50,10 +51,11 @@ type ToolCatalogGroup = { tools: ToolCatalogEntry[]; }; -function buildCoreGroups(): ToolCatalogGroup[] { +function buildCoreGroups(params: { cfg: OpenClawConfig; agentId: string }): ToolCatalogGroup[] { // Core catalog rows come from static tool sections so profile chips remain // stable even before any runtime agent session exists. - return listCoreToolSections().map((section) => ({ + const swarmEnabled = resolveSwarmConfig(params.cfg, params.agentId).enabled; + return listCoreToolSections({ swarmEnabled }).map((section) => ({ id: section.id, label: section.label, source: "core", @@ -201,7 +203,7 @@ function buildToolsCatalogResult(params: { }): ToolsCatalogResult { const agentId = normalizeOptionalString(params.agentId) || resolveDefaultAgentId(params.cfg); const includePlugins = params.includePlugins !== false; - const groups = buildCoreGroups(); + const groups = buildCoreGroups({ cfg: params.cfg, agentId }); if (includePlugins) { const existingToolNames = new Set( groups.flatMap((group) => group.tools.map((tool) => tool.id)), diff --git a/src/plugins/session-entry-slot-keys.ts b/src/plugins/session-entry-slot-keys.ts index 3c2fbbf93197..820ea1e034ca 100644 --- a/src/plugins/session-entry-slot-keys.ts +++ b/src/plugins/session-entry-slot-keys.ts @@ -30,6 +30,9 @@ const SESSION_ENTRY_RESERVED_SLOT_KEY_LIST = [ "parentSessionKey", "forkedFromParent", "spawnDepth", + "swarmGroupId", + "swarmCollector", + "swarmOutputSchema", "subagentRole", "subagentControlScope", "inheritedToolDeny", diff --git a/src/shared/fast-mode.ts b/src/shared/fast-mode.ts index 2b5494d346ea..694030b0c532 100644 --- a/src/shared/fast-mode.ts +++ b/src/shared/fast-mode.ts @@ -1,6 +1,8 @@ import type { FastMode } from "@openclaw/normalization-core/string-coerce"; import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; +export type { FastMode } from "@openclaw/normalization-core/string-coerce"; + export const DEFAULT_FAST_MODE_AUTO_ON_SECONDS = 60; export type FastModeSource = "session" | "agent" | "config" | "default"; diff --git a/src/state/openclaw-state-db-schema-additive.ts b/src/state/openclaw-state-db-schema-additive.ts index 974f6bfd0993..7f39a16ec35e 100644 --- a/src/state/openclaw-state-db-schema-additive.ts +++ b/src/state/openclaw-state-db-schema-additive.ts @@ -295,6 +295,13 @@ export function ensureAdditiveStateColumns(db: DatabaseSync): void { ensureColumn(db, "subagent_runs", "requester_settle_wake_batch_run_ids_json TEXT"); ensureColumn(db, "subagent_runs", "requester_settle_wake_last_error TEXT"); ensureColumn(db, "subagent_runs", "requester_settle_wake_retire_after INTEGER"); + ensureColumn(db, "subagent_runs", "swarm_group_id TEXT"); + ensureColumn(db, "subagent_runs", "swarm_collector INTEGER"); + ensureColumn(db, "subagent_runs", "swarm_output_schema_json TEXT"); + ensureColumn(db, "subagent_runs", "swarm_completion_status TEXT"); + ensureColumn(db, "subagent_runs", "swarm_structured_json TEXT"); + ensureColumn(db, "subagent_runs", "swarm_schema_error TEXT"); + ensureColumn(db, "subagent_runs", "swarm_usage_json TEXT"); ensureColumn(db, "worker_environments", "bootstrap_bundle_hash TEXT"); ensureColumn(db, "worker_environments", "bootstrap_openclaw_version TEXT"); ensureColumn(db, "worker_environments", "bootstrap_protocol_features_json TEXT"); diff --git a/src/state/openclaw-state-db.generated.d.ts b/src/state/openclaw-state-db.generated.d.ts index 55103a23cf94..937ffce1f980 100644 --- a/src/state/openclaw-state-db.generated.d.ts +++ b/src/state/openclaw-state-db.generated.d.ts @@ -1020,6 +1020,13 @@ export interface SubagentRuns { spawn_mode: string | null; started_at: number | null; suppress_announce_reason: string | null; + swarm_collector: number | null; + swarm_completion_status: string | null; + swarm_group_id: string | null; + swarm_output_schema_json: string | null; + swarm_schema_error: string | null; + swarm_structured_json: string | null; + swarm_usage_json: string | null; task: string; task_name: string | null; wake_on_descendant_settle: number | null; diff --git a/src/state/openclaw-state-schema.generated.ts b/src/state/openclaw-state-schema.generated.ts index 3a0473dc4c3e..87f4528e6697 100644 --- a/src/state/openclaw-state-schema.generated.ts +++ b/src/state/openclaw-state-schema.generated.ts @@ -1448,6 +1448,13 @@ CREATE TABLE IF NOT EXISTS subagent_runs ( pending_final_delivery_last_error TEXT, pending_final_delivery_payload_json TEXT, completion_announced_at INTEGER, + swarm_group_id TEXT, + swarm_collector INTEGER, + swarm_output_schema_json TEXT, + swarm_completion_status TEXT, + swarm_structured_json TEXT, + swarm_schema_error TEXT, + swarm_usage_json TEXT, payload_json TEXT NOT NULL DEFAULT '{}' ) STRICT; diff --git a/src/state/openclaw-state-schema.sql b/src/state/openclaw-state-schema.sql index 024edb46a876..ece24c5d600e 100644 --- a/src/state/openclaw-state-schema.sql +++ b/src/state/openclaw-state-schema.sql @@ -1443,6 +1443,13 @@ CREATE TABLE IF NOT EXISTS subagent_runs ( pending_final_delivery_last_error TEXT, pending_final_delivery_payload_json TEXT, completion_announced_at INTEGER, + swarm_group_id TEXT, + swarm_collector INTEGER, + swarm_output_schema_json TEXT, + swarm_completion_status TEXT, + swarm_structured_json TEXT, + swarm_schema_error TEXT, + swarm_usage_json TEXT, payload_json TEXT NOT NULL DEFAULT '{}' ) STRICT;