From e905a682f7ad2c06daad5e30cffdf8d1a90ed662 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 14 Jul 2026 14:01:55 -0700 Subject: [PATCH] feat(agents): session self-service tools New grouped sessions tool (patch: label/pin/archive/model/thinking; group CRUD; owner-gated; no reset/delete/compact). sessions_spawn gains visible + worktree via sessions.create with admission reservation and abort-confirmed rollback; a started run with no run id is aborted and deleted rather than left orphaned. subagents tool reads the unified task_runs ledger tree-scoped (subagent/acp/media/cron) and cancels detached runs, enforcing the controlScope gate for leaf callers. session_status gains a cost line. Agent-origin model patches record a fallback marker; runs failing on auth/billing/model_not_found revert to the last working model (transient errors never revert), and an independent thinkingLevel change realigns the marker so revert cannot clobber it. Refs #107237 --- .../app/i18n/NativeStringResources.kt | 5 + .../app/src/main/res/values-ar/strings.xml | 5 + .../app/src/main/res/values-de/strings.xml | 5 + .../app/src/main/res/values-es/strings.xml | 5 + .../app/src/main/res/values-fa/strings.xml | 5 + .../app/src/main/res/values-fr/strings.xml | 5 + .../app/src/main/res/values-hi/strings.xml | 5 + .../app/src/main/res/values-in/strings.xml | 5 + .../app/src/main/res/values-it/strings.xml | 5 + .../app/src/main/res/values-ja/strings.xml | 5 + .../app/src/main/res/values-ko/strings.xml | 5 + .../app/src/main/res/values-nl/strings.xml | 5 + .../app/src/main/res/values-pl/strings.xml | 5 + .../src/main/res/values-pt-rBR/strings.xml | 5 + .../app/src/main/res/values-ru/strings.xml | 5 + .../app/src/main/res/values-sv/strings.xml | 5 + .../app/src/main/res/values-th/strings.xml | 5 + .../app/src/main/res/values-tr/strings.xml | 5 + .../app/src/main/res/values-uk/strings.xml | 5 + .../app/src/main/res/values-vi/strings.xml | 5 + .../src/main/res/values-zh-rCN/strings.xml | 5 + .../src/main/res/values-zh-rTW/strings.xml | 5 + .../app/src/main/res/values/strings.xml | 5 + .../OpenClawKit/Resources/tool-display.json | 39 ++ scripts/deadcode-exports.baseline.mjs | 3 + src/agents/core-tool-factory-descriptors.ts | 1 + src/agents/openclaw-tools.ts | 27 +- src/agents/session-model-auto-revert.ts | 211 ++++++++ src/agents/subagent-depth.test.ts | 49 ++ src/agents/subagent-depth.ts | 15 +- src/agents/tool-catalog.test.ts | 2 + src/agents/tool-catalog.ts | 10 +- src/agents/tool-display-config.ts | 14 + src/agents/tool-mutation-names.ts | 33 ++ src/agents/tool-mutation.test.ts | 14 +- src/agents/tool-mutation.ts | 40 +- src/agents/tools/gateway-caller-context.ts | 29 ++ src/agents/tools/in-process-gateway.ts | 30 ++ src/agents/tools/sessions-spawn-tool.test.ts | 451 ++++++++++++++++++ src/agents/tools/sessions-spawn-tool.ts | 73 ++- .../tools/sessions-spawn-visible-admission.ts | 35 ++ src/agents/tools/sessions-spawn-visible.ts | 343 +++++++++++++ src/agents/tools/sessions-tool.test.ts | 402 ++++++++++++++++ src/agents/tools/sessions-tool.ts | 202 ++++++++ src/agents/tools/subagents-tool.test.ts | 155 +++++- src/agents/tools/subagents-tool.ts | 114 ++++- .../reply/agent-runner-execution.ts | 101 ++-- .../agent-runner-model-fallback-lifecycle.ts | 14 + src/auto-reply/reply/reply-operation-abort.ts | 53 ++ src/config/sessions/session-model-fallback.ts | 58 +++ src/config/sessions/types.ts | 8 +- src/gateway/session-model-patch-origin.ts | 61 +++ src/gateway/sessions-patch.test.ts | 144 ++++++ src/gateway/sessions-patch.ts | 66 ++- src/gateway/tool-resolution.exclude.test.ts | 10 +- src/plugins/session-entry-slot-keys.ts | 1 + src/security/dangerous-tools.ts | 1 + src/status/status-runtime-lines.ts | 103 ++++ src/status/status-text.test.ts | 132 +++++ src/status/status-text.ts | 14 +- .../codex-dynamic-tools.discord-group.json | 24 +- .../codex-dynamic-tools.heartbeat-turn.json | 24 +- .../codex-dynamic-tools.telegram-direct.json | 24 +- .../discord-group-codex-message-tool.md | 8 +- .../telegram-direct-codex-message-tool.md | 8 +- .../telegram-heartbeat-codex-tool.md | 8 +- 66 files changed, 3003 insertions(+), 266 deletions(-) create mode 100644 src/agents/session-model-auto-revert.ts create mode 100644 src/agents/tool-mutation-names.ts create mode 100644 src/agents/tools/in-process-gateway.ts create mode 100644 src/agents/tools/sessions-spawn-visible-admission.ts create mode 100644 src/agents/tools/sessions-spawn-visible.ts create mode 100644 src/agents/tools/sessions-tool.test.ts create mode 100644 src/agents/tools/sessions-tool.ts create mode 100644 src/auto-reply/reply/agent-runner-model-fallback-lifecycle.ts create mode 100644 src/auto-reply/reply/reply-operation-abort.ts create mode 100644 src/config/sessions/session-model-fallback.ts create mode 100644 src/gateway/session-model-patch-origin.ts create mode 100644 src/status/status-runtime-lines.ts 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 495d59f2e2bd..ed75cce74ad0 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 @@ -1066,6 +1066,7 @@ internal val nativeStringResourceIds: Map = "Session History" to R.string.native_edeead3f1961f9b8, "Session Search" to R.string.native_5a7a1c2ec168fbbf, "Session Send" to R.string.native_35458ac899311b76, + "Session Settings" to R.string.native_7703e4288a7ca2db, "Session Status" to R.string.native_30e08bdeea992a84, "Session Target" to R.string.native_88e6e8384a7fc7ff, "Session activity" to R.string.native_4e35d595e6975128, @@ -1344,6 +1345,7 @@ internal val nativeStringResourceIds: Map = "close" to R.string.native_310ff200149b44a3, "console" to R.string.native_93d8874c8c86f0fc, "delete" to R.string.native_6197595503f01ee2, + "delete group" to R.string.native_698ef855b198408f, "describe" to R.string.native_c52028f34e378d1e, "dialog" to R.string.native_6940189433657105, "document" to R.string.native_43cc23fa52b87b4c, @@ -1356,6 +1358,7 @@ internal val nativeStringResourceIds: Map = "events" to R.string.native_862417b9e7c3720b, "focus" to R.string.native_c51faa148557a08c, "generate" to R.string.native_24cacf5004bf68ae, + "groups" to R.string.native_4ed379d418bb8629, "hide" to R.string.native_1f5bb9791a310e13, "image" to R.string.native_6105d6cc76af4003, "import" to R.string.native_d942f64886578d87, @@ -1389,6 +1392,7 @@ internal val nativeStringResourceIds: Map = "reasoning" to R.string.native_0c4d01e81bb3d1fd, "reject" to R.string.native_084e990a49ba65e6, "remove" to R.string.native_7e5608ab610017af, + "rename group" to R.string.native_5fa1d457ddfcd26c, "requested \${relativeDeviceTime(it)}" to R.string.native_d0d0de0039c7990b, "restart" to R.string.native_3ace60b0a0c1b6c9, "role add" to R.string.native_c58ab237bd246028, @@ -1401,6 +1405,7 @@ internal val nativeStringResourceIds: Map = "screenshot" to R.string.native_4441146b0fe1d5c6, "search" to R.string.native_2419329067823cab, "send" to R.string.native_27ce1d1bf4270020, + "set groups" to R.string.native_8169e4741bbe5fa4, "snapshot" to R.string.native_16a0eeb0791b6c92, "soon" to R.string.native_4a754148b88a68e1, "start" to R.string.native_cced28c6dc3f99c2, 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 3c5d63eb0cd9..d602e624eff3 100644 --- a/apps/android/app/src/main/res/values-ar/strings.xml +++ b/apps/android/app/src/main/res/values-ar/strings.xml @@ -442,6 +442,7 @@ "نشاط الجلسة" "إعدادات التشغيل الآلي غير صالحة." "قائمة السماح" + "groups" "حول" "لم يتم العثور على رمز QR للإعداد في هذه الصورة. اختر رمز QR الذي أنشأه openclaw qr، أو أدخل رمز الإعداد يدويًا." "تصفّح الجلسات" @@ -542,6 +543,7 @@ " · الموقع: دائمًا" "التحميل من Gateway" "يحتاج إلى" + "rename group" "جاهز" "تنتظر المذكّرات أول إدخال لها." "الصفحة المباشرة" @@ -598,6 +600,7 @@ "الاستماع إلى كلمات التنبيه" "camera snap" "حدّد Gateway موفّرًا غير معروف %1$s" + "delete group" "متابعة Android · %1$s" "channel" "This skill is blocked by the gateway allowlist. Allowlist changes stay on desktop or CLI." @@ -690,6 +693,7 @@ "يشير رمز الإعداد إلى Gateway بعيد غير آمن. %1$s %2$s" "Computer" "Gateway غير متصل." + "Session Settings" "صِل Gateway للبدء" "إشعار أمان" "تجاهل تحذير الصورة المشتركة" @@ -738,6 +742,7 @@ "خلال %1$s ساعة" "موافقة Gateway معلّقة. شغّل الأمر التالي على مضيف Gateway:" "مطلوب وصول المسؤول" + "set groups" "تثبيت النموذج" "مسح البحث" "ممكّنة للوكلاء المؤهلين." 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 895a65b2281d..bf373356ab95 100644 --- a/apps/android/app/src/main/res/values-de/strings.xml +++ b/apps/android/app/src/main/res/values-de/strings.xml @@ -442,6 +442,7 @@ "Sitzungsaktivität" "Die Automation hat eine ungültige Konfiguration." "Zulassungsliste" + "groups" "Info" "In diesem Bild wurde kein Einrichtungs-QR-Code gefunden. Wähle den von openclaw qr generierten QR-Code oder gib den Einrichtungscode manuell ein." "Sitzungen durchsuchen" @@ -542,6 +543,7 @@ " · Standort: Immer" "Vom Gateway laden" "Erforderlich" + "rename group" "Bereit" "Das Tagebuch wartet auf seinen ersten Eintrag." "Live-Seite" @@ -598,6 +600,7 @@ "Auf Aktivierungswörter warten" "camera snap" "Gateway hat unbekannten Anbieter %1$s ausgewählt" + "delete group" "Android folgen · %1$s" "channel" "This skill is blocked by the gateway allowlist. Allowlist changes stay on desktop or CLI." @@ -690,6 +693,7 @@ "Der Einrichtungscode verweist auf eine unsichere Remote-Gateway. %1$s %2$s" "Computer" "Gateway getrennt." + "Session Settings" "Gateway verbinden, um zu starten" "Sicherheitshinweis" "Warnung zu geteiltem Bild schließen" @@ -738,6 +742,7 @@ "In %1$s Std." "Die Gateway-Genehmigung steht noch aus. Führen Sie auf dem Gateway-Host Folgendes aus:" "Administratorzugriff erforderlich" + "set groups" "Modell anheften" "Suche löschen" "Für berechtigte Agenten aktiviert." 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 c9883f730a4a..7e35a66f9bb5 100644 --- a/apps/android/app/src/main/res/values-es/strings.xml +++ b/apps/android/app/src/main/res/values-es/strings.xml @@ -442,6 +442,7 @@ "Actividad de la sesión" "La automatización tiene una configuración no válida." "Lista de permitidos" + "groups" "Acerca de" "No se encontró ningún código QR de configuración en esa imagen. Selecciona el código QR generado por openclaw qr o introduce manualmente el código de configuración." "Explorar sesiones" @@ -542,6 +543,7 @@ " · Ubicación: Siempre" "Cargar desde el gateway" "Necesita" + "rename group" "Listo" "El diario está esperando su primera entrada." "Página en vivo" @@ -598,6 +600,7 @@ "Escuchar palabras de activación" "camera snap" "Gateway seleccionó un proveedor desconocido: %1$s" + "delete group" "Seguir Android · %1$s" "channel" "This skill is blocked by the gateway allowlist. Allowlist changes stay on desktop or CLI." @@ -690,6 +693,7 @@ "El código de configuración apunta a un Gateway remoto no seguro. %1$s %2$s" "Computer" "Gateway desconectado." + "Session Settings" "Conecta el Gateway para comenzar" "Aviso de seguridad" "Descartar advertencia de imagen compartida" @@ -738,6 +742,7 @@ "En %1$s h" "La aprobación del Gateway está pendiente. Ejecuta esto en el host del Gateway:" "Se requiere acceso de administrador" + "set groups" "Fijar modelo" "Borrar búsqueda" "Activada para los agentes elegibles." 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 48b865466860..6845741fa7b9 100644 --- a/apps/android/app/src/main/res/values-fa/strings.xml +++ b/apps/android/app/src/main/res/values-fa/strings.xml @@ -442,6 +442,7 @@ "فعالیت جلسه" "پیکربندی اجرای خودکار نامعتبر است." "فهرست مجاز" + "groups" "درباره" "هیچ کد QR راه‌اندازی در این تصویر پیدا نشد. کد QR ایجادشده با openclaw qr را انتخاب کنید، یا کد راه‌اندازی را به‌صورت دستی وارد کنید." "مرور نشست‌ها" @@ -542,6 +543,7 @@ " · مکان: همیشه" "بارگیری از Gateway" "نیاز دارد" + "rename group" "آماده" "دفترچه منتظر نخستین ورودی خود است." "صفحه زنده" @@ -598,6 +600,7 @@ "گوش‌دادن به واژه‌های بیدارباش" "camera snap" "Gateway ارائه‌دهنده ناشناخته %1$s را انتخاب کرد" + "delete group" "دنبال کردن Android · %1$s" "channel" "This skill is blocked by the gateway allowlist. Allowlist changes stay on desktop or CLI." @@ -690,6 +693,7 @@ "کد راه‌اندازی به یک gateway راه‌دور ناامن اشاره دارد. %1$s %2$s" "Computer" "Gateway قطع شده است." + "Session Settings" "برای شروع، Gateway را متصل کنید" "اعلان امنیتی" "رد کردن هشدار تصویر اشتراکی" @@ -738,6 +742,7 @@ "%1$s ساعت دیگر" "تأیید Gateway در انتظار است. این دستور را روی میزبان Gateway اجرا کنید:" "دسترسی مدیر لازم است" + "set groups" "سنجاق کردن مدل" "پاک‌کردن جست‌وجو" "برای عامل‌های واجد شرایط فعال است." 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 562461e13015..29650bcc3308 100644 --- a/apps/android/app/src/main/res/values-fr/strings.xml +++ b/apps/android/app/src/main/res/values-fr/strings.xml @@ -442,6 +442,7 @@ "Activité de la session" "La configuration de l’automatisation n’est pas valide." "Liste d’autorisation" + "groups" "À propos" "Aucun code QR de configuration n’a été trouvé dans cette image. Choisissez le code QR généré par openclaw qr ou saisissez manuellement le code de configuration." "Parcourir les sessions" @@ -542,6 +543,7 @@ " · Localisation : Toujours" "Charger depuis le Gateway" "Requis" + "rename group" "Prêt" "Le journal attend sa première entrée." "Page en direct" @@ -598,6 +600,7 @@ "Écouter les mots d’activation" "camera snap" "Gateway a sélectionné un fournisseur inconnu %1$s" + "delete group" "Suivre Android · %1$s" "channel" "This skill is blocked by the gateway allowlist. Allowlist changes stay on desktop or CLI." @@ -690,6 +693,7 @@ "Le code de configuration pointe vers une Gateway distante non sécurisée. %1$s %2$s" "Computer" "Gateway déconnecté." + "Session Settings" "Connectez le Gateway pour commencer" "Avis de sécurité" "Ignorer l\'avertissement d\'image partagée" @@ -738,6 +742,7 @@ "Dans %1$s h" "L’approbation du Gateway est en attente. Exécutez ceci sur l’hôte du Gateway :" "Accès administrateur requis" + "set groups" "Épingler le modèle" "Effacer la recherche" "Activée pour les agents éligibles." 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 4d894a561ac8..6a2757cc7ca9 100644 --- a/apps/android/app/src/main/res/values-hi/strings.xml +++ b/apps/android/app/src/main/res/values-hi/strings.xml @@ -442,6 +442,7 @@ "सेशन गतिविधि" "ऑटोमेशन का कॉन्फ़िगरेशन अमान्य है।" "अनुमति सूची" + "groups" "परिचय" "उस इमेज में कोई सेटअप QR कोड नहीं मिला। openclaw qr से जनरेट किया गया QR चुनें या सेटअप कोड मैन्युअल रूप से दर्ज करें।" "सत्र ब्राउज़ करें" @@ -542,6 +543,7 @@ " · स्थान: हमेशा" "Gateway से लोड करें" "आवश्यक" + "rename group" "तैयार" "डायरी अपनी पहली प्रविष्टि की प्रतीक्षा कर रही है।" "लाइव पेज" @@ -598,6 +600,7 @@ "वेक वर्ड सुनें" "camera snap" "Gateway ने अज्ञात प्रदाता %1$s चुना" + "delete group" "Android का अनुसरण करें · %1$s" "channel" "This skill is blocked by the gateway allowlist. Allowlist changes stay on desktop or CLI." @@ -690,6 +693,7 @@ "सेटअप कोड एक असुरक्षित रिमोट gateway की ओर इशारा करता है। %1$s %2$s" "Computer" "Gateway डिस्कनेक्ट हो गया।" + "Session Settings" "शुरू करने के लिए Gateway कनेक्ट करें" "सुरक्षा सूचना" "साझा-छवि चेतावनी खारिज करें" @@ -738,6 +742,7 @@ "%1$sघंटे में" "Gateway की स्वीकृति लंबित है। इसे gateway होस्ट पर चलाएँ:" "Admin एक्सेस आवश्यक" + "set groups" "मॉडल पिन करें" "खोज साफ़ करें" "पात्र एजेंट के लिए सक्षम।" 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 d598a47f5f63..ed153beea7d2 100644 --- a/apps/android/app/src/main/res/values-in/strings.xml +++ b/apps/android/app/src/main/res/values-in/strings.xml @@ -442,6 +442,7 @@ "Aktivitas sesi" "Automasi memiliki konfigurasi yang tidak valid." "Daftar izin" + "groups" "Tentang" "Kode QR penyiapan tidak ditemukan dalam gambar tersebut. Pilih QR yang dibuat oleh openclaw qr, atau masukkan kode penyiapan secara manual." "Telusuri Sesi" @@ -542,6 +543,7 @@ " · Lokasi: Selalu" "Muat dari gateway" "Perlu" + "rename group" "Siap" "Diary sedang menunggu entri pertamanya." "Halaman langsung" @@ -598,6 +600,7 @@ "Dengarkan kata aktivasi" "camera snap" "Gateway memilih penyedia tidak dikenal %1$s" + "delete group" "Ikuti Android · %1$s" "channel" "This skill is blocked by the gateway allowlist. Allowlist changes stay on desktop or CLI." @@ -690,6 +693,7 @@ "Kode penyiapan mengarah ke gateway jarak jauh yang tidak aman. %1$s %2$s" "Computer" "Gateway terputus." + "Session Settings" "Hubungkan Gateway untuk memulai" "Pemberitahuan keamanan" "Tutup peringatan gambar yang dibagikan" @@ -738,6 +742,7 @@ "Dalam %1$s jam" "Persetujuan Gateway tertunda. Jalankan ini di host gateway:" "Akses admin diperlukan" + "set groups" "Sematkan model" "Hapus Pencarian" "Diaktifkan untuk agen yang memenuhi syarat." 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 26521da11b54..ea324641482c 100644 --- a/apps/android/app/src/main/res/values-it/strings.xml +++ b/apps/android/app/src/main/res/values-it/strings.xml @@ -442,6 +442,7 @@ "Attività della sessione" "L\'automazione ha una configurazione non valida." "Elenco consentiti" + "groups" "Informazioni" "Nell\'immagine non è stato trovato alcun codice QR di configurazione. Scegli il QR generato da openclaw qr oppure inserisci manualmente il codice di configurazione." "Sfoglia sessioni" @@ -542,6 +543,7 @@ " · Posizione: Sempre" "Carica dal Gateway" "Richiede" + "rename group" "Pronto" "Il diario è in attesa della sua prima voce." "Pagina live" @@ -598,6 +600,7 @@ "Ascolta le parole di attivazione" "camera snap" "Gateway ha selezionato il provider sconosciuto %1$s" + "delete group" "Segui Android · %1$s" "channel" "This skill is blocked by the gateway allowlist. Allowlist changes stay on desktop or CLI." @@ -690,6 +693,7 @@ "Il codice di configurazione punta a un gateway remoto non sicuro. %1$s %2$s" "Computer" "Gateway disconnesso." + "Session Settings" "Connetti il gateway per iniziare" "Avviso di sicurezza" "Ignora avviso immagine condivisa" @@ -738,6 +742,7 @@ "Tra %1$sh" "L\'approvazione del Gateway è in sospeso. Esegui questo comando sull\'host del Gateway:" "Accesso amministratore richiesto" + "set groups" "Fissa modello" "Cancella ricerca" "Abilitata per gli agenti idonei." 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 2a4b25199383..58b53be703ab 100644 --- a/apps/android/app/src/main/res/values-ja/strings.xml +++ b/apps/android/app/src/main/res/values-ja/strings.xml @@ -442,6 +442,7 @@ "セッションアクティビティ" "自動化の設定が無効です。" "許可リスト" + "groups" "情報" "この画像にセットアップQRコードが見つかりませんでした。openclaw qr で生成されたQRを選択するか、セットアップコードを手動で入力してください。" "セッションを参照" @@ -542,6 +543,7 @@ " · 位置情報:常に許可" "Gateway から読み込む" "必要" + "rename group" "準備完了" "日記は最初のエントリを待っています。" "ライブページ" @@ -598,6 +600,7 @@ "ウェイクワードを聞き取る" "camera snap" "Gateway が不明なプロバイダー %1$s を選択しました" + "delete group" "Androidに従う · %1$s" "channel" "This skill is blocked by the gateway allowlist. Allowlist changes stay on desktop or CLI." @@ -690,6 +693,7 @@ "セットアップコードが安全でないリモートGatewayを指しています。%1$s %2$s" "Computer" "Gateway が切断されました。" + "Session Settings" "開始するにはGatewayに接続してください" "セキュリティに関する通知" "共有画像の警告を閉じる" @@ -738,6 +742,7 @@ "%1$s時間後" "Gatewayの承認待ちです。Gatewayホストで次を実行してください:" "管理者アクセスが必要です" + "set groups" "モデルをピン留め" "検索をクリア" "対象となるエージェントに対して有効です。" 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 69e4a0d9d17c..3eafb6ef264b 100644 --- a/apps/android/app/src/main/res/values-ko/strings.xml +++ b/apps/android/app/src/main/res/values-ko/strings.xml @@ -442,6 +442,7 @@ "세션 활동" "자동화 구성이 잘못되었습니다." "허용 목록" + "groups" "정보" "해당 이미지에서 설정 QR 코드를 찾을 수 없습니다. openclaw qr로 생성한 QR을 선택하거나 설정 코드를 직접 입력하세요." "세션 둘러보기" @@ -542,6 +543,7 @@ " · 위치: 항상" "Gateway에서 불러오기" "필요" + "rename group" "준비됨" "일기가 첫 항목을 기다리고 있습니다." "라이브 페이지" @@ -598,6 +600,7 @@ "호출어 감지" "camera snap" "Gateway에서 알 수 없는 제공자 %1$s을(를) 선택했습니다" + "delete group" "Android 설정 따르기 · %1$s" "channel" "This skill is blocked by the gateway allowlist. Allowlist changes stay on desktop or CLI." @@ -690,6 +693,7 @@ "설정 코드가 안전하지 않은 원격 gateway를 가리킵니다. %1$s %2$s" "Computer" "Gateway 연결이 끊어졌습니다." + "Session Settings" "시작하려면 Gateway를 연결하세요" "보안 알림" "공유 이미지 경고 닫기" @@ -738,6 +742,7 @@ "%1$s시간 후" "Gateway 승인이 대기 중입니다. Gateway 호스트에서 다음을 실행하세요:" "관리자 권한 필요" + "set groups" "모델 고정" "검색 지우기" "적격 에이전트에 대해 활성화되었습니다." 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 57923c21e512..1fd76d751b1c 100644 --- a/apps/android/app/src/main/res/values-nl/strings.xml +++ b/apps/android/app/src/main/res/values-nl/strings.xml @@ -442,6 +442,7 @@ "Sessieactiviteit" "De automatisering heeft een ongeldige configuratie." "Toegestaan-lijst" + "groups" "Over" "Er is geen installatie-QR-code gevonden in die afbeelding. Kies de QR-code die is gegenereerd door openclaw qr of voer de installatiecode handmatig in." "Sessies bekijken" @@ -542,6 +543,7 @@ " · Locatie: Altijd" "Laden vanuit Gateway" "Vereist" + "rename group" "Gereed" "Het dagboek wacht op het eerste item." "Livepagina" @@ -598,6 +600,7 @@ "Luisteren naar activeringswoorden" "camera snap" "Gateway heeft onbekende provider %1$s geselecteerd" + "delete group" "Volg Android · %1$s" "channel" "This skill is blocked by the gateway allowlist. Allowlist changes stay on desktop or CLI." @@ -690,6 +693,7 @@ "De setupcode verwijst naar een onveilige externe Gateway. %1$s %2$s" "Computer" "Gateway losgekoppeld." + "Session Settings" "Verbind de Gateway om te beginnen" "Beveiligingsmelding" "Waarschuwing gedeelde afbeelding sluiten" @@ -738,6 +742,7 @@ "Over %1$su" "Goedkeuring van Gateway is in behandeling. Voer dit uit op de Gateway-host:" "Beheerderstoegang vereist" + "set groups" "Model vastzetten" "Zoekopdracht wissen" "Ingeschakeld voor geschikte agents." 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 4390b65a18d6..16fbcc77f4b8 100644 --- a/apps/android/app/src/main/res/values-pl/strings.xml +++ b/apps/android/app/src/main/res/values-pl/strings.xml @@ -442,6 +442,7 @@ "Aktywność sesji" "Automatyzacja ma nieprawidłową konfigurację." "Lista dozwolonych" + "groups" "Informacje" "Na tym obrazie nie znaleziono kodu QR konfiguracji. Wybierz kod QR wygenerowany przez openclaw qr lub wprowadź kod konfiguracji ręcznie." "Przeglądaj sesje" @@ -542,6 +543,7 @@ " · Lokalizacja: Zawsze" "Wczytaj z Gateway" "Wymaga" + "rename group" "Gotowe" "Dziennik czeka na pierwszy wpis." "Strona na żywo" @@ -598,6 +600,7 @@ "Nasłuchuj słów aktywujących" "camera snap" "Gateway wybrał nieznanego dostawcę %1$s" + "delete group" "Śledź Android · %1$s" "channel" "This skill is blocked by the gateway allowlist. Allowlist changes stay on desktop or CLI." @@ -690,6 +693,7 @@ "Kod konfiguracji wskazuje na niezabezpieczony zdalny Gateway. %1$s %2$s" "Computer" "Gateway rozłączony." + "Session Settings" "Połącz Gateway, aby rozpocząć" "Informacja o bezpieczeństwie" "Odrzuć ostrzeżenie o udostępnionym obrazie" @@ -738,6 +742,7 @@ "Za %1$s godz." "Oczekiwanie na zatwierdzenie Gateway. Uruchom to polecenie na hoście Gateway:" "Wymagany dostęp administratora" + "set groups" "Przypnij model" "Wyczyść wyszukiwanie" "Włączono dla kwalifikujących się agentów." 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 5f1013f976eb..186cf7cb5c60 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 @@ -442,6 +442,7 @@ "Atividade da sessão" "A automação tem uma configuração inválida." "Lista de permissões" + "groups" "Sobre" "Nenhum código QR de configuração foi encontrado nessa imagem. Escolha o QR gerado por openclaw qr ou insira o código de configuração manualmente." "Explorar sessões" @@ -542,6 +543,7 @@ " · Localização: Sempre" "Carregar do gateway" "Necessários" + "rename group" "Pronto" "O diário está aguardando sua primeira entrada." "Página ao vivo" @@ -598,6 +600,7 @@ "Ouvir palavras de ativação" "camera snap" "O Gateway selecionou o provedor desconhecido %1$s" + "delete group" "Seguir Android · %1$s" "channel" "This skill is blocked by the gateway allowlist. Allowlist changes stay on desktop or CLI." @@ -690,6 +693,7 @@ "O código de configuração aponta para um gateway remoto inseguro. %1$s %2$s" "Computer" "Gateway desconectado." + "Session Settings" "Conecte o Gateway para começar" "Aviso de segurança" "Dispensar aviso de imagem compartilhada" @@ -738,6 +742,7 @@ "Em %1$sh" "A aprovação do Gateway está pendente. Execute isto no host do Gateway:" "Acesso de administrador necessário" + "set groups" "Fixar modelo" "Limpar busca" "Ativada para agentes qualificados." 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 ad71b632fa12..fd2f1951c3da 100644 --- a/apps/android/app/src/main/res/values-ru/strings.xml +++ b/apps/android/app/src/main/res/values-ru/strings.xml @@ -442,6 +442,7 @@ "Активность сеанса" "Конфигурация автоматизации недействительна." "Список разрешений" + "groups" "О приложении" "На этом изображении не найден QR-код настройки. Выберите QR-код, созданный с помощью openclaw qr, или введите код настройки вручную." "Просмотреть сеансы" @@ -542,6 +543,7 @@ " · Геопозиция: всегда" "Загрузить из Gateway" "Требуется" + "rename group" "Готово" "Дневник ожидает первой записи." "Страница в реальном времени" @@ -598,6 +600,7 @@ "Распознавать фразы активации" "camera snap" "Gateway выбрал неизвестного поставщика %1$s" + "delete group" "Следовать Android · %1$s" "channel" "This skill is blocked by the gateway allowlist. Allowlist changes stay on desktop or CLI." @@ -690,6 +693,7 @@ "Код настройки указывает на небезопасный удаленный Gateway. %1$s %2$s" "Computer" "Gateway отключен." + "Session Settings" "Подключите Gateway, чтобы начать" "Уведомление о безопасности" "Закрыть предупреждение об общем изображении" @@ -738,6 +742,7 @@ "Через %1$s ч." "Ожидается одобрение Gateway. Выполните следующую команду на хосте Gateway:" "Требуется доступ администратора" + "set groups" "Закрепить модель" "Очистить поиск" "Включено для подходящих агентов." 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 7a98ede6a3a3..b01b5af41e25 100644 --- a/apps/android/app/src/main/res/values-sv/strings.xml +++ b/apps/android/app/src/main/res/values-sv/strings.xml @@ -442,6 +442,7 @@ "Sessionsaktivitet" "Automatiseringen har en ogiltig konfiguration." "Tillåtelselista" + "groups" "Om" "Ingen QR-kod för konfiguration hittades i bilden. Välj QR-koden som genererades av openclaw qr eller ange konfigurationskoden manuellt." "Bläddra bland sessioner" @@ -542,6 +543,7 @@ " · Plats: Alltid" "Läs in från Gateway" "Behöver" + "rename group" "Klar" "Dagboken väntar på sin första post." "Livesida" @@ -598,6 +600,7 @@ "Lyssna efter aktiveringsord" "camera snap" "Gateway valde okänd leverantör %1$s" + "delete group" "Följ Android · %1$s" "channel" "This skill is blocked by the gateway allowlist. Allowlist changes stay on desktop or CLI." @@ -690,6 +693,7 @@ "Installationskoden pekar på en osäker fjärr-Gateway. %1$s %2$s" "Computer" "Gateway frånkopplad." + "Session Settings" "Anslut Gateway för att börja" "Säkerhetsmeddelande" "Avfärda varning om delad bild" @@ -738,6 +742,7 @@ "Om %1$s tim" "Godkännande av Gateway väntar. Kör detta på Gateway-värden:" "Administratörsåtkomst krävs" + "set groups" "Fäst modell" "Rensa sökning" "Aktiverad för behöriga agenter." 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 a27538a14ef6..8f8fdf9de170 100644 --- a/apps/android/app/src/main/res/values-th/strings.xml +++ b/apps/android/app/src/main/res/values-th/strings.xml @@ -442,6 +442,7 @@ "กิจกรรมเซสชัน" "ระบบอัตโนมัติมีการกำหนดค่าที่ไม่ถูกต้อง" "รายการที่อนุญาต" + "groups" "เกี่ยวกับ" "ไม่พบคิวอาร์โค้ดสำหรับตั้งค่าในรูปภาพนั้น โปรดเลือกคิวอาร์ที่สร้างโดย openclaw qr หรือป้อนรหัสตั้งค่าด้วยตนเอง" "เรียกดูเซสชัน" @@ -542,6 +543,7 @@ " · ตำแหน่งที่ตั้ง: ตลอดเวลา" "โหลดจาก Gateway" "ต้องการ" + "rename group" "พร้อม" "ไดอารีกำลังรอรายการแรก" "หน้าไลฟ์" @@ -598,6 +600,7 @@ "ฟังคำปลุก" "camera snap" "Gateway เลือกผู้ให้บริการที่ไม่รู้จัก %1$s" + "delete group" "ติดตาม Android · %1$s" "channel" "This skill is blocked by the gateway allowlist. Allowlist changes stay on desktop or CLI." @@ -690,6 +693,7 @@ "รหัสตั้งค่าชี้ไปยัง Gateway ระยะไกลที่ไม่ปลอดภัย %1$s %2$s" "Computer" "Gateway ถูกตัดการเชื่อมต่อ" + "Session Settings" "เชื่อมต่อ Gateway เพื่อเริ่มต้น" "ประกาศด้านความปลอดภัย" "ปิดคำเตือนรูปภาพที่แชร์" @@ -738,6 +742,7 @@ "ในอีก %1$s ชม." "การอนุมัติ Gateway อยู่ระหว่างรอดำเนินการ เรียกใช้คำสั่งนี้บนโฮสต์ Gateway:" "ต้องมีสิทธิ์ผู้ดูแลระบบ" + "set groups" "ปักหมุดโมเดล" "ล้างการค้นหา" "เปิดใช้งานสำหรับเอเจนต์ที่มีสิทธิ์" 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 269e44e57cd9..2e887923ef3b 100644 --- a/apps/android/app/src/main/res/values-tr/strings.xml +++ b/apps/android/app/src/main/res/values-tr/strings.xml @@ -442,6 +442,7 @@ "Oturum etkinliği" "Otomasyonun yapılandırması geçersiz." "İzin verilenler listesi" + "groups" "Hakkında" "Bu görüntüde kurulum QR kodu bulunamadı. openclaw qr tarafından oluşturulan QR kodunu seçin veya kurulum kodunu manuel olarak girin." "Oturumlara Göz At" @@ -542,6 +543,7 @@ " · Konum: Her Zaman" "Gateway\'den yükle" "Gerekenler" + "rename group" "Hazır" "Günlük ilk kaydını bekliyor." "Canlı sayfa" @@ -598,6 +600,7 @@ "Uyandırma sözcüklerini dinle" "camera snap" "Gateway bilinmeyen sağlayıcıyı seçti: %1$s" + "delete group" "Android\'i takip et · %1$s" "channel" "This skill is blocked by the gateway allowlist. Allowlist changes stay on desktop or CLI." @@ -690,6 +693,7 @@ "Kurulum kodu güvenli olmayan bir uzak gateway\'i işaret ediyor. %1$s %2$s" "Computer" "Gateway bağlantısı kesildi." + "Session Settings" "Başlamak için Gateway\'i bağlayın" "Güvenlik bildirimi" "Paylaşılan görüntü uyarısını kapat" @@ -738,6 +742,7 @@ "%1$s sa. içinde" "Gateway onayı bekleniyor. Gateway ana makinesinde şunu çalıştırın:" "Yönetici erişimi gerekli" + "set groups" "Modeli sabitle" "Aramayı Temizle" "Uygun aracılar için etkin." 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 33ba9d6f0720..e7901e3df8ac 100644 --- a/apps/android/app/src/main/res/values-uk/strings.xml +++ b/apps/android/app/src/main/res/values-uk/strings.xml @@ -442,6 +442,7 @@ "Активність сеансу" "Автоматизація має недійсну конфігурацію." "Список дозволених" + "groups" "Про застосунок" "На цьому зображенні не знайдено QR-коду налаштування. Виберіть QR-код, згенерований за допомогою openclaw qr, або введіть код налаштування вручну." "Переглянути сеанси" @@ -542,6 +543,7 @@ " · Місцезнаходження: Завжди" "Завантажити з Gateway" "Потрібно" + "rename group" "Готово" "Щоденник очікує на перший запис." "Сторінка наживо" @@ -598,6 +600,7 @@ "Слухати слова активації" "camera snap" "Gateway вибрав невідомого постачальника %1$s" + "delete group" "Слідувати Android · %1$s" "channel" "This skill is blocked by the gateway allowlist. Allowlist changes stay on desktop or CLI." @@ -690,6 +693,7 @@ "Код налаштування вказує на небезпечний віддалений gateway. %1$s %2$s" "Computer" "Gateway відключено." + "Session Settings" "Підключіть Gateway, щоб почати" "Повідомлення про безпеку" "Закрити попередження про спільне зображення" @@ -738,6 +742,7 @@ "Через %1$s год" "Очікується схвалення Gateway. Виконайте цю команду на хості Gateway:" "Потрібен доступ адміністратора" + "set groups" "Закріпити модель" "Очистити пошук" "Увімкнено для відповідних агентів." 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 970674183504..208c49abd3c0 100644 --- a/apps/android/app/src/main/res/values-vi/strings.xml +++ b/apps/android/app/src/main/res/values-vi/strings.xml @@ -442,6 +442,7 @@ "Hoạt động phiên" "Tác vụ tự động có cấu hình không hợp lệ." "Danh sách cho phép" + "groups" "Giới thiệu" "Không tìm thấy mã QR thiết lập trong hình ảnh đó. Hãy chọn mã QR do openclaw qr tạo hoặc nhập mã thiết lập theo cách thủ công." "Duyệt các phiên" @@ -542,6 +543,7 @@ " · Vị trí: Luôn luôn" "Tải từ Gateway" "Cần" + "rename group" "Sẵn sàng" "Nhật ký đang chờ mục đầu tiên." "Trang trực tiếp" @@ -598,6 +600,7 @@ "Lắng nghe từ đánh thức" "camera snap" "Gateway đã chọn nhà cung cấp không xác định %1$s" + "delete group" "Theo Android · %1$s" "channel" "This skill is blocked by the gateway allowlist. Allowlist changes stay on desktop or CLI." @@ -690,6 +693,7 @@ "Mã thiết lập trỏ đến một gateway từ xa không an toàn. %1$s %2$s" "Computer" "Gateway đã ngắt kết nối." + "Session Settings" "Kết nối Gateway để bắt đầu" "Thông báo bảo mật" "Bỏ qua cảnh báo hình ảnh được chia sẻ" @@ -738,6 +742,7 @@ "Sau %1$s giờ" "Đang chờ phê duyệt Gateway. Chạy lệnh này trên máy chủ Gateway:" "Cần quyền quản trị" + "set groups" "Ghim mô hình" "Xóa tìm kiếm" "Đã bật cho các agent đủ điều kiện." 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 408adb49c526..a8744167bc9b 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 @@ -442,6 +442,7 @@ "会话活动" "自动化配置无效。" "允许列表" + "groups" "关于" "在该图片中未找到设置二维码。请选择由 openclaw qr 生成的二维码,或手动输入设置代码。" "浏览会话" @@ -542,6 +543,7 @@ " · 位置:始终允许" "从 Gateway 加载" "需要" + "rename group" "就绪" "日记正在等待第一条记录。" "实时页面" @@ -598,6 +600,7 @@ "监听唤醒词" "camera snap" "Gateway 选择了未知提供商 %1$s" + "delete group" "跟随 Android · %1$s" "channel" "This skill is blocked by the gateway allowlist. Allowlist changes stay on desktop or CLI." @@ -690,6 +693,7 @@ "设置代码指向不安全的远程 Gateway。%1$s %2$s" "Computer" "Gateway 已断开连接。" + "Session Settings" "连接 Gateway 以开始" "安全提示" "关闭共享图片警告" @@ -738,6 +742,7 @@ "%1$s 小时后" "Gateway 批准尚待处理。请在 Gateway 主机上运行:" "需要管理员访问权限" + "set groups" "固定模型" "清除搜索" "已对符合条件的代理启用。" 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 d252402059ef..66473d9808a3 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 @@ -442,6 +442,7 @@ "工作階段活動" "自動化的設定無效。" "允許清單" + "groups" "關於" "在該圖片中找不到設定用 QR code。請選擇 openclaw qr 所產生的 QR,或手動輸入設定代碼。" "瀏覽工作階段" @@ -542,6 +543,7 @@ " · 位置:永遠允許" "從 Gateway 載入" "需要" + "rename group" "就緒" "日記正在等待第一筆項目。" "即時頁面" @@ -598,6 +600,7 @@ "聆聽喚醒詞" "camera snap" "Gateway 選取了未知的供應商 %1$s" + "delete group" "依循 Android · %1$s" "channel" "This skill is blocked by the gateway allowlist. Allowlist changes stay on desktop or CLI." @@ -690,6 +693,7 @@ "設定碼指向不安全的遠端 Gateway。%1$s %2$s" "Computer" "Gateway 已中斷連線。" + "Session Settings" "連接 Gateway 以開始" "安全性通知" "關閉分享圖片警告" @@ -738,6 +742,7 @@ "%1$s 小時後" "Gateway 核准待處理。請在 Gateway 主機上執行:" "需要管理員存取權" + "set groups" "釘選模型" "清除搜尋" "已對符合資格的代理程式啟用。" diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml index 54cdb80e4051..9aeaaf48210a 100644 --- a/apps/android/app/src/main/res/values/strings.xml +++ b/apps/android/app/src/main/res/values/strings.xml @@ -442,6 +442,7 @@ "Session activity" "Automation has an invalid configuration." "Allowlist" + "groups" "About" "No setup QR code was found in that image. Choose the QR generated by openclaw qr, or enter the setup code manually." "Browse Sessions" @@ -542,6 +543,7 @@ " · Location: Always" "Load from gateway" "Needs" + "rename group" "Ready" "The diary is waiting for its first entry." "Live page" @@ -598,6 +600,7 @@ "Listen for wake words" "camera snap" "Gateway selected unknown provider %1$s" + "delete group" "Follow Android · %1$s" "channel" "This skill is blocked by the gateway allowlist. Allowlist changes stay on desktop or CLI." @@ -690,6 +693,7 @@ "Setup code points to an insecure remote gateway. %1$s %2$s" "Computer" "Gateway disconnected." + "Session Settings" "Connect gateway to start" "Security notice" "Dismiss shared-image warning" @@ -738,6 +742,7 @@ "In %1$sh" "Gateway approval is pending. Run this on the gateway host:" "Admin access required" + "set groups" "Pin model" "Clear Search" "Enabled for eligible agents." diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/tool-display.json b/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/tool-display.json index 0f2fa1ba70eb..c1494675b759 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/tool-display.json +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/tool-display.json @@ -494,6 +494,45 @@ "model" ] }, + "sessions": { + "emoji": "🗂️", + "title": "Session Settings", + "actions": { + "patch": { + "label": "update", + "detailKeys": [ + "sessionKey", + "label", + "pinned", + "archived", + "model", + "thinkingLevel" + ] + }, + "group_list": { + "label": "groups" + }, + "group_set": { + "label": "set groups", + "detailKeys": [ + "names" + ] + }, + "group_rename": { + "label": "rename group", + "detailKeys": [ + "name", + "to" + ] + }, + "group_delete": { + "label": "delete group", + "detailKeys": [ + "name" + ] + } + } + }, "sessions_list": { "emoji": "🗂️", "title": "Sessions", diff --git a/scripts/deadcode-exports.baseline.mjs b/scripts/deadcode-exports.baseline.mjs index e42264e9eb61..d841075ba5a8 100644 --- a/scripts/deadcode-exports.baseline.mjs +++ b/scripts/deadcode-exports.baseline.mjs @@ -161,6 +161,7 @@ export const KNIP_UNUSED_EXPORT_BASELINE = [ "src/agents/openai-completions-compat.ts: resolveOpenAICompletionsCompatDefaults", "src/agents/openai-transport-stream.ts: testing", "src/agents/run-wait.ts: testing", + "src/agents/session-model-auto-revert.ts: reconcileAgentPatchedSessionModel", "src/agents/session-suspension.ts: testing", "src/agents/session-write-lock.ts: resetSessionWriteLockStateForTest", "src/agents/session-write-lock.ts: testing", @@ -201,6 +202,7 @@ export const KNIP_UNUSED_EXPORT_BASELINE = [ "src/agents/tools/model-config.helpers.ts: hasDirectProviderApiKeyAuthForTool", "src/agents/tools/sessions-resolution.ts: testing", "src/agents/tools/sessions-send-tool.a2a.ts: testing", + "src/agents/tools/sessions-spawn-visible-admission.ts: VisibleChildReservation", "src/agents/tools/video-generate-tool.ts: resolveVideoGenerationModelConfigForTool", "src/agents/tools/web-fetch.ts: sanitizeWebFetchUrl", "src/agents/utils/tools-manager.ts: testing", @@ -345,6 +347,7 @@ export const KNIP_UNUSED_EXPORT_BASELINE = [ "src/skills/lifecycle/upload-store.ts: createSkillUploadStore", "src/skills/runtime/refresh.ts: resetSkillsRefreshForTest", "src/skills/runtime/remote-skills.ts: resetRemoteNodeSkillsForTests", + "src/status/status-runtime-lines.ts: resolveSessionCostLine", "src/system-agent/agent-turn.ts: runSystemAgentTurnWithDeps", "src/tasks/detached-task-runtime.ts: resetDetachedTaskLifecycleRuntimeForTests", "src/tasks/detached-task-runtime.ts: setDetachedTaskLifecycleRuntime", diff --git a/src/agents/core-tool-factory-descriptors.ts b/src/agents/core-tool-factory-descriptors.ts index a688a3d79403..f849eb671373 100644 --- a/src/agents/core-tool-factory-descriptors.ts +++ b/src/agents/core-tool-factory-descriptors.ts @@ -30,6 +30,7 @@ export const CORE_TOOL_FACTORY_DESCRIPTORS = [ { name: "nodes", family: "openclaw" }, { name: "pdf", family: "openclaw" }, { name: "session_status", family: "openclaw" }, + { name: "sessions", family: "openclaw" }, { name: "sessions_history", family: "openclaw" }, { name: "sessions_list", family: "openclaw" }, { name: "sessions_search", family: "openclaw" }, diff --git a/src/agents/openclaw-tools.ts b/src/agents/openclaw-tools.ts index 1d1f856ce85b..38da81fa763d 100644 --- a/src/agents/openclaw-tools.ts +++ b/src/agents/openclaw-tools.ts @@ -46,7 +46,7 @@ import type { AnyAgentTool } from "./tools/common.js"; import { createComputerTool } from "./tools/computer-tool.js"; import { createCronTool, type CronCreatorToolAllowlistEntry } from "./tools/cron-tool.js"; import { createEmbeddedCallGateway } from "./tools/embedded-gateway-stub.js"; -import { wrapToolWithGatewayCallerIdentity } from "./tools/gateway-caller-context.js"; +import { createGatewayToolCallerWrapper } from "./tools/gateway-caller-context.js"; import { createGatewayTool } from "./tools/gateway-tool.js"; import { createCreateGoalTool, @@ -66,6 +66,7 @@ import { createSessionsListTool } from "./tools/sessions-list-tool.js"; import { createSessionsSearchTool } from "./tools/sessions-search-tool.js"; import { createSessionsSendTool } from "./tools/sessions-send-tool.js"; import { createSessionsSpawnTool } from "./tools/sessions-spawn-tool.js"; +import { createSessionsTool } from "./tools/sessions-tool.js"; import { createSessionsYieldTool } from "./tools/sessions-yield-tool.js"; import { createConfiguredSkillWorkshopTool } from "./tools/skill-workshop-tool-factory.js"; import { createSubagentsTool } from "./tools/subagents-tool.js"; @@ -477,6 +478,11 @@ export function createOpenClawTools( ? { selfRemoveOnlyJobId: options.cronSelfRemoveOnlyJobId } : {}), }), + createSessionsTool({ + agentSessionKey: options?.runSessionKey ?? options?.agentSessionKey, + sandboxed: options?.sandboxed, + config: resolvedConfig, + }), ]), ...(!embedded && taskSuggestionSessionKey && options?.taskSuggestionDeliveryMode === "gateway" ? createTaskSuggestionTools({ @@ -578,6 +584,9 @@ export function createOpenClawTools( agentAccountId: options?.agentAccountId, agentTo: options?.agentTo, agentThreadId: options?.agentThreadId, + currentMessagingTarget: options?.currentMessagingTarget, + currentChannelId: options?.currentChannelId, + currentThreadTs: options?.currentThreadTs, agentGroupId: options?.agentGroupId, agentGroupChannel: options?.agentGroupChannel, agentGroupSpace: options?.agentGroupSpace, @@ -597,6 +606,7 @@ export function createOpenClawTools( }), createSubagentsTool({ agentSessionKey: options?.agentSessionKey, + config: resolvedConfig, }), createSessionStatusTool({ agentSessionKey: options?.agentSessionKey, @@ -636,20 +646,7 @@ export function createOpenClawTools( options?.recordToolPrepStage?.("openclaw-tools:client-capabilities"); const hookAgentId = options?.requesterAgentIdOverride ?? sessionAgentId; - const gatewayCallerIdentity = - hookAgentId && options?.agentSessionKey?.trim() - ? { - agentId: hookAgentId, - sessionKey: options.agentSessionKey.trim(), - turnSourceChannel: options.agentChannel, - turnSourceTo: - options.currentMessagingTarget ?? options.currentChannelId ?? options.agentTo, - turnSourceAccountId: options.agentAccountId, - turnSourceThreadId: options.currentThreadTs ?? options.agentThreadId, - } - : undefined; - const wrapGatewayCallerIdentity = (tool: AnyAgentTool) => - wrapToolWithGatewayCallerIdentity(tool, gatewayCallerIdentity); + const wrapGatewayCallerIdentity = createGatewayToolCallerWrapper(hookAgentId, options); if (options?.wrapBeforeToolCallHook === false) { return allTools.map(wrapGatewayCallerIdentity); diff --git a/src/agents/session-model-auto-revert.ts b/src/agents/session-model-auto-revert.ts new file mode 100644 index 000000000000..2d2982fb7192 --- /dev/null +++ b/src/agents/session-model-auto-revert.ts @@ -0,0 +1,211 @@ +/** One-run rollback for agent-selected session models. */ +import { + appendTranscriptMessage, + loadSessionEntry, + patchSessionEntry, +} from "../config/sessions/session-accessor.js"; +import { + createAgentPatchedSessionModelFallback, + type AgentPatchedSessionModelFallback, +} from "../config/sessions/session-model-fallback.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import type { FailoverReason } from "./embedded-agent-helpers/types.js"; +import { resolveFailoverReasonFromError } from "./failover-error.js"; +import { resolveSessionModelRef } from "./session-model-ref.js"; + +// Revert only when the chosen model is definitively unusable. Transient +// provider states (rate_limit/overloaded/timeout/server_error) hit working +// models too; reverting on them would undo a valid choice. +const REVERT_REASONS = new Set([ + "auth", + "auth_permanent", + "billing", + "model_not_found", +]); + +type SessionModelRunOutcome = + | { success: true } + | { success: false; error?: unknown; reason?: FailoverReason }; + +export async function reconcileAgentPatchedSessionModel(params: { + cfg: OpenClawConfig; + agentId?: string; + sessionKey: string; + storePath?: string; + outcome: SessionModelRunOutcome; + expectedMarkerTs?: number; + validatedFallback?: AgentPatchedSessionModelFallback; + now?: number; +}): Promise<"cleared" | "promoted" | "reverted" | "kept" | "none"> { + const reason = params.outcome.success + ? undefined + : (params.outcome.reason ?? resolveFailoverReasonFromError(params.outcome.error)); + if (!params.outcome.success && (!reason || !REVERT_REASONS.has(reason))) { + return "kept"; + } + + let note: string | undefined; + let sessionId: string | undefined; + let result: "cleared" | "promoted" | "reverted" | "none" = "none"; + await patchSessionEntry( + { + agentId: params.agentId, + sessionKey: params.sessionKey, + storePath: params.storePath, + }, + (entry) => { + const marker = entry.modelFallback; + if (marker?.source !== "agent-patch") { + return null; + } + if (params.expectedMarkerTs !== undefined && marker.ts !== params.expectedMarkerTs) { + if ( + params.outcome.success && + params.validatedFallback && + marker.ts > params.expectedMarkerTs && + params.expectedMarkerTs > (marker.lastValidatedPatchTs ?? -1) + ) { + result = "promoted"; + return { + modelFallback: { + ...params.validatedFallback, + ts: marker.ts, + lastValidatedPatchTs: params.expectedMarkerTs, + }, + }; + } + return null; + } + sessionId = entry.sessionId; + if (params.outcome.success) { + result = "cleared"; + return { modelFallback: undefined }; + } + const failed = resolveSessionModelRef(params.cfg, entry, params.agentId); + result = "reverted"; + note = `System note: model ${failed.provider}/${failed.model} failed; reverted to ${marker.prevProvider}/${marker.prevModel}.`; + return { + model: marker.prevModel, + modelProvider: marker.prevProvider, + modelOverride: marker.prevModelOverride, + providerOverride: marker.prevProviderOverride, + modelOverrideSource: marker.prevModelOverrideSource, + modelOverrideFallbackOriginProvider: marker.prevModelOverrideFallbackOriginProvider, + modelOverrideFallbackOriginModel: marker.prevModelOverrideFallbackOriginModel, + authProfileOverride: marker.prevAuthProfileOverride, + authProfileOverrideSource: marker.prevAuthProfileOverrideSource, + authProfileOverrideCompactionCount: marker.prevAuthProfileOverrideCompactionCount, + thinkingLevel: marker.prevThinkingLevel, + modelFallback: undefined, + liveModelSwitchPending: undefined, + }; + }, + ); + if (note && sessionId) { + try { + const timestamp = params.now ?? Date.now(); + await appendTranscriptMessage( + { + agentId: params.agentId, + sessionId, + sessionKey: params.sessionKey, + storePath: params.storePath, + }, + { + config: params.cfg, + message: { + role: "custom" as const, + customType: "openclaw.system-note", + content: note, + display: true, + timestamp, + }, + ...(params.now === undefined ? {} : { now: params.now }), + }, + ); + } catch { + // Rollback is authoritative; transcript note is best effort. + } + } + return result; +} + +export function createAgentPatchedSessionModelRunGuard(params: { + cfg: OpenClawConfig; + agentId: string | undefined; + sessionKey: string | undefined; + storePath: string | undefined; + onError?: (error: unknown) => void; +}) { + let markerTs: number | undefined; + let validatedFallback: AgentPatchedSessionModelFallback | undefined; + if (params.sessionKey) { + try { + const entry = loadSessionEntry({ + agentId: params.agentId, + sessionKey: params.sessionKey, + storePath: params.storePath, + }); + const marker = entry?.modelFallback; + markerTs = marker?.source === "agent-patch" ? marker.ts : undefined; + if (entry && markerTs !== undefined) { + const current = resolveSessionModelRef(params.cfg, entry, params.agentId); + validatedFallback = createAgentPatchedSessionModelFallback({ + model: current.model, + provider: current.provider, + entry, + ts: markerTs, + }); + } + } catch { + markerTs = undefined; + } + } + let failure: { error?: unknown; reason?: FailoverReason } = {}; + let reconciled = false; + const captureFailure = (error: unknown, reason?: string) => { + const classifiedReason = reason + ? (reason as FailoverReason) + : resolveFailoverReasonFromError(error); + const revertReason = + classifiedReason && REVERT_REASONS.has(classifiedReason) ? classifiedReason : undefined; + failure = { error, ...(revertReason ? { reason: revertReason } : {}) }; + return revertReason !== undefined; + }; + const captureFallbackFailure = ( + attempts: readonly { error: string; reason?: string }[], + ): boolean | undefined => { + const attempt = attempts[0]; + return attempt ? captureFailure(new Error(attempt.error), attempt.reason) : undefined; + }; + const reconcile = async (success: boolean) => { + if (reconciled || !params.sessionKey || markerTs === undefined) { + return; + } + reconciled = true; + try { + await reconcileAgentPatchedSessionModel({ + cfg: params.cfg, + ...(params.agentId ? { agentId: params.agentId } : {}), + sessionKey: params.sessionKey, + ...(params.storePath ? { storePath: params.storePath } : {}), + expectedMarkerTs: markerTs, + ...(validatedFallback ? { validatedFallback } : {}), + outcome: success ? { success: true } : { success: false, ...failure }, + }); + } catch (error) { + params.onError?.(error); + } + }; + return { + captureFailure, + captureFallbackFailure, + async fail(error: unknown, reason?: string) { + captureFailure(error, reason); + await reconcile(false); + }, + async finish(success: boolean) { + await reconcile(success); + }, + }; +} diff --git a/src/agents/subagent-depth.test.ts b/src/agents/subagent-depth.test.ts index a0c96e303cae..9d492a6b1dbd 100644 --- a/src/agents/subagent-depth.test.ts +++ b/src/agents/subagent-depth.test.ts @@ -59,6 +59,20 @@ describe("getSubagentDepthFromSessionStore", () => { expect(depth).toBe(3); }); + it("derives visible dashboard depth from parentSessionKey", () => { + const depth = getSubagentDepthFromSessionStore("agent:main:dashboard:child", { + store: { + "agent:main:main": { sessionId: "root" }, + "agent:main:dashboard:child": { + sessionId: "child", + parentSessionKey: "agent:main:main", + }, + }, + }); + + expect(depth).toBe(1); + }); + it("resolves depth when caller is identified by sessionId", () => { const key1 = "agent:main:subagent:one"; const key2 = "agent:main:subagent:two"; @@ -100,6 +114,41 @@ describe("getSubagentDepthFromSessionStore", () => { expect(depth).toBe(2); }); + + it("resolves a cross-agent parent outside the supplied child store", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-subagent-depth-cross-agent-")); + try { + const storeTemplate = path.join(tmpDir, "sessions-{agentId}.json"); + const parentKey = "agent:main:dashboard:parent"; + await replaceSessionEntry( + { + agentId: "main", + storePath: storeTemplate.replaceAll("{agentId}", "main"), + sessionKey: parentKey, + }, + { + sessionId: "parent", + updatedAt: Date.now(), + spawnDepth: 2, + }, + ); + + const depth = getSubagentDepthFromSessionStore("agent:work:dashboard:child", { + cfg: { session: { store: storeTemplate } }, + store: { + "agent:work:dashboard:child": { + sessionId: "child", + parentSessionKey: parentKey, + }, + }, + }); + + expect(depth).toBe(3); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + it("falls back to session-key segment counting when metadata is missing", () => { const key = "agent:main:subagent:flat"; const depth = getSubagentDepthFromSessionStore(key, { diff --git a/src/agents/subagent-depth.ts b/src/agents/subagent-depth.ts index 647adaa9c247..58f249c25722 100644 --- a/src/agents/subagent-depth.ts +++ b/src/agents/subagent-depth.ts @@ -15,6 +15,7 @@ type SessionDepthEntry = { sessionId?: unknown; spawnDepth?: unknown; spawnedBy?: unknown; + parentSessionKey?: unknown; }; function normalizeSpawnDepth(value: unknown): number | undefined { @@ -88,7 +89,10 @@ function resolveEntryForSessionKey(params: { return entry; } } - return findEntryBySessionId(params.store, params.sessionKey); + const entry = findEntryBySessionId(params.store, params.sessionKey); + if (entry || !params.cfg) { + return entry; + } } if (!params.cfg) { @@ -153,17 +157,18 @@ export function getSubagentDepthFromSessionStore( return storedDepth; } - const spawnedBy = normalizeOptionalString(entry?.spawnedBy); - if (!spawnedBy) { + const parentKey = + normalizeOptionalString(entry?.spawnedBy) ?? normalizeOptionalString(entry?.parentSessionKey); + if (!parentKey) { return undefined; } - const parentDepth = depthFromStore(spawnedBy); + const parentDepth = depthFromStore(parentKey); if (parentDepth !== undefined) { return parentDepth + 1; } - return getSubagentDepth(spawnedBy) + 1; + return getSubagentDepth(parentKey) + 1; }; return depthFromStore(raw) ?? fallbackDepth; diff --git a/src/agents/tool-catalog.test.ts b/src/agents/tool-catalog.test.ts index 663701d07b30..3bb6fbc22a5a 100644 --- a/src/agents/tool-catalog.test.ts +++ b/src/agents/tool-catalog.test.ts @@ -37,6 +37,7 @@ describe("tool-catalog", () => { "x_search", "memory_search", "memory_get", + "sessions", "sessions_list", "sessions_history", "sessions_search", @@ -64,6 +65,7 @@ describe("tool-catalog", () => { it("includes bundle MCP tools in coding and messaging profile policies", () => { expect(requirePolicyAllow("coding").at(-1)).toBe("bundle-mcp"); expect(requirePolicyAllow("messaging")).toEqual([ + "sessions", "sessions_list", "sessions_history", "sessions_search", diff --git a/src/agents/tool-catalog.ts b/src/agents/tool-catalog.ts index 8f0fbc0582ea..77853206279a 100644 --- a/src/agents/tool-catalog.ts +++ b/src/agents/tool-catalog.ts @@ -151,6 +151,14 @@ const CORE_TOOL_DEFINITIONS: CoreToolDefinition[] = [ profiles: ["coding"], includeInOpenClawGroup: true, }, + { + id: "sessions", + label: "sessions", + description: "Session settings and groups", + sectionId: "sessions", + profiles: ["coding", "messaging"], + includeInOpenClawGroup: true, + }, { id: "sessions_list", label: "sessions_list", @@ -202,7 +210,7 @@ const CORE_TOOL_DEFINITIONS: CoreToolDefinition[] = [ { id: "subagents", label: "subagents", - description: "Manage sub-agents", + description: "Background work: subagents, media gen, cron runs. list/cancel.", sectionId: "sessions", profiles: ["coding"], includeInOpenClawGroup: true, diff --git a/src/agents/tool-display-config.ts b/src/agents/tool-display-config.ts index dbc4ea1dbe72..027517b5812d 100644 --- a/src/agents/tool-display-config.ts +++ b/src/agents/tool-display-config.ts @@ -340,6 +340,20 @@ export const TOOL_DISPLAY_CONFIG: ToolDisplayConfig = { title: "Session Status", detailKeys: ["sessionKey", "model"], }, + sessions: { + emoji: "🗂️", + title: "Session Settings", + actions: { + patch: { + label: "update", + detailKeys: ["sessionKey", "label", "pinned", "archived", "model", "thinkingLevel"], + }, + group_list: { label: "groups" }, + group_set: { label: "set groups", detailKeys: ["names"] }, + group_rename: { label: "rename group", detailKeys: ["name", "to"] }, + group_delete: { label: "delete group", detailKeys: ["name"] }, + }, + }, sessions_list: { emoji: "🗂️", title: "Sessions", diff --git a/src/agents/tool-mutation-names.ts b/src/agents/tool-mutation-names.ts new file mode 100644 index 000000000000..d09dfa482453 --- /dev/null +++ b/src/agents/tool-mutation-names.ts @@ -0,0 +1,33 @@ +import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; + +const MUTATING_TOOL_NAMES = new Set([ + "write", + "edit", + "apply_patch", + "exec", + "bash", + "process", + "message", + "sessions", + "sessions_spawn", + "sessions_send", + "cron", + "gateway", + "canvas", + "computer", + "nodes", + "session_status", + "create_goal", + "update_goal", +]); + +export function isLikelyMutatingToolName(toolName: string): boolean { + const normalized = normalizeLowercaseStringOrEmpty(toolName); + return Boolean( + normalized && + (MUTATING_TOOL_NAMES.has(normalized) || + normalized.endsWith("_actions") || + normalized.startsWith("message_") || + normalized.includes("send")), + ); +} diff --git a/src/agents/tool-mutation.test.ts b/src/agents/tool-mutation.test.ts index 45ed009896da..31228fbedd90 100644 --- a/src/agents/tool-mutation.test.ts +++ b/src/agents/tool-mutation.test.ts @@ -130,13 +130,14 @@ describe("tool mutation helpers", () => { buildToolMutationState("message", { action: "send", to: "forum:1" }).mutatingAction, ).toBe(true); expect(buildToolMutationState("browser", { action: "list" }).mutatingAction).toBe(false); - expect( - buildToolMutationState("subagents", { action: "kill", target: "worker-1" }).mutatingAction, - ).toBe(true); - expect( - buildToolMutationState("subagents", { action: "steer", target: "worker-1" }).mutatingAction, - ).toBe(true); + for (const action of ["cancel", "kill", "steer"]) { + expect( + buildToolMutationState("subagents", { action, target: "worker-1" }).mutatingAction, + ).toBe(true); + } expect(buildToolMutationState("subagents", { action: "list" }).mutatingAction).toBe(false); + expect(buildToolMutationState("sessions", { action: "group_list" }).mutatingAction).toBe(false); + expect(buildToolMutationState("sessions", { action: "patch" }).mutatingAction).toBe(true); expect( buildToolMutationState("sessions_spawn", { task: "inspect the failure" }).mutatingAction, ).toBe(true); @@ -477,6 +478,7 @@ describe("tool mutation helpers", () => { it("keeps legacy name-only mutating heuristics for payload fallback", () => { expect(isLikelyMutatingToolName("sessions_spawn")).toBe(true); expect(isLikelyMutatingToolName("sessions_send")).toBe(true); + expect(isLikelyMutatingToolName("sessions")).toBe(true); expect(isLikelyMutatingToolName("computer")).toBe(true); expect(isLikelyMutatingToolName("browser_actions")).toBe(true); expect(isLikelyMutatingToolName("message_slack")).toBe(true); diff --git a/src/agents/tool-mutation.ts b/src/agents/tool-mutation.ts index 7d607174215a..e2395ac60724 100644 --- a/src/agents/tool-mutation.ts +++ b/src/agents/tool-mutation.ts @@ -8,26 +8,9 @@ import { normalizeLowercaseStringOrEmpty, normalizeOptionalLowercaseString, } from "@openclaw/normalization-core/string-coerce"; +import { isLikelyMutatingToolName } from "./tool-mutation-names.js"; -const MUTATING_TOOL_NAMES = new Set([ - "write", - "edit", - "apply_patch", - "exec", - "bash", - "process", - "message", - "sessions_spawn", - "sessions_send", - "cron", - "gateway", - "canvas", - "computer", - "nodes", - "session_status", - "create_goal", - "update_goal", -]); +export { isLikelyMutatingToolName }; // File-mutation tools that operate on the same `path` target identity. // Recovery is allowed across these even when the tool name differs (e.g. @@ -348,19 +331,6 @@ function appendFingerprintAlias( return false; } -export function isLikelyMutatingToolName(toolName: string): boolean { - const normalized = normalizeLowercaseStringOrEmpty(toolName); - if (!normalized) { - return false; - } - return ( - MUTATING_TOOL_NAMES.has(normalized) || - normalized.endsWith("_actions") || - normalized.startsWith("message_") || - normalized.includes("send") - ); -} - export function isMutatingToolCall(toolName: string, args: unknown): boolean { const normalized = normalizeLowercaseStringOrEmpty(toolName); const record = asRecord(args); @@ -384,10 +354,12 @@ export function isMutatingToolCall(toolName: string, args: unknown): boolean { // Message actions are an extensible plugin surface. Only known lookup // actions are replay-safe; missing and future actions fail closed. return action == null || !MESSAGE_READ_ONLY_ACTIONS.has(action); + case "sessions": + return action !== "group_list"; case "computer": return action == null || !COMPUTER_REPLAY_SAFE_ACTIONS.has(action); case "subagents": - return action === "kill" || action === "steer"; + return action === "cancel" || action === "kill" || action === "steer"; case "session_status": return typeof record?.model === "string" && record.model.trim().length > 0; case "gateway": @@ -427,6 +399,8 @@ export function isReplaySafeToolCall(toolName: string, args: unknown): boolean { return action != null && MESSAGE_READ_ONLY_ACTIONS.has(action); case "subagents": return action == null || action === "list"; + case "sessions": + return action === "group_list"; case "session_status": return !isMutatingToolCall(normalized, args); case "browser": diff --git a/src/agents/tools/gateway-caller-context.ts b/src/agents/tools/gateway-caller-context.ts index b759d4806881..930f63af3279 100644 --- a/src/agents/tools/gateway-caller-context.ts +++ b/src/agents/tools/gateway-caller-context.ts @@ -16,6 +16,17 @@ type GatewayToolCallerIdentity = { turnSourceThreadId?: string | number; }; +type GatewayToolCallerSource = { + agentSessionKey?: string; + agentChannel?: string; + currentMessagingTarget?: string; + currentChannelId?: string; + agentTo?: string; + agentAccountId?: string; + currentThreadTs?: string; + agentThreadId?: string | number; +}; + const gatewayToolCallerStorage = new AsyncLocalStorage(); export function getGatewayToolCallerIdentity(): GatewayToolCallerIdentity | undefined { @@ -66,3 +77,21 @@ export function wrapToolWithGatewayCallerIdentity( copyToolTerminalPresentation(tool, wrapped); return wrapped; } + +export function createGatewayToolCallerWrapper( + agentId: string | undefined, + source: GatewayToolCallerSource | undefined, +): (tool: AnyAgentTool) => AnyAgentTool { + const identity = + agentId && source?.agentSessionKey?.trim() + ? { + agentId, + sessionKey: source.agentSessionKey.trim(), + turnSourceChannel: source.agentChannel, + turnSourceTo: source.currentMessagingTarget ?? source.currentChannelId ?? source.agentTo, + turnSourceAccountId: source.agentAccountId, + turnSourceThreadId: source.currentThreadTs ?? source.agentThreadId, + } + : undefined; + return (tool) => wrapToolWithGatewayCallerIdentity(tool, identity); +} diff --git a/src/agents/tools/in-process-gateway.ts b/src/agents/tools/in-process-gateway.ts new file mode 100644 index 000000000000..cc6acd24909d --- /dev/null +++ b/src/agents/tools/in-process-gateway.ts @@ -0,0 +1,30 @@ +/** In-process Gateway calls for built-in agent tools. */ +import { resolveLeastPrivilegeOperatorScopesForMethod } from "../../gateway/method-scopes.js"; +import { + dispatchGatewayMethodInProcess, + hasInProcessGatewayContext, +} from "../../gateway/server-plugins.js"; +import { callGatewayTool } from "./gateway.js"; + +export type InProcessGatewayCaller = >( + method: string, + params: Record, +) => Promise; + +export function hasInProcessGatewayToolContext(): boolean { + return hasInProcessGatewayContext(); +} + +export const callInProcessGatewayTool: InProcessGatewayCaller = async ( + method: string, + params: Record, +): Promise => { + const scopes = resolveLeastPrivilegeOperatorScopesForMethod(method, params); + if (hasInProcessGatewayContext()) { + return await dispatchGatewayMethodInProcess(method, params, { + forceSyntheticClient: true, + syntheticScopes: scopes, + }); + } + return await callGatewayTool(method, {}, params, { scopes }); +}; diff --git a/src/agents/tools/sessions-spawn-tool.test.ts b/src/agents/tools/sessions-spawn-tool.test.ts index bf710da5a183..3d5f9de881f7 100644 --- a/src/agents/tools/sessions-spawn-tool.test.ts +++ b/src/agents/tools/sessions-spawn-tool.test.ts @@ -1,6 +1,9 @@ // sessions_spawn tool tests cover model-visible schema gating, ACP/subagent // dispatch, and result details for spawned child sessions. +import path from "node:path"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { upsertSessionEntry } from "../../config/sessions/session-accessor.js"; +import { withTempDir } from "../../test-helpers/temp-dir.js"; const hoisted = vi.hoisted(() => { const spawnSubagentDirectMock = vi.fn(); @@ -248,6 +251,454 @@ describe("sessions_spawn tool", () => { expect(schema.properties?.timeoutSeconds).toBeUndefined(); }); + it("advertises visible sessions with terse UI guidance", () => { + const tool = createSessionsSpawnTool(); + const schema = tool.parameters as { + properties?: { visible?: { description?: string }; worktree?: unknown }; + }; + + expect(schema.properties?.visible?.description).toBe( + "visible: user sees session in UI. Use when user asked or talks via web/app.", + ); + expect(schema.properties?.worktree).toBeDefined(); + }); + + it("creates visible worktree sessions and registers completion announce", async () => { + await withTempDir({ prefix: "openclaw-visible-spawn-" }, async (dir) => { + const callGateway = vi.fn(async () => ({ + key: "agent:main:dashboard:child", + runStarted: true, + runId: "run-visible", + })); + const registerRun = vi.fn(); + const tool = createSessionsSpawnTool({ + agentSessionKey: "agent:main:main", + agentChannel: "slack", + agentTo: "channel:C-stale", + agentThreadId: "stale-thread", + currentMessagingTarget: "channel:C-current", + currentChannelId: "C-native", + currentThreadTs: "current-thread", + config: { + session: { store: path.join(dir, "sessions.json") }, + agents: { + defaults: { + subagents: { model: "openai/gpt-5.4", runTimeoutSeconds: 120 }, + }, + list: [{ id: "main" }], + }, + }, + callGateway: callGateway as never, + registerRun, + countActiveRuns: () => 0, + }); + + const result = await tool.execute("visible", { + task: "inspect issue", + label: "Issue review", + visible: true, + worktree: true, + worktreeName: "issue-review", + worktreeBaseRef: "main", + cleanup: "delete", + }); + + expect(result.details).toMatchObject({ + status: "accepted", + childSessionKey: "agent:main:dashboard:child", + runId: "run-visible", + cleanup: "keep", + }); + expect(callGateway).toHaveBeenCalledWith("sessions.create", { + agentId: "main", + label: "Issue review", + model: "openai/gpt-5.4", + task: "inspect issue", + parentSessionKey: "agent:main:main", + worktree: true, + worktreeName: "issue-review", + worktreeBaseRef: "main", + }); + expect(registerRun).toHaveBeenCalledWith( + expect.objectContaining({ + runId: "run-visible", + childSessionKey: "agent:main:dashboard:child", + requesterSessionKey: "agent:main:main", + requesterOrigin: { + channel: "slack", + to: "channel:C-current", + threadId: "current-thread", + }, + cleanup: "keep", + runTimeoutSeconds: 120, + expectsCompletionMessage: true, + spawnMode: "run", + }), + ); + expect(hoisted.spawnSubagentDirectMock).not.toHaveBeenCalled(); + }); + }); + + it("requires visible sessions for worktree options", async () => { + const tool = createSessionsSpawnTool({ agentSessionKey: "agent:main:main" }); + + await expect( + tool.execute("hidden-worktree", { task: "inspect", worktree: true }), + ).rejects.toThrow("worktree options require visible=true"); + expect(hoisted.spawnSubagentDirectMock).not.toHaveBeenCalled(); + }); + + it("uses the target agent model for cross-agent visible sessions", async () => { + const callGateway = vi.fn(async () => ({ + key: "agent:reviewer:dashboard:child", + runStarted: true, + runId: "run-reviewer", + })); + const tool = createSessionsSpawnTool({ + agentSessionKey: "agent:main:main", + config: { + agents: { + defaults: { subagents: { allowAgents: ["reviewer"] } }, + list: [ + { id: "main" }, + { id: "reviewer", subagents: { model: "anthropic/claude-sonnet-4-6" } }, + ], + }, + }, + callGateway: callGateway as never, + registerRun: vi.fn(), + countActiveRuns: () => 0, + }); + + await tool.execute("visible-reviewer", { + task: "review patch", + agentId: "reviewer", + visible: true, + }); + + expect(callGateway).toHaveBeenCalledWith( + "sessions.create", + expect.objectContaining({ + agentId: "reviewer", + model: "anthropic/claude-sonnet-4-6", + parentSessionKey: "agent:main:main", + }), + ); + }); + + it("denies visible sessions when tool restrictions cannot carry forward", async () => { + const callGateway = vi.fn(); + const tool = createSessionsSpawnTool({ + agentSessionKey: "agent:main:main", + config: { agents: { list: [{ id: "main" }] } }, + inheritedToolDenylist: ["exec"], + callGateway, + }); + + const result = await tool.execute("visible-restricted", { + task: "inspect", + visible: true, + }); + + expect(result.details).toMatchObject({ + status: "forbidden", + error: "Visible sessions unavailable with inherited tool restrictions.", + }); + expect(callGateway).not.toHaveBeenCalled(); + }); + + it("blocks unsandboxed visible targets for a sandboxed caller runtime", async () => { + const callGateway = vi.fn(); + const tool = createSessionsSpawnTool({ + agentSessionKey: "agent:main:main", + sandboxed: true, + config: { agents: { list: [{ id: "main" }] } }, + callGateway, + countActiveRuns: () => 0, + }); + + const result = await tool.execute("visible-sandboxed", { task: "inspect", visible: true }); + + expect(result.details).toMatchObject({ + status: "forbidden", + error: "Sandboxed sessions cannot spawn unsandboxed sessions.", + }); + expect(callGateway).not.toHaveBeenCalled(); + }); + + it("reserves visible child capacity before session creation", async () => { + let resolveCreate!: (value: { key: string; runStarted: true; runId: string }) => void; + const pendingCreate = new Promise<{ + key: string; + runStarted: true; + runId: string; + }>((resolve) => { + resolveCreate = resolve; + }); + const callGateway = vi.fn(async () => await pendingCreate); + const tool = createSessionsSpawnTool({ + agentSessionKey: "agent:main:main", + config: { + agents: { + defaults: { subagents: { maxChildrenPerAgent: 1 } }, + list: [{ id: "main" }], + }, + }, + callGateway: callGateway as never, + registerRun: vi.fn(), + countActiveRuns: () => 0, + }); + + const first = tool.execute("visible-first", { task: "first", visible: true }); + await vi.waitFor(() => expect(callGateway).toHaveBeenCalledTimes(1)); + const second = await tool.execute("visible-second", { task: "second", visible: true }); + + expect(second.details).toMatchObject({ + status: "forbidden", + error: expect.stringContaining("max active children"), + }); + expect(callGateway).toHaveBeenCalledTimes(1); + + resolveCreate({ + key: "agent:main:dashboard:first", + runStarted: true, + runId: "run-first", + }); + await expect(first).resolves.toEqual( + expect.objectContaining({ details: expect.objectContaining({ status: "accepted" }) }), + ); + }); + + it("deletes a visible session whose initial run did not start", async () => { + const callGateway = vi + .fn() + .mockResolvedValueOnce({ + key: "agent:main:dashboard:not-started", + runStarted: false, + runError: "model unavailable", + }) + .mockResolvedValueOnce({ deleted: true }); + const registerRun = vi.fn(); + const tool = createSessionsSpawnTool({ + agentSessionKey: "agent:main:main", + config: { agents: { list: [{ id: "main" }] } }, + callGateway, + registerRun, + countActiveRuns: () => 0, + }); + + const result = await tool.execute("visible-not-started", { + task: "inspect", + visible: true, + }); + + expect(result.details).toMatchObject({ + status: "error", + error: "model unavailable", + childSessionKey: "agent:main:dashboard:not-started", + }); + expect(callGateway).toHaveBeenNthCalledWith(2, "sessions.delete", { + key: "agent:main:dashboard:not-started", + deleteTranscript: true, + emitLifecycleHooks: false, + }); + expect(registerRun).not.toHaveBeenCalled(); + }); + + it("aborts a partially started visible run before deleting its session", async () => { + const callGateway = vi + .fn() + .mockResolvedValueOnce({ + key: "agent:main:dashboard:partial", + runStarted: true, + runError: "missing run id", + }) + .mockResolvedValueOnce({ ok: true, abortedRunId: "run-partial" }) + .mockResolvedValueOnce({ deleted: true }); + const registerRun = vi.fn(); + const tool = createSessionsSpawnTool({ + agentSessionKey: "agent:main:main", + config: { agents: { list: [{ id: "main" }] } }, + callGateway, + registerRun, + countActiveRuns: () => 0, + }); + + const result = await tool.execute("visible-partial", { task: "inspect", visible: true }); + + // A started-but-untrackable run (no run id) is always aborted and deleted, + // never left as a visible orphan the parent cannot cancel. + expect(result.details).toMatchObject({ status: "error" }); + expect((result.details as { childSessionKey?: string }).childSessionKey).toBeUndefined(); + expect(callGateway).toHaveBeenNthCalledWith(2, "sessions.abort", { + key: "agent:main:dashboard:partial", + agentId: "main", + }); + expect(callGateway).toHaveBeenNthCalledWith(3, "sessions.delete", { + key: "agent:main:dashboard:partial", + deleteTranscript: true, + emitLifecycleHooks: false, + }); + expect(registerRun).not.toHaveBeenCalled(); + }); + + it("deletes a started run with no run id even when abort reports nothing stopped", async () => { + const callGateway = vi + .fn() + .mockResolvedValueOnce({ + key: "agent:main:dashboard:untracked", + runStarted: true, + runError: "missing run id", + }) + .mockResolvedValueOnce({ ok: true, abortedRunId: null }) + .mockResolvedValueOnce({ deleted: true }); + const registerRun = vi.fn(); + const tool = createSessionsSpawnTool({ + agentSessionKey: "agent:main:main", + config: { agents: { list: [{ id: "main" }] } }, + callGateway, + registerRun, + countActiveRuns: () => 0, + }); + + const result = await tool.execute("visible-untracked", { task: "inspect", visible: true }); + + expect(result.details).toMatchObject({ status: "error" }); + expect(callGateway).toHaveBeenNthCalledWith(3, "sessions.delete", { + key: "agent:main:dashboard:untracked", + deleteTranscript: true, + emitLifecycleHooks: false, + }); + expect(registerRun).not.toHaveBeenCalled(); + }); + + it("rolls back a visible session when announce registration fails", async () => { + const callGateway = vi + .fn() + .mockResolvedValueOnce({ + key: "agent:main:dashboard:orphan", + runStarted: true, + runId: "run-orphan", + }) + .mockResolvedValueOnce({ ok: true, abortedRunId: "run-orphan" }) + .mockResolvedValueOnce({ deleted: true }); + const tool = createSessionsSpawnTool({ + agentSessionKey: "agent:main:main", + config: { agents: { list: [{ id: "main" }] } }, + callGateway, + registerRun: () => { + throw new Error("registry unavailable"); + }, + countActiveRuns: () => 0, + }); + + const result = await tool.execute("visible-orphan", { task: "inspect", visible: true }); + + expect(result.details).toMatchObject({ status: "error", runId: "run-orphan" }); + expect(callGateway).toHaveBeenNthCalledWith(2, "sessions.abort", { + key: "agent:main:dashboard:orphan", + runId: "run-orphan", + agentId: "main", + }); + expect(callGateway).toHaveBeenNthCalledWith(3, "sessions.delete", { + key: "agent:main:dashboard:orphan", + deleteTranscript: true, + emitLifecycleHooks: false, + }); + }); + + it("keeps a visible session when rollback cannot abort its run", async () => { + const callGateway = vi + .fn() + .mockResolvedValueOnce({ + key: "agent:main:dashboard:live", + runStarted: true, + runId: "run-live", + }) + .mockRejectedValueOnce(new Error("abort unavailable")); + const tool = createSessionsSpawnTool({ + agentSessionKey: "agent:main:main", + config: { agents: { list: [{ id: "main" }] } }, + callGateway, + registerRun: () => { + throw new Error("registry unavailable"); + }, + countActiveRuns: () => 0, + }); + + const result = await tool.execute("visible-live", { task: "inspect", visible: true }); + + expect(result.details).toMatchObject({ + status: "error", + childSessionKey: "agent:main:dashboard:live", + runId: "run-live", + }); + expect(callGateway).toHaveBeenCalledTimes(2); + }); + + it("keeps a visible session when rollback does not confirm its run", async () => { + const callGateway = vi + .fn() + .mockResolvedValueOnce({ + key: "agent:main:dashboard:finished", + runStarted: true, + runId: "run-finished", + }) + .mockResolvedValueOnce({ ok: true, abortedRunId: null, status: "no-active-run" }); + const tool = createSessionsSpawnTool({ + agentSessionKey: "agent:main:main", + config: { agents: { list: [{ id: "main" }] } }, + callGateway, + registerRun: () => { + throw new Error("registry unavailable"); + }, + countActiveRuns: () => 0, + }); + + const result = await tool.execute("visible-finished", { task: "inspect", visible: true }); + + expect(result.details).toMatchObject({ + status: "error", + error: expect.stringContaining("Run abort unconfirmed. Session kept."), + childSessionKey: "agent:main:dashboard:finished", + runId: "run-finished", + }); + expect(callGateway).toHaveBeenCalledTimes(2); + }); + + it("applies spawn depth limits to visible dashboard descendants", async () => { + await withTempDir({ prefix: "openclaw-visible-depth-" }, async (dir) => { + const storePath = path.join(dir, "sessions.json"); + const childKey = "agent:main:dashboard:child"; + await upsertSessionEntry( + { agentId: "main", sessionKey: "agent:main:main", storePath }, + { sessionId: "root", updatedAt: 1 }, + ); + await upsertSessionEntry( + { agentId: "main", sessionKey: childKey, storePath }, + { sessionId: "child", updatedAt: 1, parentSessionKey: "agent:main:main" }, + ); + const callGateway = vi.fn(); + const tool = createSessionsSpawnTool({ + agentSessionKey: childKey, + config: { + session: { store: storePath }, + agents: { + list: [{ id: "main" }], + defaults: { subagents: { maxSpawnDepth: 1 } }, + }, + }, + callGateway, + countActiveRuns: () => 0, + }); + + const result = await tool.execute("visible-depth", { task: "inspect", visible: true }); + + expect(result.details).toMatchObject({ status: "forbidden" }); + expect(callGateway).not.toHaveBeenCalled(); + }); + }); + it("hides thread-bound spawn fields when current channel disables spawnSessions", () => { const tool = createSessionsSpawnTool({ agentChannel: "discord", diff --git a/src/agents/tools/sessions-spawn-tool.ts b/src/agents/tools/sessions-spawn-tool.ts index e4428f766256..8a410af93111 100644 --- a/src/agents/tools/sessions-spawn-tool.ts +++ b/src/agents/tools/sessions-spawn-tool.ts @@ -11,7 +11,6 @@ import { } from "../../channels/thread-bindings-policy.js"; import { getRuntimeConfig } from "../../config/config.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; -import { callGateway } from "../../gateway/call.js"; import { resolveSnakeCaseParamKey } from "../../param-key.js"; import { createLazyImportLoader } from "../../shared/lazy-promise.js"; import { normalizeDeliveryContext } from "../../utils/delivery-context.shared.js"; @@ -45,6 +44,14 @@ import { readStringParam, ToolInputError, } from "./common.js"; +import { + cleanupUntrackedAcpSession, + maybeSpawnVisibleSession, + resolveTrackedSpawnMode, + summarizeSessionsSpawnError, + type VisibleSessionsSpawnDeps, + VISIBLE_SESSIONS_SPAWN_SCHEMA, +} from "./sessions-spawn-visible.js"; const SESSIONS_SPAWN_RUNTIMES = ["subagent", "acp"] as const; const SESSIONS_SPAWN_SANDBOX_MODES = ["inherit", "require"] as const; @@ -75,16 +82,6 @@ async function loadAcpSpawnModule(): Promise { return await acpSpawnModuleLoader.load(); } -function summarizeError(err: unknown): string { - if (err instanceof Error) { - return err.message; - } - if (typeof err === "string") { - return err; - } - return "error"; -} - function addRoleToFailureResult( result: T, role: string | undefined, @@ -95,36 +92,6 @@ function addRoleToFailureResult( return { ...result, role }; } -function resolveTrackedSpawnMode(params: { - requestedMode?: "run" | "session"; - threadRequested: boolean; -}): "run" | "session" { - if (params.requestedMode === "run" || params.requestedMode === "session") { - return params.requestedMode; - } - return params.threadRequested ? "session" : "run"; -} - -async function cleanupUntrackedAcpSession(sessionKey: string): Promise { - const key = sessionKey.trim(); - if (!key) { - return; - } - try { - await callGateway({ - method: "sessions.delete", - params: { - key, - deleteTranscript: true, - emitLifecycleHooks: false, - }, - timeoutMs: 10_000, - }); - } catch { - // Best-effort cleanup only. - } -} - type SessionsSpawnThreadAvailability = { subagent: boolean; acp: boolean; @@ -200,6 +167,7 @@ function createSessionsSpawnToolSchema(params: { description: "Light bootstrap; subagent only.", }), ), + ...VISIBLE_SESSIONS_SPAWN_SCHEMA, // Inline attachments (snapshot-by-value). attachments: Type.Optional( @@ -255,11 +223,15 @@ export function createSessionsSpawnTool( agentAccountId?: string; agentTo?: string; agentThreadId?: string | number; + currentMessagingTarget?: string; + currentChannelId?: string; + currentThreadTs?: string; sandboxed?: boolean; config?: OpenClawConfig; /** Explicit agent ID override for cron/hook sessions where session key parsing may not work. */ requesterAgentIdOverride?: string; - } & SpawnedToolContext, + } & VisibleSessionsSpawnDeps & + SpawnedToolContext, ): AnyAgentTool { const acpAvailable = isAcpRuntimeSpawnAvailable({ config: opts?.config, @@ -321,6 +293,21 @@ export function createSessionsSpawnTool( const streamTo = runtime === "acp" && params.streamTo === "parent" ? "parent" : undefined; const lightContext = params.lightContext === true; const roleContext = requestedAgentId ? { role: requestedAgentId } : {}; + const visibleResult = await maybeSpawnVisibleSession({ + raw: params, + task, + taskName, + label, + runtime, + requestedAgentId, + sandbox, + options: opts, + }); + if (visibleResult) { + return jsonResult( + addRoleToFailureResult(visibleResult as { status: string }, requestedAgentId), + ); + } if (runtime === "acp" && !acpAvailable) { return jsonResult({ status: "error", @@ -457,7 +444,7 @@ export function createSessionsSpawnTool( await cleanupUntrackedAcpSession(childSessionKey); return jsonResult({ status: "error", - error: `Failed to register ACP run: ${summarizeError(err)}. Cleanup was attempted, but the already-started ACP run may still finish in the background.`, + error: `Failed to register ACP run: ${summarizeSessionsSpawnError(err)}. Cleanup was attempted, but the already-started ACP run may still finish in the background.`, childSessionKey, runId: childRunId, ...roleContext, diff --git a/src/agents/tools/sessions-spawn-visible-admission.ts b/src/agents/tools/sessions-spawn-visible-admission.ts new file mode 100644 index 000000000000..d525f9b86b03 --- /dev/null +++ b/src/agents/tools/sessions-spawn-visible-admission.ts @@ -0,0 +1,35 @@ +/** Process-local admission for visible child starts awaiting registry insertion. */ +const pendingVisibleChildren = new Map(); + +export type VisibleChildReservation = + | { ok: false; activeChildren: number } + | { ok: true; release: () => void }; + +export function reserveVisibleChildSlot(params: { + controllerSessionKey: string; + maxChildren: number; + countActiveRuns: (sessionKey: string) => number; +}): VisibleChildReservation { + const pending = pendingVisibleChildren.get(params.controllerSessionKey) ?? 0; + const activeChildren = params.countActiveRuns(params.controllerSessionKey) + pending; + if (activeChildren >= params.maxChildren) { + return { ok: false, activeChildren }; + } + pendingVisibleChildren.set(params.controllerSessionKey, pending + 1); + let released = false; + return { + ok: true, + release: () => { + if (released) { + return; + } + released = true; + const next = (pendingVisibleChildren.get(params.controllerSessionKey) ?? 1) - 1; + if (next > 0) { + pendingVisibleChildren.set(params.controllerSessionKey, next); + } else { + pendingVisibleChildren.delete(params.controllerSessionKey); + } + }, + }; +} diff --git a/src/agents/tools/sessions-spawn-visible.ts b/src/agents/tools/sessions-spawn-visible.ts new file mode 100644 index 000000000000..b555a8da2ee3 --- /dev/null +++ b/src/agents/tools/sessions-spawn-visible.ts @@ -0,0 +1,343 @@ +import { Type } from "typebox"; +import { + DEFAULT_SUBAGENT_MAX_CHILDREN_PER_AGENT, + DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH, +} from "../../config/agent-limits.js"; +import { getRuntimeConfig } from "../../config/config.js"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { callGateway } from "../../gateway/call.js"; +import { + isValidAgentId, + normalizeAgentId, + parseAgentSessionKey, +} from "../../routing/session-key.js"; +import { normalizeDeliveryContext } from "../../utils/delivery-context.shared.js"; +import type { GatewayMessageChannel } from "../../utils/message-channel.js"; +import { listAgentIds, resolveAgentConfig } from "../agent-scope.js"; +import { resolveSubagentSpawnModelSelection } from "../model-selection.js"; +import { resolveSandboxRuntimeStatus } from "../sandbox/runtime-status.js"; +import { getSubagentDepthFromSessionStore } from "../subagent-depth.js"; +import { countActiveRunsForSession, registerSubagentRun } from "../subagent-registry.js"; +import { resolveSubagentSpawnOwnership } from "../subagent-spawn-ownership.js"; +import { resolveConfiguredSubagentRunTimeoutSeconds } from "../subagent-spawn-plan.js"; +import { resolveSubagentTargetPolicy } from "../subagent-target-policy.js"; +import { normalizeToolModelOverride, readStringParam, ToolInputError } from "./common.js"; +import { callInProcessGatewayTool, type InProcessGatewayCaller } from "./in-process-gateway.js"; +import { reserveVisibleChildSlot } from "./sessions-spawn-visible-admission.js"; + +export const VISIBLE_SESSIONS_SPAWN_SCHEMA = { + visible: Type.Optional( + Type.Boolean({ + description: "visible: user sees session in UI. Use when user asked or talks via web/app.", + }), + ), + worktree: Type.Optional(Type.Boolean({ description: "Visible session worktree" })), + worktreeName: Type.Optional(Type.String({ description: "Worktree name" })), + worktreeBaseRef: Type.Optional(Type.String({ description: "Worktree base ref" })), +}; + +export type VisibleSessionsSpawnDeps = { + callGateway?: InProcessGatewayCaller; + registerRun?: typeof registerSubagentRun; + countActiveRuns?: typeof countActiveRunsForSession; +}; + +type VisibleSessionsSpawnOptions = VisibleSessionsSpawnDeps & { + agentSessionKey?: string; + completionOwnerKey?: string; + agentChannel?: GatewayMessageChannel; + agentAccountId?: string; + agentTo?: string; + agentThreadId?: string | number; + currentMessagingTarget?: string; + currentChannelId?: string; + currentThreadTs?: string; + sandboxed?: boolean; + config?: OpenClawConfig; + requesterAgentIdOverride?: string; + inheritedToolAllowlist?: string[]; + inheritedToolDenylist?: string[]; +}; + +export function summarizeSessionsSpawnError(error: unknown): string { + return error instanceof Error ? error.message : typeof error === "string" ? error : "error"; +} + +export function resolveTrackedSpawnMode(params: { + requestedMode?: "run" | "session"; + threadRequested: boolean; +}): "run" | "session" { + return params.requestedMode ?? (params.threadRequested ? "session" : "run"); +} + +export async function cleanupUntrackedAcpSession(sessionKey: string): Promise { + const key = sessionKey.trim(); + if (!key) { + return; + } + try { + await callGateway({ + method: "sessions.delete", + params: { key, deleteTranscript: true, emitLifecycleHooks: false }, + timeoutMs: 10_000, + }); + } catch { + // Best-effort cleanup only. + } +} + +async function deleteVisibleSession( + gatewayCall: InProcessGatewayCaller, + childSessionKey: string, +): Promise { + try { + await gatewayCall("sessions.delete", { + key: childSessionKey, + deleteTranscript: true, + emitLifecycleHooks: false, + }); + } catch { + // Best-effort rollback only. + } +} + +export async function maybeSpawnVisibleSession(params: { + raw: Record; + task: string; + taskName?: string; + label: string; + runtime: "subagent" | "acp"; + requestedAgentId?: string; + sandbox: "inherit" | "require"; + options?: VisibleSessionsSpawnOptions; +}): Promise | undefined> { + const worktree = params.raw.worktree === true; + const worktreeName = readStringParam(params.raw, "worktreeName"); + const worktreeBaseRef = readStringParam(params.raw, "worktreeBaseRef"); + if (params.raw.visible !== true) { + if (worktree || worktreeName || worktreeBaseRef) { + throw new ToolInputError("worktree options require visible=true"); + } + return undefined; + } + if (params.runtime !== "subagent") { + throw new ToolInputError('visible=true supports runtime="subagent" only'); + } + const unsupported = [ + ["model", normalizeToolModelOverride(readStringParam(params.raw, "model"))], + ["thinking", readStringParam(params.raw, "thinking")], + ["cwd", readStringParam(params.raw, "cwd")], + ["thread", params.raw.thread === true ? true : undefined], + ["mode", params.raw.mode], + ["context", params.raw.context], + ["lightContext", params.raw.lightContext === true ? true : undefined], + ["attachments", Array.isArray(params.raw.attachments) ? params.raw.attachments : undefined], + ["attachAs", params.raw.attachAs], + ] as const; + const unsupportedEntry = unsupported.find(([, value]) => value !== undefined); + if (unsupportedEntry) { + throw new ToolInputError(`${unsupportedEntry[0]} unavailable with visible=true`); + } + + const cfg = params.options?.config ?? getRuntimeConfig(); + if ( + (params.options?.inheritedToolAllowlist?.length ?? 0) > 0 || + (params.options?.inheritedToolDenylist?.length ?? 0) > 0 + ) { + return { + status: "forbidden", + error: "Visible sessions unavailable with inherited tool restrictions.", + }; + } + const ownership = resolveSubagentSpawnOwnership({ + cfg, + agentSessionKey: params.options?.agentSessionKey, + completionOwnerKey: params.options?.completionOwnerKey, + }); + const requesterKey = ownership.controllerSessionKey; + const callerDepth = getSubagentDepthFromSessionStore(requesterKey, { cfg }); + const maxDepth = + cfg.agents?.defaults?.subagents?.maxSpawnDepth ?? DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH; + if (callerDepth >= maxDepth) { + return { + status: "forbidden", + error: `sessions_spawn is not allowed at this depth (current depth: ${callerDepth}, max: ${maxDepth})`, + }; + } + const maxChildren = + cfg.agents?.defaults?.subagents?.maxChildrenPerAgent ?? DEFAULT_SUBAGENT_MAX_CHILDREN_PER_AGENT; + if (params.requestedAgentId && !isValidAgentId(params.requestedAgentId)) { + return { + status: "error", + error: `Invalid agentId "${params.requestedAgentId}". Use agents_list.`, + }; + } + const requesterAgentId = normalizeAgentId( + params.options?.requesterAgentIdOverride ?? parseAgentSessionKey(requesterKey)?.agentId, + ); + const requireAgentId = + resolveAgentConfig(cfg, requesterAgentId)?.subagents?.requireAgentId ?? + cfg.agents?.defaults?.subagents?.requireAgentId ?? + false; + if (requireAgentId && !params.requestedAgentId) { + return { status: "forbidden", error: "sessions_spawn requires agentId. Use agents_list." }; + } + const targetAgentId = params.requestedAgentId + ? normalizeAgentId(params.requestedAgentId) + : requesterAgentId; + const targetPolicy = resolveSubagentTargetPolicy({ + requesterAgentId, + targetAgentId, + requestedAgentId: params.requestedAgentId, + allowAgents: + resolveAgentConfig(cfg, requesterAgentId)?.subagents?.allowAgents ?? + cfg.agents?.defaults?.subagents?.allowAgents, + configuredAgentIds: listAgentIds(cfg), + }); + if (!targetPolicy.ok) { + return { status: "forbidden", error: targetPolicy.error }; + } + const resolvedModel = resolveSubagentSpawnModelSelection({ + cfg, + agentId: targetAgentId, + }); + const runTimeoutSeconds = resolveConfiguredSubagentRunTimeoutSeconds({ cfg }); + const requesterRuntime = resolveSandboxRuntimeStatus({ cfg, sessionKey: requesterKey }); + const childRuntime = resolveSandboxRuntimeStatus({ + cfg, + sessionKey: `agent:${targetAgentId}:dashboard:pending`, + }); + const requesterSandboxed = params.options?.sandboxed === true || requesterRuntime.sandboxed; + if (!childRuntime.sandboxed && (requesterSandboxed || params.sandbox === "require")) { + return { + status: "forbidden", + error: requesterSandboxed + ? "Sandboxed sessions cannot spawn unsandboxed sessions." + : 'sessions_spawn sandbox="require" needs sandboxed target.', + }; + } + + const reservation = reserveVisibleChildSlot({ + controllerSessionKey: requesterKey, + maxChildren, + countActiveRuns: params.options?.countActiveRuns ?? countActiveRunsForSession, + }); + if (!reservation.ok) { + return { + status: "forbidden", + error: `sessions_spawn has reached max active children for this session (${reservation.activeChildren}/${maxChildren})`, + }; + } + try { + const gatewayCall = params.options?.callGateway ?? callInProcessGatewayTool; + const response = await gatewayCall<{ + key?: string; + runStarted?: boolean; + runId?: string; + runError?: unknown; + }>("sessions.create", { + agentId: targetAgentId, + ...(params.label ? { label: params.label } : {}), + model: resolvedModel, + task: params.task, + parentSessionKey: requesterKey, + ...(worktree ? { worktree: true } : {}), + ...(worktreeName ? { worktreeName } : {}), + ...(worktreeBaseRef ? { worktreeBaseRef } : {}), + }); + const childSessionKey = response.key?.trim(); + const runId = response.runId?.trim(); + const runError = response.runError + ? summarizeSessionsSpawnError(response.runError) + : "Visible session run failed"; + if (!childSessionKey) { + return { + status: "error", + error: runError, + }; + } + if (response.runStarted !== true) { + await deleteVisibleSession(gatewayCall, childSessionKey); + return { status: "error", error: runError, childSessionKey }; + } + if (!runId) { + // A started run with no run id is untrackable: it cannot be registered, + // announced, or cancelled, so never leave it as a visible orphan. Abort + // by key to stop whatever is running, then delete the session. + try { + await gatewayCall("sessions.abort", { key: childSessionKey, agentId: targetAgentId }); + } catch { + // Best-effort stop before cleanup. + } + await deleteVisibleSession(gatewayCall, childSessionKey); + return { status: "error", error: runError }; + } + try { + (params.options?.registerRun ?? registerSubagentRun)({ + runId, + childSessionKey, + controllerSessionKey: ownership.controllerSessionKey, + requesterSessionKey: ownership.completionRequesterSessionKey, + requesterOrigin: normalizeDeliveryContext({ + channel: params.options?.agentChannel, + accountId: params.options?.agentAccountId, + to: + params.options?.currentMessagingTarget ?? + params.options?.currentChannelId ?? + params.options?.agentTo, + threadId: params.options?.currentThreadTs ?? params.options?.agentThreadId, + }), + requesterDisplayKey: ownership.completionRequesterDisplayKey, + task: params.task, + taskName: params.taskName, + agentId: targetAgentId, + requesterAgentId: params.options?.requesterAgentIdOverride, + cleanup: "keep", + label: params.label || undefined, + runTimeoutSeconds, + expectsCompletionMessage: params.raw.expectsCompletionMessage !== false, + spawnMode: "run", + }); + } catch (error) { + let abortResponse: { abortedRunId?: string | null }; + try { + abortResponse = await gatewayCall<{ abortedRunId?: string | null }>("sessions.abort", { + key: childSessionKey, + runId, + agentId: targetAgentId, + }); + } catch (abortError) { + return { + status: "error", + error: `Visible run registration failed: ${summarizeSessionsSpawnError(error)}. Run abort failed: ${summarizeSessionsSpawnError(abortError)}. Session kept.`, + childSessionKey, + runId, + }; + } + if (abortResponse.abortedRunId !== runId) { + return { + status: "error", + error: `Visible run registration failed: ${summarizeSessionsSpawnError(error)}. Run abort unconfirmed. Session kept.`, + childSessionKey, + runId, + }; + } + await deleteVisibleSession(gatewayCall, childSessionKey); + return { + status: "error", + error: `Visible run registration failed: ${summarizeSessionsSpawnError(error)}. Run aborted; cleanup attempted.`, + childSessionKey, + runId, + }; + } + return { + status: "accepted", + childSessionKey, + runId, + mode: "run", + cleanup: "keep", + }; + } finally { + reservation.release(); + } +} diff --git a/src/agents/tools/sessions-tool.test.ts b/src/agents/tools/sessions-tool.test.ts new file mode 100644 index 000000000000..6073b3113d7a --- /dev/null +++ b/src/agents/tools/sessions-tool.test.ts @@ -0,0 +1,402 @@ +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + loadSessionEntry, + loadTranscriptEvents, + patchSessionEntry, + upsertSessionEntry, +} from "../../config/sessions/session-accessor.js"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { isAgentSessionModelPatchOrigin } from "../../gateway/session-model-patch-origin.js"; +import { GATEWAY_OWNER_ONLY_CORE_TOOLS } from "../../security/dangerous-tools.js"; +import { withTempDir } from "../../test-helpers/temp-dir.js"; +import { createAgentPatchedSessionModelRunGuard } from "../session-model-auto-revert.js"; +import { testing as sessionsResolutionTesting } from "./sessions-resolution.js"; +import { createSessionsTool } from "./sessions-tool.js"; + +describe("sessions tool", () => { + afterEach(() => { + sessionsResolutionTesting.setDepsForTest(); + }); + + it("uses the core owner gate", () => { + expect(GATEWAY_OWNER_ONLY_CORE_TOOLS).toContain("sessions"); + }); + + it("patches its session, then reverts a failed agent-selected model", async () => { + await withTempDir({ prefix: "openclaw-sessions-tool-" }, async (dir) => { + const storePath = path.join(dir, "sessions.json"); + const sessionKey = "agent:main:main"; + const cfg: OpenClawConfig = { + session: { store: storePath }, + agents: { defaults: { model: { primary: "openai/good" } } }, + }; + await upsertSessionEntry( + { agentId: "main", sessionKey, storePath }, + { + sessionId: "session-main", + updatedAt: 1, + model: "good", + modelProvider: "openai", + modelOverride: "good", + providerOverride: "openai", + modelOverrideSource: "auto", + modelOverrideFallbackOriginProvider: "openai", + modelOverrideFallbackOriginModel: "primary", + authProfileOverride: "good-profile", + authProfileOverrideSource: "user", + thinkingLevel: "high", + }, + ); + const callGateway = vi.fn(async (method: string, params: Record) => { + expect(method).toBe("sessions.patch"); + expect(isAgentSessionModelPatchOrigin()).toBe(true); + await patchSessionEntry({ agentId: "main", sessionKey, storePath }, () => ({ + label: params.label as string, + model: "bad", + modelProvider: "broken", + modelOverride: "bad", + providerOverride: "broken", + modelOverrideSource: "user", + modelOverrideFallbackOriginProvider: undefined, + modelOverrideFallbackOriginModel: undefined, + authProfileOverride: "bad-profile", + authProfileOverrideSource: "user", + thinkingLevel: "low", + modelFallback: { + prevModel: "good", + prevProvider: "openai", + prevModelOverride: "good", + prevProviderOverride: "openai", + prevModelOverrideSource: "auto", + prevModelOverrideFallbackOriginProvider: "openai", + prevModelOverrideFallbackOriginModel: "primary", + prevAuthProfileOverride: "good-profile", + prevAuthProfileOverrideSource: "user", + prevThinkingLevel: "high", + ts: Date.now(), + source: "agent-patch", + }, + })); + return { ok: true }; + }); + const tool = createSessionsTool({ + agentSessionKey: sessionKey, + config: cfg, + callGateway: callGateway as never, + }); + const currentRunGuard = createAgentPatchedSessionModelRunGuard({ + cfg, + agentId: "main", + sessionKey, + storePath, + }); + + await tool.execute("patch-model", { + action: "patch", + label: "Research", + model: "broken/bad", + }); + + expect(callGateway).toHaveBeenCalledWith("sessions.patch", { + key: sessionKey, + label: "Research", + model: "broken/bad", + }); + expect(loadSessionEntry({ agentId: "main", sessionKey, storePath })).toMatchObject({ + label: "Research", + modelFallback: { + prevModel: "good", + prevProvider: "openai", + prevModelOverrideSource: "auto", + prevModelOverrideFallbackOriginProvider: "openai", + prevModelOverrideFallbackOriginModel: "primary", + prevAuthProfileOverride: "good-profile", + prevThinkingLevel: "high", + source: "agent-patch", + }, + }); + await currentRunGuard.finish(true); + expect(loadSessionEntry({ agentId: "main", sessionKey, storePath })).toHaveProperty( + "modelFallback", + ); + + const runGuard = createAgentPatchedSessionModelRunGuard({ + cfg, + agentId: "main", + sessionKey, + storePath, + }); + await runGuard.fail({ status: 404, message: "No endpoints found for broken/bad." }); + expect(loadSessionEntry({ agentId: "main", sessionKey, storePath })).toMatchObject({ + model: "good", + modelProvider: "openai", + modelOverrideSource: "auto", + modelOverrideFallbackOriginProvider: "openai", + modelOverrideFallbackOriginModel: "primary", + authProfileOverride: "good-profile", + thinkingLevel: "high", + }); + expect(loadSessionEntry({ agentId: "main", sessionKey, storePath })).not.toHaveProperty( + "modelFallback", + ); + const events = await loadTranscriptEvents({ + agentId: "main", + sessionId: "session-main", + sessionKey, + storePath, + }); + expect(events).toContainEqual( + expect.objectContaining({ + message: expect.objectContaining({ + customType: "openclaw.system-note", + content: "System note: model broken/bad failed; reverted to openai/good.", + }), + }), + ); + }); + }); + + it("clears the model fallback marker after a successful run", async () => { + await withTempDir({ prefix: "openclaw-sessions-tool-success-" }, async (dir) => { + const storePath = path.join(dir, "sessions.json"); + const sessionKey = "agent:main:main"; + await upsertSessionEntry( + { agentId: "main", sessionKey, storePath }, + { + sessionId: "session-main", + updatedAt: 1, + modelFallback: { + prevModel: "good", + prevProvider: "openai", + ts: 1, + source: "agent-patch", + }, + }, + ); + + await createAgentPatchedSessionModelRunGuard({ + cfg: {}, + agentId: "main", + sessionKey, + storePath, + }).finish(true); + expect(loadSessionEntry({ agentId: "main", sessionKey, storePath })).not.toHaveProperty( + "modelFallback", + ); + }); + }); + + it("denies model patches without in-process gateway context", async () => { + const callGateway = vi.fn(); + const tool = createSessionsTool({ + agentSessionKey: "agent:main:main", + config: {}, + callGateway, + hasInProcessGatewayContext: () => false, + }); + + const result = await tool.execute("patch-model", { + action: "patch", + model: "openai/gpt-5.4", + }); + + expect(result.details).toEqual({ + status: "forbidden", + error: "Model patch needs in-process gateway.", + }); + expect(callGateway).not.toHaveBeenCalled(); + }); + + it("reverts when the patched model fails but a fallback completes the run", async () => { + await withTempDir({ prefix: "openclaw-sessions-tool-fallback-" }, async (dir) => { + const storePath = path.join(dir, "sessions.json"); + const sessionKey = "agent:main:main"; + await upsertSessionEntry( + { agentId: "main", sessionKey, storePath }, + { + sessionId: "session-main", + updatedAt: 1, + model: "bad", + modelProvider: "broken", + modelOverride: "bad", + providerOverride: "broken", + modelFallback: { + prevModel: "good", + prevProvider: "openai", + ts: 1, + source: "agent-patch", + }, + }, + ); + const runGuard = createAgentPatchedSessionModelRunGuard({ + cfg: {}, + agentId: "main", + sessionKey, + storePath, + }); + + const needsRevert = runGuard.captureFallbackFailure([ + { + error: "No endpoints found for broken/bad.", + reason: "model_not_found", + }, + { error: "Fallback context overflow.", reason: "context_overflow" }, + ]); + await runGuard.finish(!needsRevert); + + expect(loadSessionEntry({ agentId: "main", sessionKey, storePath })).toMatchObject({ + model: "good", + modelProvider: "openai", + }); + expect(loadSessionEntry({ agentId: "main", sessionKey, storePath })).not.toHaveProperty( + "modelFallback", + ); + }); + }); + + it("promotes the newest validated model across overlapping patches", async () => { + await withTempDir({ prefix: "openclaw-sessions-tool-overlap-" }, async (dir) => { + const storePath = path.join(dir, "sessions.json"); + const sessionKey = "agent:main:main"; + const cfg: OpenClawConfig = { + agents: { defaults: { model: { primary: "openai/a" } } }, + }; + await upsertSessionEntry( + { agentId: "main", sessionKey, storePath }, + { + sessionId: "session-main", + updatedAt: 1, + model: "b", + modelProvider: "openai", + modelOverride: "b", + providerOverride: "openai", + modelFallback: { + prevModel: "a", + prevProvider: "openai", + ts: 10, + source: "agent-patch", + }, + }, + ); + const runB = createAgentPatchedSessionModelRunGuard({ + cfg, + agentId: "main", + sessionKey, + storePath, + }); + await patchSessionEntry({ agentId: "main", sessionKey, storePath }, () => ({ + model: "c", + modelOverride: "c", + modelFallback: { + prevModel: "a", + prevProvider: "openai", + ts: 20, + source: "agent-patch", + }, + })); + const runC = createAgentPatchedSessionModelRunGuard({ + cfg, + agentId: "main", + sessionKey, + storePath, + }); + await patchSessionEntry({ agentId: "main", sessionKey, storePath }, () => ({ + model: "d", + modelOverride: "d", + modelFallback: { + prevModel: "a", + prevProvider: "openai", + ts: 30, + source: "agent-patch", + }, + })); + const runD = createAgentPatchedSessionModelRunGuard({ + cfg, + agentId: "main", + sessionKey, + storePath, + }); + + await runC.finish(true); + await runB.finish(true); + expect( + loadSessionEntry({ agentId: "main", sessionKey, storePath })?.modelFallback, + ).toMatchObject({ + prevModel: "c", + prevProvider: "openai", + lastValidatedPatchTs: 20, + ts: 30, + }); + + await runD.fail({ status: 404, message: "No endpoints found for openai/d." }); + expect(loadSessionEntry({ agentId: "main", sessionKey, storePath })).toMatchObject({ + model: "c", + modelProvider: "openai", + modelOverride: "c", + providerOverride: "openai", + }); + }); + }); + + it("routes group actions to existing gateway methods", async () => { + const callGateway = vi.fn(async (method: string, params: Record) => ({ + method, + params, + })); + const tool = createSessionsTool({ + agentSessionKey: "agent:main:main", + callGateway: callGateway as never, + }); + + await tool.execute("list", { action: "group_list" }); + await tool.execute("set", { action: "group_set", names: ["Now", "Later"] }); + await tool.execute("rename", { action: "group_rename", name: "Now", to: "Next" }); + await tool.execute("delete", { action: "group_delete", name: "Later" }); + + expect(callGateway.mock.calls).toEqual([ + ["sessions.groups.list", {}], + ["sessions.groups.put", { names: ["Now", "Later"] }], + ["sessions.groups.rename", { name: "Now", to: "Next" }], + ["sessions.groups.delete", { name: "Later" }], + ]); + await expect(tool.execute("set-missing", { action: "group_set" })).rejects.toThrow( + "names required", + ); + await expect( + tool.execute("set-invalid", { action: "group_set", names: ["Now", null] }), + ).rejects.toThrow("names[1] required"); + expect(callGateway).toHaveBeenCalledTimes(4); + }); + + it("rejects an empty patch", async () => { + const callGateway = vi.fn(); + const tool = createSessionsTool({ + agentSessionKey: "agent:main:main", + config: {}, + callGateway, + }); + + await expect(tool.execute("patch-empty", { action: "patch" })).rejects.toThrow( + "Patch setting required", + ); + expect(callGateway).not.toHaveBeenCalled(); + }); + + it("denies patch targets outside the caller session tree", async () => { + sessionsResolutionTesting.setDepsForTest({ + callGateway: vi.fn(async () => ({ sessions: [] })) as never, + }); + const callGateway = vi.fn(); + const tool = createSessionsTool({ agentSessionKey: "agent:main:main", callGateway }); + + await expect( + tool.execute("patch-other", { + action: "patch", + sessionKey: "agent:main:other", + archived: true, + }), + ).rejects.toThrow("Session status visibility is restricted"); + expect(callGateway).not.toHaveBeenCalled(); + }); +}); diff --git a/src/agents/tools/sessions-tool.ts b/src/agents/tools/sessions-tool.ts new file mode 100644 index 000000000000..08119f3544c7 --- /dev/null +++ b/src/agents/tools/sessions-tool.ts @@ -0,0 +1,202 @@ +/** Session self-service tool. */ +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +import { Type } from "typebox"; +import { getRuntimeConfig } from "../../config/config.js"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { withAgentSessionModelPatchOrigin } from "../../gateway/session-model-patch-origin.js"; +import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js"; +import type { AnyAgentTool } from "./common.js"; +import { jsonResult, readStringParam, ToolAuthorizationError, ToolInputError } from "./common.js"; +import { + callInProcessGatewayTool, + hasInProcessGatewayToolContext, + type InProcessGatewayCaller, +} from "./in-process-gateway.js"; +import { + createAgentToAgentPolicy, + createSessionVisibilityGuard, + resolveEffectiveSessionToolsVisibility, +} from "./sessions-access.js"; +import { resolveSessionToolContext } from "./sessions-helpers.js"; +import { resolveSessionReference } from "./sessions-resolution.js"; + +const ACTIONS = ["patch", "group_list", "group_set", "group_rename", "group_delete"] as const; +const GROUP_NAME_MAX_LENGTH = 512; +const GROUP_NAMES_MAX_ITEMS = 200; + +const SessionsToolSchema = Type.Object( + { + action: Type.Union( + ACTIONS.map((action) => Type.Literal(action)), + { + description: "Action", + }, + ), + sessionKey: Type.Optional(Type.String({ description: "Target session. Default: current" })), + label: Type.Optional(Type.String({ description: "Session label" })), + pinned: Type.Optional(Type.Boolean({ description: "Pin session" })), + archived: Type.Optional(Type.Boolean({ description: "Archive session" })), + model: Type.Optional(Type.String({ description: "Model override" })), + thinkingLevel: Type.Optional(Type.String({ description: "Thinking override" })), + names: Type.Optional(Type.Array(Type.String(), { description: "Ordered group names" })), + name: Type.Optional(Type.String({ description: "Group name" })), + to: Type.Optional(Type.String({ description: "New group name" })), + }, + { additionalProperties: false }, +); + +type SessionsToolOptions = { + agentSessionKey?: string; + sandboxed?: boolean; + config?: OpenClawConfig; + callGateway?: InProcessGatewayCaller; + hasInProcessGatewayContext?: () => boolean; +}; + +function readBoolean(params: Record, key: string): boolean | undefined { + const value = params[key]; + if (value === undefined) { + return undefined; + } + if (typeof value !== "boolean") { + throw new ToolInputError(`${key} must be boolean`); + } + return value; +} + +function readGroupName(value: unknown, label: string): string { + if (typeof value !== "string" || !value.trim()) { + throw new ToolInputError(`${label} required`); + } + const name = value.trim(); + if (name.length > GROUP_NAME_MAX_LENGTH) { + throw new ToolInputError(`${label} too long`); + } + return name; +} + +function readGroupNames(value: unknown): string[] { + if (!Array.isArray(value)) { + throw new ToolInputError("names required"); + } + if (value.length > GROUP_NAMES_MAX_ITEMS) { + throw new ToolInputError("Too many group names"); + } + return value.map((name, index) => readGroupName(name, `names[${index}]`)); +} + +async function resolvePatchTarget( + opts: SessionsToolOptions, + sessionKey: string | undefined, +): Promise<{ cfg: OpenClawConfig; key: string }> { + const context = resolveSessionToolContext(opts); + const rawKey = sessionKey ?? context.effectiveRequesterKey; + const resolved = await resolveSessionReference({ + sessionKey: rawKey, + alias: context.alias, + mainKey: context.mainKey, + requesterInternalKey: context.effectiveRequesterKey, + restrictToSpawned: context.restrictToSpawned, + }); + if (!resolved.ok) { + throw new ToolInputError(resolved.error); + } + if (resolved.key !== context.effectiveRequesterKey) { + // Session visibility is the configured read/write scope for session tools; + // the action only selects error copy. Owner gating remains separate. + const guard = await createSessionVisibilityGuard({ + action: "status", + requesterSessionKey: context.effectiveRequesterKey, + requesterAgentId: resolveAgentIdFromSessionKey(context.effectiveRequesterKey), + visibility: resolveEffectiveSessionToolsVisibility({ + cfg: context.cfg, + sandboxed: opts.sandboxed === true, + }), + a2aPolicy: createAgentToAgentPolicy(context.cfg), + }); + const access = guard.check(resolved.key); + if (!access.allowed) { + throw new ToolAuthorizationError(access.error); + } + } + return { cfg: context.cfg, key: resolved.key }; +} + +export function createSessionsTool(opts: SessionsToolOptions = {}): AnyAgentTool { + const gatewayCall = opts.callGateway ?? callInProcessGatewayTool; + return { + label: "Sessions", + name: "sessions", + description: + "Session settings and groups. patch/group_list/group_set/group_rename/group_delete.", + parameters: SessionsToolSchema, + execute: async (_toolCallId, rawArgs) => { + const params = rawArgs as Record; + const action = readStringParam(params, "action", { required: true }); + if (action === "group_list") { + return jsonResult(await gatewayCall("sessions.groups.list", {})); + } + // Group catalog is global by contract. Owner-only tool gating protects mutations. + if (action === "group_set") { + const names = readGroupNames(params.names); + return jsonResult(await gatewayCall("sessions.groups.put", { names })); + } + if (action === "group_rename") { + return jsonResult( + await gatewayCall("sessions.groups.rename", { + name: readGroupName(params.name, "name"), + to: readGroupName(params.to, "to"), + }), + ); + } + if (action === "group_delete") { + return jsonResult( + await gatewayCall("sessions.groups.delete", { + name: readGroupName(params.name, "name"), + }), + ); + } + if (action !== "patch") { + throw new ToolInputError(`Unknown action: ${action}`); + } + + const { key } = await resolvePatchTarget( + { ...opts, config: opts.config ?? getRuntimeConfig() }, + normalizeOptionalString(readStringParam(params, "sessionKey")), + ); + const patch = { + key, + ...(params.label !== undefined + ? { label: readStringParam(params, "label", { required: true }) } + : {}), + ...(params.pinned !== undefined ? { pinned: readBoolean(params, "pinned") } : {}), + ...(params.archived !== undefined ? { archived: readBoolean(params, "archived") } : {}), + ...(params.model !== undefined + ? { model: readStringParam(params, "model", { required: true }) } + : {}), + ...(params.thinkingLevel !== undefined + ? { thinkingLevel: readStringParam(params, "thinkingLevel", { required: true }) } + : {}), + }; + if (Object.keys(patch).length === 1) { + throw new ToolInputError("Patch setting required"); + } + const inProcessGatewayAvailable = + opts.hasInProcessGatewayContext?.() ?? + (opts.callGateway ? true : hasInProcessGatewayToolContext()); + if (patch.model !== undefined && !inProcessGatewayAvailable) { + return jsonResult({ + status: "forbidden", + error: "Model patch needs in-process gateway.", + }); + } + const result = + patch.model === undefined + ? await gatewayCall("sessions.patch", patch) + : await withAgentSessionModelPatchOrigin( + async () => await gatewayCall("sessions.patch", patch), + ); + return jsonResult(result); + }, + }; +} diff --git a/src/agents/tools/subagents-tool.test.ts b/src/agents/tools/subagents-tool.test.ts index ea1df93abb12..27048928771d 100644 --- a/src/agents/tools/subagents-tool.test.ts +++ b/src/agents/tools/subagents-tool.test.ts @@ -1,17 +1,156 @@ -// Subagents tool tests cover requester-scoped listing guidance and numeric -// status-window validation. -import { describe, expect, it } from "vitest"; +// Subagents tool tests cover requester-scoped task listing and cancellation. +import { describe, expect, it, vi } from "vitest"; +import type { TaskRecord, TaskRuntime, TaskStatus } from "../../tasks/task-registry.types.js"; import { createSubagentsTool } from "./subagents-tool.js"; +function task(params: { + taskId: string; + runtime: TaskRuntime; + status?: TaskStatus; + ownerKey?: string; + requesterSessionKey?: string; + childSessionKey?: string; + label?: string; + progressSummary?: string; + terminalSummary?: string; +}): TaskRecord { + return { + taskId: params.taskId, + runtime: params.runtime, + ownerKey: params.ownerKey ?? "agent:main:main", + requesterSessionKey: params.requesterSessionKey ?? "agent:main:main", + scopeKind: "session", + task: params.taskId, + status: params.status ?? "running", + deliveryStatus: "not_applicable", + notifyPolicy: "done_only", + createdAt: Date.now(), + lastEventAt: Date.now(), + ...(params.childSessionKey ? { childSessionKey: params.childSessionKey } : {}), + ...(params.label ? { label: params.label } : {}), + ...(params.progressSummary ? { progressSummary: params.progressSummary } : {}), + ...(params.terminalSummary ? { terminalSummary: params.terminalSummary } : {}), + }; +} + describe("subagents tool", () => { - it("does not advertise sessions_yield as unconditionally available", () => { - // sessions_yield is context-dependent; the model-facing description should - // not promise it exists in every runtime. + it("advertises the unified task ledger", () => { const tool = createSubagentsTool(); - expect(tool.description).toBe( - "List requester-session active/recent subagents. If available, wait via sessions_yield; never poll-loop.", + expect(tool.description).toBe("Background work: subagents, media gen, cron runs. list/cancel."); + }); + + it("lists cross-runtime tasks in the caller session tree", async () => { + const tasks = [ + task({ + taskId: "subagent-task", + runtime: "subagent", + childSessionKey: "agent:main:dashboard:child", + label: "Research", + progressSummary: "Reading", + }), + task({ taskId: "acp-task", runtime: "acp", status: "succeeded", terminalSummary: "Done" }), + task({ taskId: "cli-task", runtime: "cli" }), + task({ taskId: "cron-task", runtime: "cron" }), + task({ + taskId: "outside-owner", + runtime: "cli", + ownerKey: "agent:other:main", + requesterSessionKey: "agent:main:main", + }), + task({ + taskId: "child-task", + runtime: "cli", + ownerKey: "agent:main:dashboard:child", + requesterSessionKey: "agent:main:dashboard:child", + }), + task({ + taskId: "outside", + runtime: "cron", + ownerKey: "agent:other:main", + requesterSessionKey: "agent:other:main", + }), + ]; + const tool = createSubagentsTool({ + agentSessionKey: "agent:main:main", + config: {}, + listTasks: () => tasks, + }); + + const result = await tool.execute("list", { action: "list" }); + + expect(result.details).toMatchObject({ status: "ok", taskTotal: 5 }); + const rows = (result.details as { tasks: Array> }).tasks; + expect(rows).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + taskId: "subagent-task", + runtime: "subagent", + status: "running", + label: "Research", + progressSummary: "Reading", + }), + expect.objectContaining({ + taskId: "acp-task", + runtime: "acp", + status: "completed", + terminalSummary: "Done", + }), + expect.objectContaining({ taskId: "cli-task", runtime: "cli" }), + expect.objectContaining({ taskId: "cron-task", runtime: "cron" }), + expect.objectContaining({ taskId: "child-task", runtime: "cli" }), + ]), ); + expect(rows).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ taskId: "outside" }), + expect.objectContaining({ taskId: "outside-owner" }), + ]), + ); + }); + + it("cancels only tasks in the caller session tree", async () => { + const tasks = [ + task({ taskId: "inside", runtime: "cli" }), + task({ + taskId: "outside", + runtime: "cron", + ownerKey: "agent:other:main", + requesterSessionKey: "agent:other:main", + }), + task({ + taskId: "outside-owner", + runtime: "cli", + ownerKey: "agent:other:main", + requesterSessionKey: "agent:main:main", + }), + ]; + const cancelTask = vi.fn(async () => ({ found: true, cancelled: true })); + const tool = createSubagentsTool({ + agentSessionKey: "agent:main:main", + config: {}, + listTasks: () => tasks, + cancelTask: cancelTask as never, + }); + + await expect(tool.execute("cancel", { action: "cancel", taskId: "inside" })).resolves.toEqual( + expect.objectContaining({ details: expect.objectContaining({ status: "cancelled" }) }), + ); + expect(cancelTask).toHaveBeenCalledWith({ cfg: {}, taskId: "inside" }); + + await expect( + tool.execute("cancel-outside", { action: "cancel", taskId: "outside" }), + ).resolves.toEqual( + expect.objectContaining({ details: expect.objectContaining({ status: "forbidden" }) }), + ); + expect(cancelTask).toHaveBeenCalledTimes(1); + + await expect( + tool.execute("cancel-outside-owner", { action: "cancel", taskId: "outside-owner" }), + ).resolves.toEqual( + expect.objectContaining({ details: expect.objectContaining({ status: "forbidden" }) }), + ); + expect(cancelTask).toHaveBeenCalledTimes(1); }); it.each([0, 1.5])("rejects invalid recentMinutes value %s", async (recentMinutes) => { diff --git a/src/agents/tools/subagents-tool.ts b/src/agents/tools/subagents-tool.ts index a88014be90db..b04d185493ae 100644 --- a/src/agents/tools/subagents-tool.ts +++ b/src/agents/tools/subagents-tool.ts @@ -1,10 +1,14 @@ /** * subagents built-in tool. * - * Lists active and recent subagents controlled by the caller's session tree. + * Lists and cancels background work in the caller's session tree. */ import { Type } from "typebox"; import { getRuntimeConfig } from "../../config/config.js"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { listTaskRecordsUnsorted } from "../../tasks/runtime-internal.js"; +import { cancelDetachedTaskRunById } from "../../tasks/task-executor.js"; +import type { TaskRecord, TaskStatus } from "../../tasks/task-registry.types.js"; import { optionalPositiveIntegerSchema, optionalStringEnum } from "../schema/typebox.js"; import { DEFAULT_RECENT_MINUTES, @@ -16,26 +20,81 @@ import { buildSubagentList } from "../subagent-list.js"; import type { AnyAgentTool } from "./common.js"; import { jsonResult, readPositiveIntegerParam, readStringParam } from "./common.js"; -const SUBAGENT_ACTIONS = ["list"] as const; +const SUBAGENT_ACTIONS = ["list", "cancel"] as const; type SubagentAction = (typeof SUBAGENT_ACTIONS)[number]; const SubagentsToolSchema = Type.Object({ action: optionalStringEnum(SUBAGENT_ACTIONS), recentMinutes: optionalPositiveIntegerSchema(), + taskId: Type.Optional(Type.String({ description: "Task id" })), }); +const STATUS_MAP: Record = { + queued: "queued", + running: "running", + succeeded: "completed", + failed: "failed", + timed_out: "timed_out", + cancelled: "cancelled", + lost: "failed", +}; + +type SubagentsToolOptions = { + agentSessionKey?: string; + config?: OpenClawConfig; + listTasks?: typeof listTaskRecordsUnsorted; + cancelTask?: typeof cancelDetachedTaskRunById; +}; + +function taskUpdatedAt(task: TaskRecord): number { + return task.lastEventAt ?? task.endedAt ?? task.startedAt ?? task.createdAt; +} + +function listTreeTasks(tasks: TaskRecord[], rootSessionKey: string): TaskRecord[] { + const visibleKeys = new Set([rootSessionKey]); + const visibleTasks = new Set(); + let changed = true; + while (changed) { + changed = false; + for (const task of tasks) { + if (task.scopeKind !== "session" || visibleTasks.has(task.taskId)) { + continue; + } + if (!visibleKeys.has(task.ownerKey)) { + continue; + } + visibleTasks.add(task.taskId); + if (task.childSessionKey && !visibleKeys.has(task.childSessionKey)) { + visibleKeys.add(task.childSessionKey); + changed = true; + } + } + } + return tasks.filter((task) => visibleTasks.has(task.taskId)); +} + +function mapTask(task: TaskRecord) { + return { + taskId: task.taskId, + runtime: task.runtime, + status: STATUS_MAP[task.status], + ...(task.label ? { label: task.label } : {}), + ...(task.progressSummary ? { progressSummary: task.progressSummary } : {}), + ...(task.terminalSummary ? { terminalSummary: task.terminalSummary } : {}), + }; +} + /** Creates the subagents list tool scoped to the caller's controlled session tree. */ -export function createSubagentsTool(opts?: { agentSessionKey?: string }): AnyAgentTool { +export function createSubagentsTool(opts: SubagentsToolOptions = {}): AnyAgentTool { return { label: "Subagents", name: "subagents", - description: - "List requester-session active/recent subagents. If available, wait via sessions_yield; never poll-loop.", + description: "Background work: subagents, media gen, cron runs. list/cancel.", parameters: SubagentsToolSchema, execute: async (_toolCallId, args) => { const params = args as Record; const action = (readStringParam(params, "action") ?? "list") as SubagentAction; - const cfg = getRuntimeConfig(); + const cfg = opts.config ?? getRuntimeConfig(); const recentMinutesRaw = readPositiveIntegerParam(params, "recentMinutes"); const recentMinutes = recentMinutesRaw === undefined @@ -47,6 +106,10 @@ export function createSubagentsTool(opts?: { agentSessionKey?: string }): AnyAge }); // The caller only sees subagents controlled by its effective controller session. const runs = listControlledSubagentRuns(controller.controllerSessionKey); + const treeTasks = listTreeTasks( + (opts.listTasks ?? listTaskRecordsUnsorted)(), + controller.controllerSessionKey, + ); if (action === "list") { const list = buildSubagentList({ @@ -54,6 +117,16 @@ export function createSubagentsTool(opts?: { agentSessionKey?: string }): AnyAge runs, recentMinutes, }); + const cutoff = Date.now() - recentMinutes * 60_000; + const tasks = treeTasks + .filter( + (task) => + task.status === "queued" || + task.status === "running" || + taskUpdatedAt(task) >= cutoff, + ) + .toSorted((left, right) => taskUpdatedAt(right) - taskUpdatedAt(left)) + .map(mapTask); return jsonResult({ status: "ok", action: "list", @@ -61,12 +134,41 @@ export function createSubagentsTool(opts?: { agentSessionKey?: string }): AnyAge callerSessionKey: controller.callerSessionKey, callerIsSubagent: controller.callerIsSubagent, total: list.total, + taskTotal: tasks.length, + tasks, active: list.active.map(({ line: _line, ...view }) => view), recent: list.recent.map(({ line: _line, ...view }) => view), text: list.text, }); } + if (action === "cancel") { + const taskId = readStringParam(params, "taskId", { required: true }); + const target = treeTasks.find((task) => task.taskId === taskId); + if (!target) { + return jsonResult({ status: "forbidden", error: "Task outside session tree." }); + } + // Leaf subagents may cancel only their own tasks, matching the + // control-scope gate every other cross-session subagent mutation enforces. + if ( + controller.controlScope !== "children" && + target.ownerKey !== controller.callerSessionKey + ) { + return jsonResult({ + status: "forbidden", + error: "Leaf subagents cannot cancel other sessions.", + }); + } + const result = await (opts.cancelTask ?? cancelDetachedTaskRunById)({ cfg, taskId }); + return jsonResult({ + status: result.cancelled ? "cancelled" : "error", + taskId, + found: result.found, + cancelled: result.cancelled, + ...(result.reason ? { reason: result.reason } : {}), + }); + } + return jsonResult({ status: "error", error: "Unsupported action.", diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index bcc9b198a384..9fb653f9d823 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -70,6 +70,7 @@ import { resolveAgentRunErrorLifecycleFields, } from "../../agents/run-termination.js"; import { buildAgentRuntimeOutcomePlan } from "../../agents/runtime-plan/build.js"; +import { createAgentPatchedSessionModelRunGuard } from "../../agents/session-model-auto-revert.js"; import { withLocalSessionPlacementTurnAdmission } from "../../agents/session-placement-admission.js"; import { resolveSessionRuntimeOverrideForProvider } from "../../agents/session-runtime-compat.js"; import { resolveCandidateThinkingLevel } from "../../agents/thinking-runtime.js"; @@ -133,6 +134,7 @@ import { GENERIC_EXTERNAL_RUN_FAILURE_TEXT, HEARTBEAT_EXTERNAL_RUN_FAILURE_TEXT, } from "./agent-runner-failure-copy.js"; +import { emitModelFallbackStepLifecycle } from "./agent-runner-model-fallback-lifecycle.js"; import { buildEmbeddedRunExecutionParams, resolveQueuedReplyRuntimeConfig, @@ -160,6 +162,12 @@ import type { FollowupRun } from "./queue.js"; import { createBlockReplyDeliveryHandler } from "./reply-delivery.js"; import type { ReplyMediaContext } from "./reply-media-paths.js"; import { createReplyMediaContext } from "./reply-media-paths.runtime.js"; +import { + buildRestartLifecycleReplyText, + isReplyOperationRestartAbort, + isReplyOperationUserAbort, + resolveRestartLifecycleError, +} from "./reply-operation-abort.js"; import type { ReplyOperation } from "./reply-run-registry.js"; import { isReplyProfilerEnabled } from "./reply-timing-tracker.js"; import type { TypingSignaler } from "./typing-mode.js"; @@ -1283,80 +1291,6 @@ export function buildContextOverflowRecoveryText(params: { return prefix + (heartbeatBleedHint ?? buildContextOverflowResetHint(primaryContextWindow)); } -function buildRestartLifecycleReplyText(): string { - return "⚠️ Gateway is restarting. Please wait a few seconds and try again."; -} - -function resolveRestartLifecycleError( - err: unknown, -): GatewayDrainingError | CommandLaneClearedError | undefined { - const pending = [err]; - const seen = new Set(); - - let pendingIndex = 0; - while (pendingIndex < pending.length) { - const candidate = pending[pendingIndex++]; - if (!candidate || seen.has(candidate)) { - continue; - } - seen.add(candidate); - - if (candidate instanceof GatewayDrainingError || candidate instanceof CommandLaneClearedError) { - return candidate; - } - - if (isFallbackSummaryError(candidate)) { - for (const attempt of candidate.attempts) { - pending.push(attempt.error); - } - } - - if (candidate instanceof Error && "cause" in candidate) { - pending.push(candidate.cause); - } - } - - return undefined; -} - -function isReplyOperationUserAbort(replyOperation?: ReplyOperation): boolean { - if ( - replyOperation?.result?.kind === "aborted" && - replyOperation.result.code === "aborted_by_user" - ) { - return true; - } - const abortSignal = replyOperation?.abortSignal; - return abortSignal?.aborted === true && !isAgentRunRestartAbortReason(abortSignal.reason); -} - -function isReplyOperationRestartAbort(replyOperation?: ReplyOperation): boolean { - if ( - replyOperation?.result?.kind === "aborted" && - replyOperation.result.code === "aborted_for_restart" - ) { - return true; - } - const abortSignal = replyOperation?.abortSignal; - return abortSignal?.aborted === true && isAgentRunRestartAbortReason(abortSignal.reason); -} - -function emitModelFallbackStepLifecycle(params: { - runId: string; - sessionKey?: string; - step: Record; -}) { - emitAgentEvent({ - runId: params.runId, - ...(params.sessionKey ? { sessionKey: params.sessionKey } : {}), - stream: "lifecycle", - data: { - phase: "fallback_step", - ...params.step, - }, - }); -} - /** Decides whether to retry after rechecking auto-fallback primary probe state. */ export function resolveRunAfterAutoFallbackPrimaryProbeRecheck(params: { run: FollowupRun["run"]; @@ -1646,6 +1580,14 @@ async function runAgentTurnWithFallbackInternal( let fallbackAttempts: RuntimeFallbackAttempt[] = []; let fallbackExhausted = false; let terminalRunFailed = false; + const modelPatch = createAgentPatchedSessionModelRunGuard({ + cfg: runtimeConfig, + agentId: params.followupRun.run.agentId, + sessionKey: params.sessionKey, + storePath: params.storePath, + onError: (error) => + logVerbose(`agent model patch reconciliation failed: ${formatErrorMessage(error)}`), + }); let pendingLifecycleTerminal: | { provider: string; @@ -2972,6 +2914,9 @@ async function runAgentTurnWithFallbackInternal( terminalErrorMessage ?? "All model fallback candidates failed", ); terminalRunFailed = true; + if (modelPatch.captureFallbackFailure(fallbackAttempts) === undefined) { + modelPatch.captureFailure(embeddedError ?? exhaustionError); + } emitSettledLifecycleError(exhaustionError, { ...terminalMetadata, fallbackExhaustedFailure: true, @@ -2981,6 +2926,7 @@ async function runAgentTurnWithFallbackInternal( } else if (deferredLifecycleError || embeddedError) { const terminalError = new Error(terminalErrorMessage ?? "Agent run failed"); terminalRunFailed = true; + modelPatch.captureFailure(embeddedError ?? terminalError); emitSettledLifecycleError(terminalError, terminalMetadata); params.replyOperation?.retainFailureUntilComplete(); params.replyOperation?.fail("run_failed", terminalError); @@ -3011,6 +2957,7 @@ async function runAgentTurnWithFallbackInternal( "The requested model may be temporarily unavailable. Please try again shortly." : "⚠️ Model switch could not be completed. The requested model may be temporarily unavailable. Please try again shortly."; params.replyOperation?.fail("run_failed", err); + await modelPatch.fail(err); return { kind: "final", payload: markAgentRunFailureReplyPayload({ @@ -3158,6 +3105,7 @@ async function runAgentTurnWithFallbackInternal( if (providerRequestError) { takePendingLifecycleTerminal()?.emit("error", err); params.replyOperation?.fail("run_failed", err); + await modelPatch.fail(err); return { kind: "final", payload: markAgentRunFailureReplyPayload({ @@ -3279,6 +3227,7 @@ async function runAgentTurnWithFallbackInternal( }); } params.replyOperation?.fail("run_failed", err); + await modelPatch.fail(err); return { kind: "final", payload: markAgentRunFailureReplyPayload({ @@ -3351,6 +3300,10 @@ async function runAgentTurnWithFallbackInternal( } } } + const patchedModelNeedsRevert = terminalRunFailed + ? false + : (modelPatch.captureFallbackFailure(fallbackAttempts) ?? false); + await modelPatch.finish(!terminalRunFailed && !patchedModelNeedsRevert); const terminalFailurePayload = terminalRunFailed ? buildTerminalAgentRunFailureReplyPayload({ isHeartbeat: params.isHeartbeat, diff --git a/src/auto-reply/reply/agent-runner-model-fallback-lifecycle.ts b/src/auto-reply/reply/agent-runner-model-fallback-lifecycle.ts new file mode 100644 index 000000000000..639c8b75df74 --- /dev/null +++ b/src/auto-reply/reply/agent-runner-model-fallback-lifecycle.ts @@ -0,0 +1,14 @@ +import { emitAgentEvent } from "../../infra/agent-events.js"; + +export function emitModelFallbackStepLifecycle(params: { + runId: string; + sessionKey?: string; + step: Record; +}) { + emitAgentEvent({ + runId: params.runId, + ...(params.sessionKey ? { sessionKey: params.sessionKey } : {}), + stream: "lifecycle", + data: { phase: "fallback_step", ...params.step }, + }); +} diff --git a/src/auto-reply/reply/reply-operation-abort.ts b/src/auto-reply/reply/reply-operation-abort.ts new file mode 100644 index 000000000000..73719c3954a4 --- /dev/null +++ b/src/auto-reply/reply/reply-operation-abort.ts @@ -0,0 +1,53 @@ +import { isFallbackSummaryError } from "../../agents/model-fallback.js"; +import { isAgentRunRestartAbortReason } from "../../agents/run-termination.js"; +import { CommandLaneClearedError, GatewayDrainingError } from "../../process/command-queue.js"; +import type { ReplyOperation } from "./reply-run-registry.js"; + +export function buildRestartLifecycleReplyText(): string { + return "⚠️ Gateway is restarting. Please wait a few seconds and try again."; +} + +export function isReplyOperationUserAbort(replyOperation?: ReplyOperation): boolean { + if ( + replyOperation?.result?.kind === "aborted" && + replyOperation.result.code === "aborted_by_user" + ) { + return true; + } + const abortSignal = replyOperation?.abortSignal; + return abortSignal?.aborted === true && !isAgentRunRestartAbortReason(abortSignal.reason); +} + +export function isReplyOperationRestartAbort(replyOperation?: ReplyOperation): boolean { + if ( + replyOperation?.result?.kind === "aborted" && + replyOperation.result.code === "aborted_for_restart" + ) { + return true; + } + const abortSignal = replyOperation?.abortSignal; + return abortSignal?.aborted === true && isAgentRunRestartAbortReason(abortSignal.reason); +} + +export function resolveRestartLifecycleError( + error: unknown, +): GatewayDrainingError | CommandLaneClearedError | undefined { + const pending = [error]; + const seen = new Set(); + for (const candidate of pending) { + if (!candidate || seen.has(candidate)) { + continue; + } + seen.add(candidate); + if (candidate instanceof GatewayDrainingError || candidate instanceof CommandLaneClearedError) { + return candidate; + } + if (isFallbackSummaryError(candidate)) { + pending.push(...candidate.attempts.map((attempt) => attempt.error)); + } + if (candidate instanceof Error && "cause" in candidate) { + pending.push(candidate.cause); + } + } + return undefined; +} diff --git a/src/config/sessions/session-model-fallback.ts b/src/config/sessions/session-model-fallback.ts new file mode 100644 index 000000000000..977921e8b3df --- /dev/null +++ b/src/config/sessions/session-model-fallback.ts @@ -0,0 +1,58 @@ +export type AgentPatchedSessionModelFallback = { + prevModel: string; + prevProvider: string; + prevModelOverride?: string; + prevProviderOverride?: string; + prevModelOverrideSource?: "auto" | "user"; + prevModelOverrideFallbackOriginProvider?: string; + prevModelOverrideFallbackOriginModel?: string; + prevAuthProfileOverride?: string; + prevAuthProfileOverrideSource?: "auto" | "user"; + prevAuthProfileOverrideCompactionCount?: number; + prevThinkingLevel?: string; + lastValidatedPatchTs?: number; + ts: number; + source: "agent-patch"; +}; + +export function createAgentPatchedSessionModelFallback(params: { + model: string; + provider: string; + entry: { + modelOverride?: string; + providerOverride?: string; + modelOverrideSource?: "auto" | "user"; + modelOverrideFallbackOriginProvider?: string; + modelOverrideFallbackOriginModel?: string; + authProfileOverride?: string; + authProfileOverrideSource?: "auto" | "user"; + authProfileOverrideCompactionCount?: number; + thinkingLevel?: string; + }; + ts: number; +}): AgentPatchedSessionModelFallback { + const { entry } = params; + return { + prevModel: params.model, + prevProvider: params.provider, + ...(entry.modelOverride ? { prevModelOverride: entry.modelOverride } : {}), + ...(entry.providerOverride ? { prevProviderOverride: entry.providerOverride } : {}), + ...(entry.modelOverrideSource ? { prevModelOverrideSource: entry.modelOverrideSource } : {}), + ...(entry.modelOverrideFallbackOriginProvider + ? { prevModelOverrideFallbackOriginProvider: entry.modelOverrideFallbackOriginProvider } + : {}), + ...(entry.modelOverrideFallbackOriginModel + ? { prevModelOverrideFallbackOriginModel: entry.modelOverrideFallbackOriginModel } + : {}), + ...(entry.authProfileOverride ? { prevAuthProfileOverride: entry.authProfileOverride } : {}), + ...(entry.authProfileOverrideSource + ? { prevAuthProfileOverrideSource: entry.authProfileOverrideSource } + : {}), + ...(entry.authProfileOverrideCompactionCount !== undefined + ? { prevAuthProfileOverrideCompactionCount: entry.authProfileOverrideCompactionCount } + : {}), + ...(entry.thinkingLevel ? { prevThinkingLevel: entry.thinkingLevel } : {}), + ts: params.ts, + source: "agent-patch", + }; +} diff --git a/src/config/sessions/types.ts b/src/config/sessions/types.ts index c189ac3b8244..097533893a4d 100644 --- a/src/config/sessions/types.ts +++ b/src/config/sessions/types.ts @@ -15,11 +15,9 @@ import type { TtsAutoMode } from "../types.tts.js"; import type { SessionRestartRecoveryState } from "./restart-recovery-types.js"; import type { SessionEntryProvenance } from "./session-entry-provenance.js"; import { rewriteSessionFileForNewSessionId } from "./session-file-rotation.js"; +import type { AgentPatchedSessionModelFallback } from "./session-model-fallback.js"; export type SessionScope = "per-sender" | "global"; - -type SessionChannelId = ChannelId; - export type SessionChatType = ChatType; export type SessionOrigin = { @@ -388,6 +386,8 @@ export type SessionEntry = SessionRestartRecoveryState & /** Selected model that produced the current auto fallback override. */ modelOverrideFallbackOriginProvider?: string; modelOverrideFallbackOriginModel?: string; + /** One-run rollback guard for a model selected by the agent sessions tool. */ + modelFallback?: AgentPatchedSessionModelFallback; authProfileOverride?: string; authProfileOverrideSource?: "auto" | "user"; authProfileOverrideCompactionCount?: number; @@ -480,7 +480,7 @@ export type SessionEntry = SessionRestartRecoveryState & deliveryContext?: DeliveryContext; /** Last ambient room message durably appended to this transcript, keyed by channel scope. */ ambientTranscriptWatermarks?: Record; - lastChannel?: SessionChannelId; + lastChannel?: ChannelId; lastTo?: string; lastAccountId?: string; lastThreadId?: string | number; diff --git a/src/gateway/session-model-patch-origin.ts b/src/gateway/session-model-patch-origin.ts new file mode 100644 index 000000000000..599347673c0f --- /dev/null +++ b/src/gateway/session-model-patch-origin.ts @@ -0,0 +1,61 @@ +import { AsyncLocalStorage } from "node:async_hooks"; +import { + normalizeOptionalLowercaseString, + normalizeOptionalString, +} from "@openclaw/normalization-core/string-coerce"; +import { resolveProviderIdForAuth } from "../agents/provider-auth-aliases.js"; +import { resolveSessionModelRef } from "../agents/session-model-ref.js"; +import type { SessionEntry } from "../config/sessions.js"; +import { createAgentPatchedSessionModelFallback } from "../config/sessions/session-model-fallback.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; + +const agentSessionModelPatch = new AsyncLocalStorage(); + +export function withAgentSessionModelPatchOrigin(run: () => T): T { + return agentSessionModelPatch.run(true, run); +} + +export function isAgentSessionModelPatchOrigin(): boolean { + return agentSessionModelPatch.getStore() === true; +} + +export function shouldPreserveSessionAuthProfileOverride(params: { + cfg: OpenClawConfig; + entry: SessionEntry; + currentProvider: string; + provider: string; +}): boolean { + const profileOverride = normalizeOptionalString(params.entry.authProfileOverride); + const provider = normalizeOptionalLowercaseString(params.provider); + if (!profileOverride || !provider) { + return false; + } + const resolvesToTargetProvider = (rawProvider: string | undefined): boolean => { + const candidate = normalizeOptionalLowercaseString(rawProvider); + return Boolean( + candidate && + resolveProviderIdForAuth(candidate, { config: params.cfg }) === + resolveProviderIdForAuth(provider, { config: params.cfg }), + ); + }; + const delimiterIndex = profileOverride.indexOf(":"); + if (delimiterIndex < 0) { + return resolvesToTargetProvider(params.currentProvider); + } + return resolvesToTargetProvider(profileOverride.slice(0, delimiterIndex)); +} + +export function snapshotAgentModelFallback( + cfg: OpenClawConfig, + entry: SessionEntry, + agentId: string, + now: number, +): NonNullable { + const prior = resolveSessionModelRef(cfg, entry, agentId); + return createAgentPatchedSessionModelFallback({ + model: prior.model, + provider: prior.provider, + entry, + ts: now, + }); +} diff --git a/src/gateway/sessions-patch.test.ts b/src/gateway/sessions-patch.test.ts index e36f547c989f..20c894491a23 100644 --- a/src/gateway/sessions-patch.test.ts +++ b/src/gateway/sessions-patch.test.ts @@ -8,6 +8,7 @@ import { createEmptyPluginRegistry } from "../plugins/registry-empty.js"; import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../plugins/runtime.js"; import { AGENT_HARNESS_SESSION_KEY_RESERVED_MESSAGE } from "../sessions/agent-harness-session-key.js"; import { MODEL_SELECTION_LOCKED_MESSAGE } from "../sessions/model-overrides.js"; +import { withAgentSessionModelPatchOrigin } from "./session-model-patch-origin.js"; import { applySessionsPatchToStore } from "./sessions-patch.js"; const acpSessionMetaMocks = vi.hoisted(() => ({ @@ -23,6 +24,7 @@ const KIMI_SUBAGENT_KEY = "agent:kimi:subagent:child"; const MAIN_SESSION_KEY = "agent:main:main"; const ANTHROPIC_SONNET_MODEL = "anthropic/claude-sonnet-4-6"; const ANTHROPIC_SONNET_ID = "claude-sonnet-4-6"; +const ANTHROPIC_OPUS_MODEL = "anthropic/claude-opus-4-6"; const ANTHROPIC_OPUS_ID = "claude-opus-4-6"; const OPENAI_GPT_MODEL = "openai/gpt-5.4"; const OPENAI_GPT_ID = "gpt-5.4"; @@ -708,6 +710,148 @@ describe("gateway sessions patch", () => { expect(entry.liveModelSwitchPending).toBe(true); }); + test("clears an agent model rollback marker on explicit model patches", async () => { + const store = mainStoreEntry({ + sessionId: "sess-agent-model-patch", + modelFallback: { + prevModel: OPENAI_GPT_ID, + prevProvider: "openai", + ts: 1, + source: "agent-patch", + }, + }); + const entry = await applyMainModelPatch({ + store, + cfg: createAllowlistedAnthropicModelCfg(), + model: ANTHROPIC_SONNET_MODEL, + catalogRefs: [OPENAI_GPT_MODEL, ANTHROPIC_SONNET_MODEL], + }); + + expect(entry.modelFallback).toBeUndefined(); + }); + + test("atomically snapshots prior selection for agent model patches", async () => { + const store = mainStoreEntry({ + providerOverride: "openai", + modelOverride: OPENAI_GPT_ID, + modelOverrideSource: "auto", + modelOverrideFallbackOriginProvider: "openai", + modelOverrideFallbackOriginModel: "gpt-primary", + authProfileOverride: "openai:good", + authProfileOverrideSource: "user", + thinkingLevel: "high", + }); + const entry = await withAgentSessionModelPatchOrigin( + async () => + await applyMainModelPatch({ + store, + cfg: createAllowlistedAnthropicModelCfg(), + model: ANTHROPIC_SONNET_MODEL, + catalogRefs: [OPENAI_GPT_MODEL, ANTHROPIC_SONNET_MODEL], + }), + ); + + expect(entry.modelFallback).toMatchObject({ + prevModel: OPENAI_GPT_ID, + prevProvider: "openai", + prevModelOverrideSource: "auto", + prevModelOverrideFallbackOriginProvider: "openai", + prevModelOverrideFallbackOriginModel: "gpt-primary", + prevAuthProfileOverride: "openai:good", + prevThinkingLevel: "high", + source: "agent-patch", + }); + }); + + test("keeps the last validated model across consecutive agent patches", async () => { + const cfg = createAllowlistedAnthropicModelCfg(); + cfg.agents!.defaults!.models![ANTHROPIC_OPUS_MODEL] = { alias: "opus" }; + const first = await withAgentSessionModelPatchOrigin( + async () => + await applyMainModelPatch({ + store: mainStoreEntry({ + providerOverride: "openai", + modelOverride: OPENAI_GPT_ID, + }), + cfg, + model: ANTHROPIC_SONNET_MODEL, + catalogRefs: [OPENAI_GPT_MODEL, ANTHROPIC_SONNET_MODEL], + }), + ); + const firstMarker = first.modelFallback; + expect(firstMarker).toMatchObject({ + prevModel: OPENAI_GPT_ID, + prevProvider: "openai", + }); + + const second = await withAgentSessionModelPatchOrigin( + async () => + await applyMainModelPatch({ + store: { [MAIN_SESSION_KEY]: first }, + cfg, + model: ANTHROPIC_OPUS_MODEL, + catalogRefs: [OPENAI_GPT_MODEL, ANTHROPIC_SONNET_MODEL, ANTHROPIC_OPUS_MODEL], + }), + ); + + expect(second.modelFallback).toMatchObject({ + prevModel: OPENAI_GPT_ID, + prevProvider: "openai", + }); + expect(second.modelFallback?.ts).toBeGreaterThan(firstMarker?.ts ?? 0); + }); + + test("realigns the model-revert marker with an independent thinkingLevel change", async () => { + const store: Record = { + [MAIN_SESSION_KEY]: { + thinkingLevel: "high", + modelOverride: ANTHROPIC_SONNET_ID, + providerOverride: "anthropic", + modelFallback: { + prevModel: OPENAI_GPT_ID, + prevProvider: "openai", + prevThinkingLevel: "high", + ts: 1, + source: "agent-patch", + }, + } as SessionEntry, + }; + const entry = expectPatchOk( + await runPatch({ store, patch: { key: MAIN_SESSION_KEY, thinkingLevel: "low" } }), + ); + // The model-revert target still points at the pre-switch model, but its + // thinkingLevel restore now honors the user's newer choice instead of "high". + expect(entry.thinkingLevel).toBe("low"); + expect(entry.modelFallback).toMatchObject({ + prevModel: OPENAI_GPT_ID, + prevProvider: "openai", + prevThinkingLevel: "low", + ts: 1, + source: "agent-patch", + }); + }); + + test("clears the marker thinkingLevel restore when the user clears thinkingLevel", async () => { + const store: Record = { + [MAIN_SESSION_KEY]: { + thinkingLevel: "high", + modelFallback: { + prevModel: OPENAI_GPT_ID, + prevProvider: "openai", + prevThinkingLevel: "high", + ts: 1, + source: "agent-patch", + }, + } as SessionEntry, + }; + const entry = expectPatchOk( + await runPatch({ store, patch: { key: MAIN_SESSION_KEY, thinkingLevel: null } }), + ); + expect(entry.thinkingLevel).toBeUndefined(); + expect(entry.modelFallback?.prevThinkingLevel).toBeUndefined(); + expect(entry.modelFallback?.prevModel).toBe(OPENAI_GPT_ID); + }); + test("clears pending live model switches for model reset patches", async () => { const store = mainStoreEntry({ sessionId: "sess-live-reset", diff --git a/src/gateway/sessions-patch.ts b/src/gateway/sessions-patch.ts index ea287d64a249..43cff7bafdae 100644 --- a/src/gateway/sessions-patch.ts +++ b/src/gateway/sessions-patch.ts @@ -23,7 +23,6 @@ import { resolveDefaultModelForAgent, resolveSubagentConfiguredModelSelection, } from "../agents/model-selection.js"; -import { resolveProviderIdForAuth } from "../agents/provider-auth-aliases.js"; import { resolveEffectiveAgentRuntime } from "../agents/thinking-runtime.js"; import { normalizeGroupActivation } from "../auto-reply/group-activation.js"; import { @@ -62,6 +61,11 @@ import { } from "../sessions/model-overrides.js"; import { normalizeSendPolicy } from "../sessions/send-policy.js"; import { parseSessionLabel, SESSION_LABEL_MAX_LENGTH } from "../sessions/session-label.js"; +import { + isAgentSessionModelPatchOrigin, + shouldPreserveSessionAuthProfileOverride, + snapshotAgentModelFallback, +} from "./session-model-patch-origin.js"; function invalid(message: string): { ok: false; error: ErrorShape } { return { ok: false, error: errorShape(ErrorCodes.INVALID_REQUEST, message) }; @@ -83,43 +87,6 @@ function normalizeExecAsk(raw: string): "off" | "on-miss" | "always" | undefined return undefined; } -function shouldPreserveSessionAuthProfileOverride(params: { - cfg: OpenClawConfig; - entry: SessionEntry; - currentProvider: string; - provider: string; -}): boolean { - const profileOverride = normalizeOptionalString(params.entry.authProfileOverride); - if (!profileOverride) { - return false; - } - const provider = normalizeOptionalLowercaseString(params.provider); - if (!provider) { - return false; - } - const resolvesToTargetProvider = (rawProvider: string | undefined): boolean => { - const candidate = normalizeOptionalLowercaseString(rawProvider); - if (!candidate) { - return false; - } - return ( - resolveProviderIdForAuth(candidate, { config: params.cfg }) === - resolveProviderIdForAuth(provider, { config: params.cfg }) - ); - }; - const delimiterIndex = profileOverride.indexOf(":"); - if (delimiterIndex < 0) { - return resolvesToTargetProvider(params.currentProvider); - } - const profileProvider = normalizeOptionalLowercaseString( - profileOverride.slice(0, delimiterIndex), - ); - if (!profileProvider) { - return false; - } - return resolvesToTargetProvider(profileProvider); -} - function supportsSpawnLineage(storeKey: string): boolean { return isSubagentSessionKey(storeKey) || isAcpSessionKey(storeKey); } @@ -611,8 +578,13 @@ export async function projectSessionsPatchEntry(params: { next.execNode = trimmed; } } - if ("model" in patch) { + const agentModelFallback = isAgentSessionModelPatchOrigin() + ? next.modelFallback?.source === "agent-patch" + ? { ...next.modelFallback, ts: Math.max(now, next.modelFallback.ts + 1) } + : snapshotAgentModelFallback(cfg, next, sessionAgentId, now) + : undefined; + delete next.modelFallback; const raw = patch.model; if (raw === null) { applyModelOverrideToSessionEntry({ @@ -680,6 +652,9 @@ export async function projectSessionsPatchEntry(params: { markLiveSwitchPending: true, }); } + if (agentModelFallback) { + next.modelFallback = agentModelFallback; + } } if (next.thinkingLevel && ("thinkingLevel" in patch || "model" in patch)) { @@ -716,6 +691,19 @@ export async function projectSessionsPatchEntry(params: { } } + // A thinkingLevel change made on its own (no model switch) never touches the + // agent-patch revert marker, so realign its restore target with the user's + // newer choice; otherwise a later model-failure revert clobbers it. + if ( + "thinkingLevel" in patch && + !("model" in patch) && + next.modelFallback?.source === "agent-patch" + ) { + next.modelFallback = next.thinkingLevel + ? { ...next.modelFallback, prevThinkingLevel: next.thinkingLevel } + : { ...next.modelFallback, prevThinkingLevel: undefined }; + } + if ("sendPolicy" in patch) { const raw = patch.sendPolicy; if (raw === null) { diff --git a/src/gateway/tool-resolution.exclude.test.ts b/src/gateway/tool-resolution.exclude.test.ts index 05ffec4e10db..1f4761526df0 100644 --- a/src/gateway/tool-resolution.exclude.test.ts +++ b/src/gateway/tool-resolution.exclude.test.ts @@ -150,8 +150,14 @@ describe("resolveGatewayScopedTools excludeToolNames", () => { ]); expect(nonOwnerResult.tools.map((tool) => tool.name)).toEqual(["read", "sessions_spawn"]); const args = readCreateToolsArgs(1); - expect(args.pluginToolDenylist).toEqual(["cron", "gateway", "nodes", "computer"]); - expect(args.inheritedToolDenylist).toEqual(["cron", "gateway", "nodes", "computer"]); + expect(args.pluginToolDenylist).toEqual(["cron", "gateway", "sessions", "nodes", "computer"]); + expect(args.inheritedToolDenylist).toEqual([ + "cron", + "gateway", + "sessions", + "nodes", + "computer", + ]); }); it("keeps real gateway deny policy inheritable while excluding native dedup tools", () => { diff --git a/src/plugins/session-entry-slot-keys.ts b/src/plugins/session-entry-slot-keys.ts index d5e1c327448f..674fc3680bb7 100644 --- a/src/plugins/session-entry-slot-keys.ts +++ b/src/plugins/session-entry-slot-keys.ts @@ -76,6 +76,7 @@ const SESSION_ENTRY_RESERVED_SLOT_KEY_LIST = [ "modelOverrideSource", "modelOverrideFallbackOriginProvider", "modelOverrideFallbackOriginModel", + "modelFallback", "authProfileOverride", "authProfileOverrideSource", "authProfileOverrideCompactionCount", diff --git a/src/security/dangerous-tools.ts b/src/security/dangerous-tools.ts index 64ecb4741656..fac8c88929d6 100644 --- a/src/security/dangerous-tools.ts +++ b/src/security/dangerous-tools.ts @@ -48,6 +48,7 @@ export const GATEWAY_CONTROL_PLANE_TOOLS = ["cron", "gateway"] as const; */ export const GATEWAY_OWNER_ONLY_CORE_TOOLS = [ ...GATEWAY_CONTROL_PLANE_TOOLS, + "sessions", "nodes", "computer", ] as const; diff --git a/src/status/status-runtime-lines.ts b/src/status/status-runtime-lines.ts new file mode 100644 index 000000000000..4c9d746ad92d --- /dev/null +++ b/src/status/status-runtime-lines.ts @@ -0,0 +1,103 @@ +import os from "node:os"; +import type { SessionEntry } from "../config/sessions.js"; +import { resolveSessionFilePath, resolveSessionFilePathOptions } from "../config/sessions/paths.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { formatDurationCompact } from "../infra/format-time/format-duration.ts"; +import { + loadSessionCostSummariesFromCache, + resolveExistingUsageSessionFile, +} from "../infra/session-cost-usage.js"; +import { formatTokenCount, formatUsd } from "../utils/usage-format.js"; + +export function buildStatusUptimeLine(): string { + const format = (ms: number) => formatDurationCompact(ms, { spaced: true }) ?? "0s"; + const gatewayMs = Math.max(0, Math.round(process.uptime() * 1000)); + const systemMs = Math.max(0, Math.round(os.uptime() * 1000)); + return `⏱️ Uptime: gateway ${format(gatewayMs)} · system ${format(systemMs)}`; +} + +export async function resolveSessionCostLine( + params: { + cfg: OpenClawConfig; + agentId: string; + sessionEntry?: SessionEntry; + storePath?: string; + }, + deps: { + load?: typeof loadSessionCostSummariesFromCache; + now?: () => number; + timeoutMs?: number; + } = {}, +): Promise { + const sessionId = params.sessionEntry?.sessionId?.trim(); + if (!sessionId) { + return undefined; + } + let sessionFile: string | undefined; + try { + const pathOpts = resolveSessionFilePathOptions({ + storePath: params.storePath, + agentId: params.agentId, + }); + sessionFile = resolveExistingUsageSessionFile({ + sessionId, + sessionEntry: params.sessionEntry, + sessionFile: resolveSessionFilePath(sessionId, params.sessionEntry, pathOpts), + agentId: params.agentId, + }); + } catch { + return undefined; + } + if (!sessionFile) { + return undefined; + } + const now = deps.now?.() ?? Date.now(); + const date = new Date(now); + const startMs = new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime(); + const timeoutMs = deps.timeoutMs ?? 3_500; + let timeout: NodeJS.Timeout | undefined; + try { + const loaded = await Promise.race([ + (deps.load ?? loadSessionCostSummariesFromCache)({ + sessions: [{ sessionId, sessionFile }], + config: params.cfg, + agentId: params.agentId, + startMs, + endMs: now, + dayBucket: { mode: "utc-offset", utcOffsetMinutes: -date.getTimezoneOffset() }, + requestRefresh: false, + }), + new Promise((_, reject) => { + timeout = setTimeout(() => reject(new Error("session cost timeout")), timeoutMs); + }), + ]).finally(() => { + if (timeout) { + clearTimeout(timeout); + } + }); + const summary = loaded.cacheStatus.status === "fresh" ? loaded.summaries[0] : null; + if (!summary) { + return undefined; + } + const cost = summary.missingCostEntries > 0 ? "cost partial" : formatUsd(summary.totalCost); + return `💵 ${cost ? `${cost} · ` : ""}${formatTokenCount(summary.totalTokens)} tok (today)`; + } catch { + return undefined; + } +} + +export async function appendSessionCostLine( + usageLine: string | null, + cfg: OpenClawConfig, + agentId: string, + sessionEntry?: SessionEntry, + storePath?: string, +): Promise { + const line = await resolveSessionCostLine({ + cfg, + agentId, + ...(sessionEntry ? { sessionEntry } : {}), + ...(storePath ? { storePath } : {}), + }); + return line ? [usageLine, line].filter(Boolean).join("\n") : usageLine; +} diff --git a/src/status/status-text.test.ts b/src/status/status-text.test.ts index 3df58d542706..afc458f58296 100644 --- a/src/status/status-text.test.ts +++ b/src/status/status-text.test.ts @@ -1,4 +1,6 @@ import { describe, expect, it } from "vitest"; +import { formatSqliteSessionFileMarker } from "../config/sessions/sqlite-marker.js"; +import { resolveSessionCostLine } from "./status-runtime-lines.js"; import { buildStatusText } from "./status-text.js"; type StatusTextParams = Parameters[0]; @@ -94,3 +96,133 @@ describe("buildStatusText channel features", () => { expect(text).toContain("enable richMessages for this Telegram account"); }); }); + +describe("session status cost line", () => { + const sessionEntry = { + sessionId: "cost-session", + updatedAt: 0, + sessionFile: formatSqliteSessionFileMarker({ + agentId: "main", + sessionId: "cost-session", + storePath: "/tmp/openclaw-status-cost/sessions.json", + }), + }; + + it("shows cached current-session cost and tokens", async () => { + const load = async () => ({ + cacheStatus: { + status: "fresh" as const, + cachedFiles: 1, + pendingFiles: 0, + staleFiles: 0, + }, + summaries: [ + { + input: 400_000, + output: 56_000, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 456_000, + totalCost: 1.23, + inputCost: 1, + outputCost: 0.23, + cacheReadCost: 0, + cacheWriteCost: 0, + missingCostEntries: 0, + }, + ], + }); + + await expect( + resolveSessionCostLine( + { cfg: {}, agentId: "main", sessionEntry }, + { load, now: () => new Date(2026, 6, 14, 12).getTime() }, + ), + ).resolves.toBe("💵 $1.23 · 456k tok (today)"); + }); + + it("omits a cold cost cache", async () => { + await expect( + resolveSessionCostLine( + { cfg: {}, agentId: "main", sessionEntry }, + { + load: async () => ({ + cacheStatus: { + status: "partial", + cachedFiles: 0, + pendingFiles: 1, + staleFiles: 0, + }, + summaries: [null], + }), + }, + ), + ).resolves.toBeUndefined(); + }); + + it("omits a stale cached summary", async () => { + await expect( + resolveSessionCostLine( + { cfg: {}, agentId: "main", sessionEntry }, + { + load: async () => ({ + cacheStatus: { + status: "stale", + cachedFiles: 0, + pendingFiles: 1, + staleFiles: 1, + }, + summaries: [ + { + input: 1, + output: 1, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 2, + totalCost: 1, + inputCost: 1, + outputCost: 0, + cacheReadCost: 0, + cacheWriteCost: 0, + missingCostEntries: 0, + }, + ], + }), + }, + ), + ).resolves.toBeUndefined(); + }); + + it("marks incomplete pricing", async () => { + await expect( + resolveSessionCostLine( + { cfg: {}, agentId: "main", sessionEntry }, + { + load: async () => ({ + cacheStatus: { + status: "fresh", + cachedFiles: 1, + pendingFiles: 0, + staleFiles: 0, + }, + summaries: [ + { + input: 400_000, + output: 56_000, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 456_000, + totalCost: 1.23, + inputCost: 1, + outputCost: 0.23, + cacheReadCost: 0, + cacheWriteCost: 0, + missingCostEntries: 1, + }, + ], + }), + }, + ), + ).resolves.toBe("💵 cost partial · 456k tok (today)"); + }); +}); diff --git a/src/status/status-text.ts b/src/status/status-text.ts index 1f01ced857d6..34ff47d2c12b 100644 --- a/src/status/status-text.ts +++ b/src/status/status-text.ts @@ -1,4 +1,3 @@ -import os from "node:os"; import path from "node:path"; import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce"; import { @@ -32,7 +31,6 @@ import { toAgentModelListLike } from "../config/model-input.js"; import type { SessionEntry } from "../config/sessions.js"; import { hasSessionAutoModelFallbackProvenance } from "../config/sessions/model-override-provenance.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { formatDurationCompact } from "../infra/format-time/format-duration.ts"; import { formatUsageWindowSummary, loadProviderUsageSummary, @@ -58,6 +56,7 @@ import { } from "./codex-synthetic-usage.js"; import { resolveActiveFallbackState } from "./fallback-notice-state.js"; import { formatCompactPluginHealthLine } from "./status-plugin-health.js"; +import { appendSessionCostLine, buildStatusUptimeLine } from "./status-runtime-lines.js"; import type { BuildStatusTextParams } from "./status-text.types.js"; // Status text assembly gathers runtime/model/session/task facts, then delegates @@ -270,16 +269,6 @@ function formatAgentTaskCountsLine(agentId: string): string | undefined { return `📌 Tasks: ${snapshot.activeCount} active · ${snapshot.totalCount} total · agent-local`; } -function formatStatusUptimeDuration(ms: number): string { - return formatDurationCompact(ms, { spaced: true }) ?? "0s"; -} - -function buildStatusUptimeLine(): string { - const gatewayUptimeMs = Math.max(0, Math.round(process.uptime() * 1000)); - const systemUptimeMs = Math.max(0, Math.round(os.uptime() * 1000)); - return `⏱️ Uptime: gateway ${formatStatusUptimeDuration(gatewayUptimeMs)} · system ${formatStatusUptimeDuration(systemUptimeMs)}`; -} - async function resolveRuntimePluginHealthLine(): Promise { try { const { collectRuntimePluginHealthSnapshot } = await loadStatusPluginHealthRuntime(); @@ -499,6 +488,7 @@ export async function buildStatusText(params: BuildStatusTextParams): Promise