ai-agent-book 精选快照(<2MB 代码与文档,来自 github.com/bojieli/ai-agent-book)
Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s

This commit is contained in:
2026-08-20 13:12:50 +00:00
commit b119135836
10275 changed files with 3284984 additions and 0 deletions
+46
View File
@@ -0,0 +1,46 @@
# الفصل التاسع · التطور المستمر للوكلاء
> يشرح كيف ينمو الوكيل بالخبرة، أحيانًا من دون تغيير الأوزان، وكيف ينتقل من استخدام الأدوات إلى إنشائها، ومن القدرة العامة إلى المهارة الموثوقة.
← [العودة إلى الملف التمهيدي الرئيسي](../docs/ar/README.md) · 📖 [قراءة نص الفصل](../book-ar/chapter9.ar.md)
## كيفية قراءة التجارب
يستخدم النص هياكل آلية قصيرة لشرح تدفق التحكم؛ ويحتوي دليل التجارب على محولات SDK الكاملة والسجلات والاختبارات وأدلة القبول. لا حاجة لقراءة كل ملف سطرًا سطرًا.
- **Starter:** ابدأ بالهدف والأمر الأدنى وشروط القبول؛ وابدأ من [trajectory-verifier](trajectory-verifier/);
- **Builder:** تتبّع نقطة الدخول والحلقة الأساسية ومخطط الحالة/الرسائل والأدوات وأداة التحقق.
- **Maintainer:** ثم اقرأ الاختبارات وmanifest الأدلة ومعالجة الأعطال ومسارات التراجع ومحولات المزوّد.
في القراءة الأولى يمكنك تجاوز بيانات الاعتماد وطبقة العرض وتوافق المزوّد؛ عُد إليها عند إعادة إنتاج رقم.
## المشاريع المصاحبة
| التجربة | المشروع | النوع | الوصف |
| :--: | --- | :--: | --- |
| 8-1 | [متحقق المسار](trajectory-verifier/) | ✅ | التجربة 8-1: تجمع نتائج البيئة وقواعد العمليات والمعايير اللغوية في تشخيص مدعوم بالأدلة لمسارات خدمة العملاء |
| 8-2 | [خبرة GAIA](gaia-experience/) | ✅ | التجربة 8-2: تقارن المسارات الناجحة والجزئية والفاشلة لإنشاء وثائق خبرة بصيغة Markdown تستخلص الأنماط المشتركة بينها |
| 8-3 | [التحسين التلقائي للموجّهات](prompt-auto-optimization/) | ✅ | التجربة 8-3: توليد تعديلات دنيا للموجّه من المسارات الفاشلة، وضبط الإطلاق باستخدام مجموعة حالات حدية ومجموعة احتفاظ |
| 8-4 | [تحويل مسارات المتصفح إلى RPA](browser-use-rpa/) | ✅ | التجربة 8-4: تحوّل مسارات المتصفح إلى سير عمل ذي شروط حالة، ثم تتحقق منه بإعادة الضبط والتشغيل |
| 8-5 | [وكيل يعدّل نفسه](self-modifying-agent/) | ✅ | التجربة 8-5: تحوّل حالات الفشل المتكررة إلى تعديلات لآلية إعادة المحاولة وقاطع الدائرة، ثم تمر باختبارات الانحدار والإطلاق التدريجي والتراجع |
| 8-6 | [تطور Hermes الذاتي](hermes-self-evolution/) | 📖 | يعطي Hermes الكتاب كاملًا وشفرتَه؛ يختار تحسينًا ويعدّل نفسه، ثم يحوّل كل رفض من Reviewer إلى جولة تعلم جديدة حتى يُقبل |
| 8-7 | [تقييم التطور الذاتي](self-evolution-eval/) | ✅ | التجربة 8-7: تقيم التطور طويل المدى عبر أربع مراحل - التعلم والنقل وتغيير القواعد والاحتفاظ بها |
| 8-8 | [harness-safety-gate](harness-safety-gate/) | ✅ | بوابة تأكيد للعمليات عالية المخاطر |
| 8-9 | [ai-style-skill](ai-style-skill/) | ✅ | تحويل ملاحظات الكتابة إلى Skill قابلة للتحقق؛ يربط الفصل Skill علامات الاقتباس المنحنية ببيانات اصطناعية مدققة وبالتدريب اللاحق، ويفصل أخطاء tokenizer/Harness في النسخ الدقيق |
توفر جميع التجارب السابقة وضعًا يعمل دون اتصال واختبارات وحدة لا تحتاج إلى مفتاح API. وتوثق ملفات README لكل مشروع مسارات التوسع التي تتطلب نموذجًا حقيقيًا أو متصفحًا.
## حالات تكميلية
| التجربة | المشروع | العلاقة |
| :--: | --- | --- |
| 7-8 | [تقطير الموجّهات](prompt-distillation/) | مشروع مشترك بين الفصول حول تقطير الموجّهات والتعلم داخل المعلمات؛ وتنتمي طريقة التدريب إلى الفصل السابع |
| — | [أدوات تتطور ذاتيًا](self-evolving-tools/) | اكتشاف الأدوات وتغليفها وإعادة استخدامها بأسلوب Alita؛ وهي حالة مكملة لتجربة تحويل الخبرة إلى برامج |
## أنواع المشاريع
| الأيقونة | النوع | المعنى |
| :--: | --- | --- |
| ✅ | **مستقل** | شفرة كاملة قابلة للتشغيل في هذا المستودع بعد إعداد مفتاح API |
| 📖 | **دليل إعادة الإنتاج** | وثائق تفصيلية تعتمد على مستودع خارجي يُجلب باستخدام `git clone` |
| 🚧 | **وثيقة التصميم** | وثيقة تصميم وخطة تنفيذ؛ أما الشفرة القابلة للتشغيل فما تزال قيد التطوير |
+47
View File
@@ -0,0 +1,47 @@
# Chapter 9 · Agent Self-Evolution
> Growth without changing weights. Three learning paradigms, learning from experience, and the journey from "tool user" to "tool creator," allowing Agents to progress from "smart" to "skilled."
← [Back to main README](../docs/en/README.md) · 📖 [Read chapter text](../book-en/chapter9.md)
## How to Read the Experiments
The prose uses short mechanism skeletons to explain control flow; the experiment directory contains complete SDK adapters, logs, tests, and acceptance evidence. You do not need to read every file line by line.
- **Starter:** Start with the goal, minimum command, and acceptance conditions; begin with [trajectory-verifier](trajectory-verifier/);
- **Builder:** Follow the entry point, core loop, state/message schema, tools, and verifier.
- **Maintainer:** Then read tests, evidence manifests, failure handling, rollback paths, and provider adapters.
On a first pass, skip credential loading, presentation code, and provider-compatibility layers; return when reproducing a number.
## Companion Projects
| Exp. | Project | Type | Description |
| :--: | --- | :--: | --- |
| 8-1 | [trajectory-verifier](trajectory-verifier/) | ✅ | Experiment 8-1: combines environment outcomes, process rules, and language rubrics into evidence-backed diagnoses of customer-service trajectories |
| 8-2 | [gaia-experience](gaia-experience/) | ✅ | Experiment 8-2: compares successful, partially successful, and failed trajectories to generate cross-trajectory Markdown experience documents |
| 8-3 | [prompt-auto-optimization](prompt-auto-optimization/) | ✅ | Experiment 8-3: generates minimal prompt patches from failed trajectories, controlling release with a boundary set and a retention set |
| 8-4 | Text experiment | 🚧 | Experiment 8-4: evolves a requirements-clarification and Spec-confirmation Skill from user feedback, with a three-arm A/B design and release gates |
| 8-5 | [browser-use-rpa](browser-use-rpa/) | ✅ | Experiment 8-5: compiles browser trajectories into workflows with state predicates, verified by reset-and-replay |
| 8-6 | [self-modifying-agent](self-modifying-agent/) | ✅ | Experiment 8-6: repeated failures trigger retry/circuit-breaker code patches, regression tests, canary rollout, and rollback |
| 8-7 | [harness-safety-gate](harness-safety-gate/) | ✅ | Experiment 8-7: evolves a high-risk operation confirmation gate from user corrections and audits |
| 8-8 | [hermes-self-evolution](hermes-self-evolution/) | 📖 | Experiment 8-8: gives Hermes the whole book and its own source; it chooses an improvement, changes itself, and turns each Reviewer rejection into another learning round until accepted |
| 8-9 | [self-evolution-eval](self-evolution-eval/) | ✅ | Experiment 8-9: evaluates long-term evolution across four phases — learning, transfer, rule change, and retention |
All experiments above offer offline entry points and unit tests that require no API Key; extension paths that need real models or a browser are documented in each project's README.
## Supplementary Cases
| Exp. | Project | Relation |
| :--: | --- | --- |
| 7-8 | [prompt-distillation](prompt-distillation/) | Cross-chapter project on prompt distillation and parameterized learning; the training method belongs to Chapter 7 |
| — | [self-evolving-tools](self-evolving-tools/) | Alita-style tool discovery, encapsulation, and reuse — a supplementary case of "writing experience into programs" |
| — | [ai-style-skill](ai-style-skill/) | Supplementary writing-Skill case; the main example appears in Chapter 2 |
## Project Types
| Icon | Type | Meaning |
| :--: | --- | --- |
| ✅ | **Standalone** | Full code in this repo, runs after configuring API Key |
| 📖 | **Reproduction Guide** | Detailed doc depending on **external repos** to `git clone` |
| 🚧 | **Design Doc** | Architecture/implementation plan only, runnable code still WIP |
+44
View File
@@ -0,0 +1,44 @@
# Capítulo 9 · Auto-Evolución del Agente
> Crecimiento sin cambiar pesos: aprendizaje a partir de la experiencia, de usuario de herramientas a creador de herramientas
← [Volver al README principal](../docs/es/README.md) · 📖 [Leer texto del capítulo](../book-es/chapter9.es.md)
## Cómo leer los experimentos
El texto usa skeletons breves para explicar el flujo de control; el directorio de experimentos contiene adaptadores SDK completos, registros, pruebas y evidencias de aceptación. No hace falta leer cada archivo línea por línea.
- **Starter:** Empieza por el objetivo, el comando mínimo y la aceptación; comienza con [trajectory-verifier](trajectory-verifier/);
- **Builder:** Sigue el punto de entrada, el bucle central, el esquema de estado/mensajes, las herramientas y el verificador.
- **Maintainer:** Después revisa pruebas, manifiestos, fallos, rollback y adaptadores de proveedores.
En la primera pasada puedes omitir credenciales, presentación y compatibilidad de proveedores; vuelve al reproducir una cifra.
## Proyectos Complementarios
| Exp. | Proyecto | Tipo | Descripción |
| :--: | --- | :--: | --- |
| 8-1 | [trajectory-verifier](trajectory-verifier/) | ✅ | Diagnóstico de trayectorias con evidencias basadas en resultados del entorno y reglas |
| 8-2 | [gaia-experience](gaia-experience/) | ✅ | Comparación de trayectorias exitosas y fallidas para generar documentación de experiencia |
| 8-3 | [prompt-auto-optimization](prompt-auto-optimization/) | ✅ | Generación de parches mínimos de prompts a partir de trayectorias fallidas con control de versión |
| 8-4 | [browser-use-rpa](browser-use-rpa/) | ✅ | Compilación de trayectorias de navegador en flujos de trabajo con predicados de estado |
| 8-5 | [self-modifying-agent](self-modifying-agent/) | ✅ | Parches de código de reintento/disyuntor activados por fallos repetidos con regresión y despliegue |
| 8-6 | [hermes-self-evolution](hermes-self-evolution/) | 📖 | Da a Hermes el libro y su propio código; elige una mejora, se modifica y convierte cada rechazo del Reviewer en otra ronda de aprendizaje hasta ser aceptado |
| 8-7 | [self-evolution-eval](self-evolution-eval/) | ✅ | Evaluación 8-7 de evolución a largo plazo en cuatro etapas: aprendizaje, transferencia, reglas y retención |
| 8-8 | [harness-safety-gate](harness-safety-gate/) | ✅ | Puerta de confirmación para operaciones de alto riesgo |
| 8-9 | [ai-style-skill](ai-style-skill/) | ✅ | Convierte comentarios de escritura en un Skill verificable; el capítulo conecta la Skill de comillas curvas con datos sintéticos auditados y posentrenamiento, y separa los fallos de tokenizer/Harness en la copia exacta |
## Casos Complementarios
| Exp. | Proyecto | Relación |
| :--: | --- | --- |
| 7-8 | [prompt-distillation](prompt-distillation/) | Proyecto cruzado de destilación de prompts y aprendizaje parametrizado (Capítulo 7) |
| — | [self-evolving-tools](self-evolving-tools/) | Descubrimiento, encapsulación y reutilización de herramientas estilo Alita |
## Tipos de Proyectos
| Icono | Tipo | Significado |
| :--: | --- | --- |
| ✅ | **Autónomo** | Código completo en este repositorio, se ejecuta tras configurar la Clave API |
| 📖 | **Guía de Reproducción** | Documento detallado que depende de **repositorios externos** para realizar `git clone` |
| 🚧 | **Documento de Diseño** | Solo arquitectura/plan de implementación, el código ejecutable aún está en desarrollo |
+39
View File
@@ -0,0 +1,39 @@
# 9. fejezet · Az ágensek folyamatos evolúciója
> Tapasztalatból fejleszti az ágenst: nyomvonalakat ellenőriz, tudást desztillál, promptokat javít, munkafolyamatokat készít, és ellenőrzött módon módosítja önmagát.
← [Vissza a magyar főoldalhoz](../docs/hu/README.md) · 📖 [A fejezet olvasása](../book-hu/chapter9.md)
## Hogyan olvassuk a kísérleteket?
A törzsszöveg rövid mechanizmus-skeletonokkal magyarázza a vezérlési folyamatot; a kísérleti könyvtárakban találhatók a teljes SDK-adapterek, naplók, tesztek és átvételi bizonyítékok. Nem kell minden fájlt sorról sorra elolvasni.
- **Starter:** Kezdje a céllal, a minimális paranccsal és az átvételi feltételekkel; induljon innen: [trajectory-verifier](trajectory-verifier/);
- **Builder:** Kövesse a belépési pontot, a fő ciklust, az állapot-/üzenetsémát, az eszközöket és az ellenőrzőt.
- **Maintainer:** Végül olvassa el a teszteket, a bizonyíték-manifeszteket, a hibakezelést, a visszaállítási útvonalakat és a provider-adaptereket.
Első olvasáskor átugorható a hitelesítő adatok betöltése, a megjelenítési réteg és a provider-kompatibilitás; a számok reprodukálásakor térjen vissza.
## Kapcsolódó projektek
| Kísérlet | Projekt | Típus | Leírás |
| :--: | --- | :--: | --- |
| 8-1 | [trajectory-verifier](trajectory-verifier/) | ✅ | A környezeti eredményeket, folyamatszabályokat és rubrikákat bizonyítékalapú diagnózissá egyesíti. |
| 8-2 | [gaia-experience](gaia-experience/) | ✅ | Sikeres, részben sikeres és sikertelen nyomvonalakból tapasztalati dokumentumot készít. |
| 8-3 | [prompt-auto-optimization](prompt-auto-optimization/) | ✅ | Minimális promptjavítást készít, és határ- valamint megtartási készlettel vezérli a kiadást. |
| 8-4 | [browser-use-rpa](browser-use-rpa/) | ✅ | Böngészőnyomvonalakat fordít reset és visszajátszás segítségével ellenőrzött munkafolyamattá. |
| 8-5 | [self-modifying-agent](self-modifying-agent/) | ✅ | Ismételt hibák után kódjavítást indít, majd regressziót, canary kiadást és visszaállítást végez. |
| 8-6 | [hermes-self-evolution](hermes-self-evolution/) | 📖 | Hermes megkapja a teljes könyvet és saját forrását, választ egy javítást, módosítja önmagát, és minden Reviewer-elutasításból új tanulási kört indít az elfogadásig. |
| 8-7 | [self-evolution-eval](self-evolution-eval/) | ✅ | A 8-7. kísérlet hosszú távon értékeli a tanulást, átvitelt, szabályváltozást és megtartást. |
| 8-8 | [harness-safety-gate](harness-safety-gate/) | ✅ | Magas kockázatú műveletek megerősítési kapuja |
| 8-9 | [ai-style-skill](ai-style-skill/) | ✅ | Az írási visszajelzést ellenőrizhető Skill-lé alakítja; a fejezet a görbe idézőjeles Skillt auditált szintetikus adattal és utóhangolással kapcsolja össze, a pontos másolás tokenizer/Harness hibáit pedig külön kezeli |
Minden kísérlet kínál offline belépési pontot és API-kulcs nélküli egységtesztet; a valódi modellt vagy böngészőt igénylő útvonalakat az egyes projektek README-je ismerteti.
## Projekttípusok
| Ikon | Típus | Jelentés |
| :--: | --- | --- |
| ✅ | **Önálló** | A teljes kód a repository-ban található, és az API-kulcsok beállítása után futtatható. |
| 📖 | **Reprodukciós útmutató** | Külső repository szükséges, amelyet külön kell `git clone` paranccsal letölteni. |
| 🚧 | **Folyamatban** | Az implementáció vagy az elfogadási bizonyíték még nem teljes. |
+39
View File
@@ -0,0 +1,39 @@
# Bab 9 · Evolusi Berkelanjutan Agent
> Membantu Agent berkembang dari pengalaman: memverifikasi trajectory, menyuling pengetahuan, memperbaiki prompt, membuat workflow, dan memodifikasi diri secara terkendali.
← [Kembali ke README utama](../docs/id/README.md) · 📖 [Baca bab](../book-id/chapter9.md)
## Cara Membaca Eksperimen
Teks utama memakai skeleton mekanisme singkat untuk menjelaskan alur kontrol; direktori eksperimen berisi adapter SDK lengkap, log, pengujian, dan bukti penerimaan. Anda tidak perlu membaca setiap berkas baris demi baris.
- **Starter:** Mulai dari tujuan, perintah minimum, dan syarat penerimaan; awali dengan [trajectory-verifier](trajectory-verifier/);
- **Builder:** Telusuri titik masuk, loop inti, skema status/pesan, alat, dan verifier.
- **Maintainer:** Terakhir, baca pengujian, manifest bukti, penanganan kegagalan, rollback, dan adapter provider.
Pada pembacaan pertama, lewati kredensial, presentasi, dan kompatibilitas provider; kembali saat mereproduksi angka.
## Proyek Pendamping
| Eksperimen | Proyek | Jenis | Deskripsi |
| :--: | --- | :--: | --- |
| 8-1 | [trajectory-verifier](trajectory-verifier/) | ✅ | Menggabungkan hasil lingkungan, aturan proses, dan Rubric menjadi diagnosis berbasis bukti. |
| 8-2 | [gaia-experience](gaia-experience/) | ✅ | Membandingkan trajectory sukses, parsial, dan gagal untuk membuat dokumen pengalaman. |
| 8-3 | [prompt-auto-optimization](prompt-auto-optimization/) | ✅ | Menghasilkan patch prompt minimal dan mengendalikan rilis dengan set batas serta retensi. |
| 8-4 | [browser-use-rpa](browser-use-rpa/) | ✅ | Mengompilasi trajectory browser menjadi workflow yang diverifikasi melalui reset dan replay. |
| 8-5 | [self-modifying-agent](self-modifying-agent/) | ✅ | Memicu patch kode setelah kegagalan berulang, lalu melakukan regresi, canary, dan rollback. |
| 8-6 | [hermes-self-evolution](hermes-self-evolution/) | 📖 | Memberi Hermes seluruh buku dan source-nya sendiri; ia memilih peningkatan, mengubah dirinya, dan menjadikan tiap penolakan Reviewer sebagai putaran belajar baru sampai diterima. |
| 8-7 | [self-evolution-eval](self-evolution-eval/) | ✅ | Eksperimen 8-7 mengevaluasi pembelajaran, transfer, perubahan aturan, dan retensi jangka panjang. |
| 8-8 | [harness-safety-gate](harness-safety-gate/) | ✅ | Gerbang konfirmasi operasi berisiko tinggi |
| 8-9 | [ai-style-skill](ai-style-skill/) | ✅ | Mengubah umpan balik tulisan menjadi Skill yang dapat diverifikasi; bab ini menghubungkan Skill tanda kutip lengkung dengan data sintetis yang diaudit dan post-training, serta memisahkan kegagalan tokenizer/Harness pada penyalinan eksak |
Semua eksperimen menyediakan entry point offline dan unit test tanpa API Key; jalur yang membutuhkan model nyata atau browser dijelaskan dalam README proyek.
## Jenis Proyek
| Ikon | Jenis | Arti |
| :--: | --- | --- |
| ✅ | **Mandiri** | Kode lengkap tersedia di repositori dan dapat dijalankan setelah API Key dikonfigurasi. |
| 📖 | **Panduan Reproduksi** | Memerlukan repositori eksternal yang harus di-`git clone`. |
| 🚧 | **Dalam Proses** | Implementasi atau bukti penerimaan belum lengkap. |
+46
View File
@@ -0,0 +1,46 @@
# 第9章 · Agent の自己進化
> 重みを変えずに成長する。3 つの学習パラダイム、経験からの学習、そして「ツールの利用者」から「ツールの創造者」への道のりを通じて、Agent を「賢い」段階から「熟練した」段階へと進化させる。
← [メイン README に戻る](../docs/ja/README.md) · 📖 [章の本文を読む](../book-ja/chapter9.ja.md)
## 実験の読み方
本文では短い mechanism skeleton で制御フローを説明し、実験ディレクトリには完全な SDK アダプター、ログ、テスト、受け入れ証拠を置きます。すべてのファイルを一行ずつ読む必要はありません。
- **Starter:** 目的・最小コマンド・受け入れ条件から始め、まず [trajectory-verifier](trajectory-verifier/);
- **Builder:** エントリポイント、中心ループ、状態/メッセージ schema、ツール、検証器を追います。
- **Maintainer:** 最後にテスト、証拠 manifest、失敗処理、rollback 経路、provider adapter を読みます。
初読では認証情報、表示層、provider 互換層を飛ばし、数値を再現するときに戻ってください。
## 付随プロジェクト
| 実験 | プロジェクト | 種類 | 説明 |
| :--: | --- | :--: | --- |
| 8-1 | [trajectory-verifier](trajectory-verifier/) | ✅ | 実験 8-1:環境の結果・プロセスルール・言語 Rubric を組み合わせ、証拠付きのカスタマーサービス軌跡診断を形成する |
| 8-2 | [gaia-experience](gaia-experience/) | ✅ | 実験 8-2:成功・部分成功・失敗の軌跡を比較し、軌跡横断の Markdown 経験ドキュメントを生成する |
| 8-3 | [prompt-auto-optimization](prompt-auto-optimization/) | ✅ | 実験 8-3:失敗軌跡から最小の Prompt パッチを生成し、境界セットと保持セットでリリースを制御する |
| 8-4 | [browser-use-rpa](browser-use-rpa/) | ✅ | 実験 8-4:ブラウザ軌跡を状態述語付きのワークフローにコンパイルし、リセット再生で検証する |
| 8-5 | [self-modifying-agent](self-modifying-agent/) | ✅ | 実験 8-5:繰り返しの障害をトリガーに、リトライ/サーキットブレーカーのコードパッチ、回帰テスト、カナリアリリースとロールバックを行う |
| 8-6 | [hermes-self-evolution](hermes-self-evolution/) | 📖 | Hermes に本書全体と自分のソースを渡す。改善を選んで自分を書き換え、Reviewer の拒否を次の学習に変えながら受理まで進む |
| 8-7 | [self-evolution-eval](self-evolution-eval/) | ✅ | 実験 8-7:学習・転移・ルール変化・保持の 4 段階で長期的な進化を評価する |
| 8-8 | [harness-safety-gate](harness-safety-gate/) | ✅ | 高リスク操作の確認ゲート |
| 8-9 | [ai-style-skill](ai-style-skill/) | ✅ | 執筆フィードバックを検証可能な Skill に変換する。章では曲線引用符 Skill を監査済み合成データと追加学習に接続し、exact-copy の tokenizer/Harness 障害を分離する |
上記の実験はすべて、API キー不要のオフライン入口とユニットテストを提供する。実モデルやブラウザが必要な拡張パスは各プロジェクトの README に記載されている。
## 補足事例
| 実験 | プロジェクト | 関係 |
| :--: | --- | --- |
| 7-8 | [prompt-distillation](prompt-distillation/) | Prompt 蒸留とパラメータ化学習の章横断プロジェクト。訓練手法は第 7 章に属する |
| — | [self-evolving-tools](self-evolving-tools/) | Alita 式のツール発見・カプセル化・再利用。「経験をプログラムとして書き出す」ことの補足事例 |
## プロジェクトの種類
| アイコン | 種類 | 意味 |
| :--: | --- | --- |
| ✅ | **単独実行** | このリポジトリに完全なコードがあり、API キーを設定すれば実行できる |
| 📖 | **再現ガイド** | `git clone` が必要な**外部リポジトリ**に依存する詳細ドキュメント |
| 🚧 | **設計ドキュメント** | アーキテクチャ/実装計画のみで、実行可能なコードは未完成 |
+46
View File
@@ -0,0 +1,46 @@
# 제9장 · 에이전트의 지속적 진화
> 가중치를 바꾸지 않고도 성장하는 방법을 다룹니다. 세 가지 학습 패러다임, 경험으로부터의 학습, ‘도구 사용자’에서 ‘도구 제작자’로 나아가는 과정을 통해 에이전트가 ‘똑똑함’을 넘어 ‘능숙함’을 갖추게 합니다.
← [한국어 메인 README로 돌아가기](../docs/ko/README.md) · 📖 [제9장 본문 읽기](../book-ko/chapter9.ko.md)
## 실험 읽는 방법
본문은 짧은 메커니즘 skeleton으로 제어 흐름을 설명하고, 실험 디렉터리에는 완전한 SDK 어댑터·로그·테스트·검수 증거를 둡니다. 모든 파일을 줄 단위로 읽을 필요는 없습니다.
- **Starter:** 목표, 최소 명령, 검수 조건부터 시작하고 다음에서 출발하세요: [trajectory-verifier](trajectory-verifier/);
- **Builder:** 진입점, 핵심 루프, 상태/메시지 스키마, 도구와 verifier를 따라갑니다.
- **Maintainer:** 마지막으로 테스트, 증거 manifest, 실패 처리, rollback 경로와 provider adapter를 읽습니다.
첫 읽기에서는 credential, UI, provider 호환 계층을 건너뛰고 수치를 재현할 때 돌아오세요.
## 연계 프로젝트
| 실험 | 프로젝트 | 유형 | 설명 |
| :--: | --- | :--: | --- |
| 8-1 | [trajectory-verifier](trajectory-verifier/) | ✅ | 실제 고객 서비스 호출 28건, Judge 호출 8건, 전문가 표본 8건을 검수했습니다. [증거](trajectory-verifier/validation/real_20260729T165247Z/evidence.json)에는 핵심 위반의 안정성 주장이 재현되지 않은 사실도 함께 기록돼 있습니다. |
| 8-2 | [gaia-experience](gaia-experience/) | ✅ | 실제 GAIA 궤적 세 그룹과 지식 문서 대조를 검수했습니다. [증거](gaia-experience/validation/real_20260729T164012Z/evidence.json)에는 지식 문서 그룹이 25%, 두 대조군이 각각 50%였던 부정적 결과가 기록돼 있습니다. |
| 8-3 | [prompt-auto-optimization](prompt-auto-optimization/) | ✅ | 실제 작업 에이전트, LLM Judge, 코딩 에이전트로 초기·자동·수동 세 그룹의 전체 보존 세트와 경계 세트를 실행했으며, 원본 응답과 배포 기준을 보존했습니다. |
| 8-4 | [browser-use-rpa](browser-use-rpa/) | ✅ | 실제 ARK 에이전트와 Chromium이 초기화 가능한 로컬 메시지 사이트에서 탐색, 독립 검증, 매개변수화된 재실행, 거짓 성공 대조, 페이지 변경에 따른 무효화를 완료했습니다. |
| 8-5 | [self-modifying-agent](self-modifying-agent/) | ✅ | 실제 코딩 에이전트가 반복 장애에서 패치를 만들고, 결정론적 후보 및 의도적으로 지나치게 넓은 반례와 함께 동일한 회귀·카나리·롤백 배포 게이트를 거쳤습니다. [증거](self-modifying-agent/validation/latest.json)는 수락과 거부 이력을 모두 보존합니다. |
| 8-6 | [hermes-self-evolution](hermes-self-evolution/) | 📖 | Hermes에게 책 전체와 자기 소스를 줍니다. 스스로 개선을 고르고 자신을 수정하며, Reviewer의 거절을 다음 학습으로 바꾸어 수락될 때까지 발전합니다. |
| 8-7 | [self-evolution-eval](self-evolution-eval/) | ✅ | 실험 8-7은 정적·추가 전용·진화형 세 방식에 대해 3개 시드와 14개 작업, 총 126회의 실제 호출을 수행합니다. [증거](self-evolution-eval/validation/latest.json)는 전이, 규칙 교체, 보존, 대응표본 통계를 담고 있습니다. |
| 8-8 | [harness-safety-gate](harness-safety-gate/) | ✅ | 고위험 작업 확인 게이트 |
| 8-9 | [ai-style-skill](ai-style-skill/) | ✅ | 글쓰기 피드백을 검증 가능한 Skill로 변환하며, 곡선 따옴표 Skill을 감사된 합성 데이터·후훈련과 연결하고 exact-copy의 tokenizer/Harness 오류를 분리합니다 |
위 실험은 모두 API 키 없이 실행할 수 있는 오프라인 진입점과 단위 테스트를 사전 점검용으로 유지합니다. 표의 ✅는 각 디렉터리에 보존된 실제 모델·궤적·브라우저의 정식 증거를 근거로 하며, 오프라인 동작 시연으로 대신하지 않습니다. 과거의 수치나 정성적 주장이 재현되지 않았을 때도 부정적 결과를 증거에 그대로 기록합니다.
## 보충 사례
| 실험 | 프로젝트 | 연관성 |
| :--: | --- | --- |
| 7-8 | [prompt-distillation](prompt-distillation/) | 프롬프트 증류와 파라미터 기반 학습을 다루는 장 간 프로젝트입니다. 학습 방법 자체는 제7장에 속합니다. |
| — | [self-evolving-tools](self-evolving-tools/) | Alita 방식의 도구 발견·캡슐화·재사용을 보여 주는, ‘경험을 프로그램에 기록하기’의 보충 사례입니다. |
## 프로젝트 유형
| 아이콘 | 유형 | 의미 |
| :--: | --- | --- |
| ✅ | **독립 실행** | 전체 코드가 이 저장소에 있으며, API 키를 설정하면 실행할 수 있습니다. |
| 📖 | **재현 가이드** | **외부 저장소**를 `git clone`해야 하는 상세 안내 문서입니다. |
| 🚧 | **진행 중** | 구현은 있지만 실제 데이터·환경 또는 장기 검수 증거가 아직 완전하지 않습니다. |
+51
View File
@@ -0,0 +1,51 @@
# 第 9 章 · Agent 的持续进化
> 从运行轨迹中获得可靠信号,把经验转化为可验证、可回滚的能力更新
← [返回主目录](../README.md) · 📖 [读本章正文](../book/chapter9.md)
## 如何阅读实验
正文用 skeleton 说明验证、载体选择、候选发布/回滚和睡眠学习;项目代码按三层阅读:
- **Starter**:从 [trajectory-verifier](trajectory-verifier/) 运行离线样例,先看三层 verifier 和证据输出;
- **Builder**:再看 [self-modifying-agent](self-modifying-agent/) 的提案—回归—灰度—回滚循环,以及 [prompt-auto-optimization](prompt-auto-optimization/) 的最小 diff
- **Maintainer**:检查安全可信根、边界/保留/安全集、版本淘汰和 validation/latest.json。首次可跳过 provider 与 UI 适配。
## 配套实验
| 编号 | 项目 | 类型 | 一句话说明 |
| :--: | --- | :--: | --- |
| 9-1 | [trajectory-verifier](trajectory-verifier/) | ✅ | 实验 9-1:28 条真实客服调用、8 次 Judge 调用与 8 条专家标注样本已通过验收;[证据](trajectory-verifier/validation/real_20260729T165247Z/evidence.json)同时记录关键违规稳定性主张未复现 |
| 9-2 | [gaia-experience](gaia-experience/) | ✅ | 实验 9-2:真实 GAIA 三组轨迹与知识文档对照已验收;[证据](gaia-experience/validation/real_20260729T164012Z/evidence.json)记录知识文档组仅 25%、两控制组均 50% 的负结果 |
| 9-3 | [prompt-auto-optimization](prompt-auto-optimization/) | ✅ | 实验 9-3:真实任务 Agent、LLM Judge 与 Coding Agent 跑完初始/自动/人工三组完整保留集和边界集;原始回执与发布门槛已保存 |
| 9-4 | 正文对照实验 | 🚧 | 从用户反馈中进化“需求澄清 + Spec 确认”Skill;正文给出三臂 A/B 设计、指标和发布门槛,配套实现待补充 |
| 9-5 | [browser-use-rpa](browser-use-rpa/) | ✅ | 实验 9-5:真实 ARK Agent + Chromium 在可重置本地消息站完成探索、独立验证、参数化回放、假成功对照与页面变化失效 |
| 9-6 | [self-modifying-agent](self-modifying-agent/) | ✅ | 实验 9-6:真实 Coding Agent 从重复故障生成补丁,并与确定性提案、故意过宽的反例通过同一回归/灰度/回滚发布门;[证据](self-modifying-agent/validation/latest.json)保留接受与拒绝历史 |
| 9-7 | [harness-safety-gate](harness-safety-gate/) | ✅ | 实验 9-7:用户纠正/点踩/事后审计触发“高风险调用确认门禁”提案,经 AST 静态检查、未完成任务回放和正常操作回放;确定性提案通过,真实 `gpt-4o-mini` 提案因检查失败被安全拒绝,整体验收通过 |
| 9-8 | [hermes-self-evolution](hermes-self-evolution/) | 📖 | 实验 9-8:把整本书和源码交给 Hermes;它读完后选择一项改进,亲手修改自己,并把每次 Reviewer 的退回变成下一轮学习,直到通过 |
| 9-9 | [self-evolution-eval](self-evolution-eval/) | ✅ | 实验 9-9static、append-only、evolving 三臂 × 3 seeds × 14 任务共 126 次真实调用;[证据](self-evolution-eval/validation/latest.json)保留迁移、规则替换、保持与配对统计 |
除仍处于设计阶段的实验 9-4 外,其余带项目链接的实验都保留无需 API Key 的离线入口和单元测试用于预检;表中 ✅ 来自各目录保存的真实模型、真实轨迹或真实浏览器规范证据,不由离线机制演示代替。历史数值或定性主张未复现时,证据按负结果如实记录。
证据完整性边界:9-6、9-9 的 canonical evidence 与 `latest.json` 都有独立
SHA-256 sidecar,当前复算一致;9-5 对三个关键浏览器产物保存并核对了 hash。
9-1、9-2、9-3 的 `latest.json` 虽与各自真实 run 的 `evidence.json` 字节一致,
但没有顶层 evidence/source hash manifest,因此可审计强度低于 9-6、9-9,
不能把提交时存在的 JSON 等同于运行时源码已被固定。
## 补充案例
| 编号 | 项目 | 关系 |
| :--: | --- | --- |
| 8-8 | [prompt-distillation](../chapter8/prompt-distillation/) | Prompt 蒸馏与参数化学习的跨章项目;训练方法归入第八章 |
| — | [self-evolving-tools](self-evolving-tools/) | Alita 式工具发现、封装与复用,是“将经验写成程序”的补充案例 |
| — | [ai-style-skill](ai-style-skill/) | 写作型 Skill 的补充实验:把“去 AI 味”反馈提炼为可检查规则;正文示例移至第二章,自动更新管道和验收数据保留在项目 README |
## 项目类型说明
| 图标 | 类型 | 含义 |
| :--: | --- | --- |
| ✅ | **可独立运行** | 本仓库自带完整代码,配置好 API Key 即可运行 |
| 📖 | **复现指南** | 依赖需自行 `git clone` 的**外部仓库**(训练框架、评测基准等) |
| 🚧 | **进行中** | 已有实现,但真实数据、真实环境或纵向验收证据尚未完整 |
+46
View File
@@ -0,0 +1,46 @@
# Глава 9 · Самоэволюция агента
> Рост без изменения весов. Три парадигмы обучения, обучение на опыте и путь от «пользователя инструментов» к «создателю инструментов» — как агенту перейти от «умного» к «умелому».
← [К оглавлению](../docs/ru/README.md) · 📖 [Читать главу](../book-ru/chapter9.md)
## Как читать эксперименты
В основном тексте короткие скелеты механизмов объясняют поток управления; в каталогах экспериментов находятся полные адаптеры SDK, журналы, тесты и приёмочные доказательства. Читать каждый файл построчно не требуется.
- **Starter:** Начните с цели, минимальной команды и условий приёмки; начните с [trajectory-verifier](trajectory-verifier/);
- **Builder:** Проследите точку входа, основной цикл, схему состояния/сообщений, инструменты и проверяющий модуль.
- **Maintainer:** Затем изучите тесты, манифесты доказательств, обработку сбоев, откат и адаптеры провайдеров.
При первом чтении можно пропустить ключи, слой представления и совместимость провайдеров; вернитесь при воспроизведении чисел.
## Сопутствующие проекты
| Эксп. | Проект | Тип | Описание |
| :--: | --- | :--: | --- |
| 8-1 | [trajectory-verifier](trajectory-verifier/) | ✅ | Эксперимент 8-1: объединяет результаты среды, процессные правила и языковые рубрики в диагностику траекторий клиентской поддержки с привязкой к доказательствам |
| 8-2 | [gaia-experience](gaia-experience/) | ✅ | Эксперимент 8-2: сравнивает успешные, частично успешные и неудачные траектории, формируя межтраекторные Markdown-документы опыта |
| 8-3 | [prompt-auto-optimization](prompt-auto-optimization/) | ✅ | Эксперимент 8-3: генерирует минимальные патчи промпта из неудачных траекторий и управляет релизом через граничный и удерживающий наборы |
| 8-4 | [browser-use-rpa](browser-use-rpa/) | ✅ | Эксперимент 8-4: компилирует браузерные траектории в рабочие процессы с предикатами состояния, проверенные сбросом и повторным воспроизведением |
| 8-5 | [self-modifying-agent](self-modifying-agent/) | ✅ | Эксперимент 8-5: повторяющиеся сбои запускают патчи кода с повторами/размыкателем цепи, регрессионные тесты, канареечный релиз и откат |
| 8-6 | [hermes-self-evolution](hermes-self-evolution/) | 📖 | Hermes получает всю книгу и свой исходный код, выбирает улучшение, меняет себя и превращает каждый отказ Reviewer в новый раунд обучения до принятия |
| 8-7 | [self-evolution-eval](self-evolution-eval/) | ✅ | Эксперимент 8-7: оценивает долгосрочную эволюцию в четыре фазы — обучение, перенос, изменение правил и удержание |
| 8-8 | [harness-safety-gate](harness-safety-gate/) | ✅ | Шлюз подтверждения высокорисковых операций |
| 8-9 | [ai-style-skill](ai-style-skill/) | ✅ | Преобразует отзывы о письме в проверяемый Skill; глава связывает Skill для кривых кавычек с аудированными синтетическими данными и постобучением, а ошибки tokenizer/Harness в exact-copy разделяет |
Все перечисленные эксперименты имеют офлайн-вход без API-ключа и юнит-тесты; пути расширения, требующие реальных моделей или браузера, описаны в README каждого проекта.
## Дополнительные примеры
| Эксп. | Проект | Связь |
| :--: | --- | --- |
| 7-8 | [prompt-distillation](prompt-distillation/) | Межглавный проект по дистилляции промптов и параметризованному обучению; метод обучения относится к главе 7 |
| — | [self-evolving-tools](self-evolving-tools/) | Обнаружение, инкапсуляция и переиспользование инструментов в духе Alita — дополнительный пример «записи опыта в виде программ» |
## Типы проектов
| Значок | Тип | Значение |
| :--: | --- | --- |
| ✅ | **Автономный** | Полный код в этом репозитории, запускается после настройки API-ключа |
| 📖 | **Гайд по воспроизведению** | Подробный документ, зависящий от **внешних репозиториев** через `git clone` |
| 🚧 | **Проектный документ** | Только архитектура/план реализации, рабочий код ещё в разработке |
+46
View File
@@ -0,0 +1,46 @@
# அத்தியாயம் 9 · ஏஜென்ட் சுய-பரிணாமம்
> எடைகளை மாற்றாமலும் வளர முடியும். மூன்று கற்றல் முன்னுதாரணங்கள் — அனுபவத்திலிருந்து கற்றல், "கருவி பயனரிலிருந்து கருவி படைப்பாளராக மாறுதல்" வரை — ஏஜென்ட்டை "புத்திசாலி" என்பதிலிருந்து "திறன் மிக்க" நிலைக்குக் கொண்டு செல்கிறது.
← [முக்கிய README க்குத் திரும்பு](../docs/ta/README.md) · 📖 [அத்தியாய உரையைப் படி](../book-ta/chapter9.ta.md)
## சோதனைகளை எப்படிப் படிப்பது
முதன்மை உரை குறுகிய mechanism skeleton-களால் control flow-ஐ விளக்குகிறது; முழு SDK adapters, logs, tests, acceptance evidence ஆகியவை experiment கோப்பகத்தில் உள்ளன. ஒவ்வொரு கோப்பையும் வரி வரியாகப் படிக்க வேண்டியதில்லை.
- **Starter:** இலக்கு, குறைந்தபட்ச கட்டளை, ஏற்றுக்கொள்ளும் நிபந்தனைகளில் தொடங்குங்கள்; முதலில் [trajectory-verifier](trajectory-verifier/);
- **Builder:** நுழைவுப் புள்ளி, மையச் சுழற்சி, state/message schema, கருவிகள், verifier ஆகியவற்றைப் பின்தொடருங்கள்.
- **Maintainer:** பின்னர் tests, evidence manifest, தோல்வி கையாளல், rollback பாதை, provider adapter ஆகியவற்றைப் படியுங்கள்.
முதல் வாசிப்பில் credentials, UI, provider-compatibility அடுக்குகளைத் தவிர்க்கலாம்; முடிவுகளை மீண்டும் உருவாக்கும்போது திரும்பிப் பாருங்கள்.
## துணை திட்டங்கள்
| சோதனை | Project | Type | Description |
| :--: | --- | :--: | --- |
| 8-1 | [trajectory-verifier](trajectory-verifier/) | ✅ | சோதனை 8-1: சூழல் முடிவுகள், செயல்முறை விதிகள், மொழி Rubric ஆகியவற்றை இணைத்து ஆதாரத்துடன் கூடிய வாடிக்கையாளர் சேவைப் பாதை நோயறிதலை உருவாக்குகிறது |
| 8-2 | [gaia-experience](gaia-experience/) | ✅ | சோதனை 8-2: வெற்றிகரமான, ஓரளவு வெற்றிகரமான மற்றும் தோல்வியுற்ற பாதைகளை ஒப்பிட்டு, பாதைகளுக்கு இடையிலான Markdown அனுபவ ஆவணங்களை உருவாக்குகிறது |
| 8-3 | [prompt-auto-optimization](prompt-auto-optimization/) | ✅ | சோதனை 8-3: தோல்விப் பாதைகளிலிருந்து குறைந்தபட்ச Prompt இணைப்புகளை உருவாக்கி, எல்லைத் தொகுப்பு மற்றும் தக்கவைப்புத் தொகுப்பு மூலம் வெளியீட்டைக் கட்டுப்படுத்துகிறது |
| 8-4 | [browser-use-rpa](browser-use-rpa/) | ✅ | சோதனை 8-4: உலாவிப் பாதைகளை நிலை முன்னுரிமைகளுடன் (state predicates) கூடிய பணிப்பாய்வுகளாகத் தொகுத்து, மீளமைப்பு-மறுஇயக்கத்தால் சரிபார்க்கப்பட்டதாக்குகிறது |
| 8-5 | [self-modifying-agent](self-modifying-agent/) | ✅ | சோதனை 8-5: மீண்டும் மீண்டும் ஏற்படும் கோளாறுகள் மறுயறி/சர்க்யூட் பிரேக்கர் குறியீட்டு இணைப்புகள், பின்னடைவுச் சோதனைகள், கனேரி வெளியீடு மற்றும் மீள்திருப்பத்தைத் தூண்டுகின்றன |
| 8-6 | [hermes-self-evolution](hermes-self-evolution/) | 📖 | முழுப் புத்தகத்தையும் தனது source-ஐயும் Hermes-க்கு கொடுக்கிறது; அது ஒரு மேம்பாட்டைத் தேர்ந்து தன்னை மாற்றி, ஒவ்வொரு Reviewer rejection-ஐயும் அடுத்த learning round-ஆக மாற்றுகிறது |
| 8-7 | [self-evolution-eval](self-evolution-eval/) | ✅ | சோதனை 8-7: கற்றல், இடமாற்றம், விதி மாற்றம், தக்கவைப்பு என்ற நான்கு கட்டங்களில் நீண்டகாலப் பரிணாமத்தை மதிப்பீடு செய்கிறது |
| 8-8 | [harness-safety-gate](harness-safety-gate/) | ✅ | அதிக ஆபத்து செயல்பாடுகளுக்கான உறுதிப்படுத்தல் வாயில் |
| 8-9 | [ai-style-skill](ai-style-skill/) | ✅ | எழுத்துப் பின்னூட்டத்தை சரிபார்க்கக்கூடிய Skill ஆக மாற்றுகிறது; வளைந்த மேற்கோள் Skill தணிக்கை செய்யப்பட்ட செயற்கைத் தரவு மற்றும் பிந்தைய பயிற்சியுடன் இணைக்கப்படுகிறது, exact-copy tokenizer/Harness பிழைகள் தனியே பிரிக்கப்படுகின்றன |
மேற்கண்ட அனைத்துச் சோதனைகளும் API Key தேவையில்லாத ஆஃப்லைன் நுழைவுகளையும் அலகுச் சோதனைகளையும் வழங்குகின்றன; உண்மையான மாதிரிகள் அல்லது உலாவி தேவைப்படும் விரிவாக்கப் பாதைகள் ஒவ்வொரு திட்டத்தின் README-இலும் விளக்கப்பட்டுள்ளன.
## துணை எடுத்துக்காட்டுகள்
| சோதனை | Project | தொடர்பு |
| :--: | --- | --- |
| 7-8 | [prompt-distillation](prompt-distillation/) | Prompt வடித்தல் மற்றும் அளவுருமய கற்றல் தொடர்பான அத்தியாயங் கடந்த திட்டம்; பயிற்சி முறை அத்தியாயம் 7-ஐச் சேர்ந்தது |
| — | [self-evolving-tools](self-evolving-tools/) | Alita பாணி கருவி கண்டுபிடிப்பு, உறையிடல் மற்றும் மறுபயன்பாடு — "அனுபவத்தை நிரலாக எழுதுதல்" என்பதன் துணை எடுத்துக்காட்டு |
## திட்ட வகைகள்
| சின்னம் | வகை | பொருள் |
| :--: | --- | --- |
| ✅ | **தனித்து இயங்கும்** | முழு குறியீடு இந்த களஞ்சியத்தில், API Key உள்ளமைத்தவுடன் இயங்கும் |
| 📖 | **மறு உருவாக்க வழிகாட்டி** | **வெளிப்புற களஞ்சியங்களை** `git clone` செய்ய வேண்டிய விரிவான ஆவணம் |
| 🚧 | **வடிவமைப்பு ஆவணம்** | கட்டமைப்பு/செயலாக்கத் திட்டம் மட்டும், இயங்கும் குறியீடு இன்னும் WIP |
+37
View File
@@ -0,0 +1,37 @@
# Bölüm 9 · Agent'ın Kendi Kendine Evrimi
> Ağırlıkları değiştirmeden büyüme. Üç öğrenme paradigması, deneyimden öğrenme ve "araç kullanıcısından" "araç yaratıcısına" giden yolculuk; Agent'ların "akıllı"dan "usta"ya ilerlemesini sağlar.
← [Ana README'ye dön](../README.tr.md) · 📖 [Bölüm metnini oku](../book-tr/chapter9.tr.md)
## Deneyler nasıl okunur
Metin, kontrol akışını açıklamak için kısa mekanizma skeleton'ları kullanır; deney dizininde tam SDK adaptörleri, günlükler, testler ve kabul kanıtı bulunur. Her dosyayı satır satır okumanız gerekmez.
- **Starter:** Hedef, en kısa komut ve kabul koşullarıyla başlayın; önce [trajectory-verifier](trajectory-verifier/);
- **Builder:** Giriş noktasını, ana döngüyü, durum/mesaj şemasını, araçları ve doğrulayıcıyı izleyin.
- **Maintainer:** Son olarak testleri, kanıt manifestlerini, hata işlemeyi, rollback yollarını ve sağlayıcı adaptörlerini okuyun.
İlk okumada kimlik bilgisi yükleme, sunum katmanı ve sağlayıcı uyumluluğunu atlayıp sayıları yeniden üretirken dönün.
## Eşlik Eden Projeler
| Proje | Tür | Açıklama |
| --- | :--: | --- |
| [gaia-experience](gaia-experience/) | ✅ | AWorld çerçevesi ve GAIA kıstasına dayalı olarak eksiksiz bir "öğren-uygula" döngüsü uygular. Ajan, başarılı görev izlencelerini otomatik olarak yapılandırılmış deneyimlere özetler ve bunları yeni görevlerde getirip uygulayarak kendi kendine evrim gerçekleştirir. |
| [browser-use-rpa](browser-use-rpa/) | ✅ | Tarayıcı otomasyonu için bir iş akışı kayıt sistemi uygular; tekrarlanan işlem dizilerini otomatik olarak parametreli araçlara kapsüller. Pahalı LLM çıkarımından kesin otomatik yürütmeye geçerek 3-5 kat hız artışı sağlar. |
| [prompt-distillation](prompt-distillation/) | ✅ | Karmaşık promptların etkinliğini model parametrelerine damıtır; çıkarım sırasında prompt uzunluğunu azaltır ve bağlamsal deneyimi parametreli bilgiye dönüştürür. |
| [prompt-auto-optimization](prompt-auto-optimization/) | ✅ | İnsan geri bildirimine dayalı otomatik sistem istemi öğrenimi: tau-bench tarzı havayolu müşteri hizmetleri "aşırı aktarma" sorununu örnek alarak, bir Coding Agent sistem istem dosyasını okur, sorunlu kuralları belirler, kesin değişiklikler üretir ve istem dosyasını gerçekten yeniden yazar. Ardından değişiklikleri yeniden değerlendirir, "geri bildirim → yeniden yazma → doğrulama" döngüsü oluşturur. |
| [self-evolving-tools](self-evolving-tools/) | ✅ | Alita tarzı bir "minimal ön tanım, maksimum kendi kendine evrim" yaklaşımı: ajanın önceden inşa edilmiş alana özgü aracı yoktur, yalnızca beş genel meta-araç vardır. Yapamayacağı bir görevle karşılaştığında, açık kaynak kütüphaneler/API'ler için web'de arama yapar, dokümantasyon okur, bir sandbox'ta test eder, uygulanabilir çözümleri yeni araçlar olarak kapsüller, yeniden kullanım için araç kütüphanesinde saklar ve tüm süreç boyunca halüsinasyon kontrolüne vurgu yapar. |
| [hermes-self-evolution](hermes-self-evolution/) | 📖 | Deney 8-6: Hermes'e kitabın tamamını ve kendi kaynağını verir; bir iyileştirme seçip kendini değiştirir ve her Reviewer reddini kabul edilene kadar yeni bir öğrenme turuna dönüştürür. |
| [self-evolution-eval](self-evolution-eval/) | ✅ | Deney 8-7: öğrenme, aktarım, kural değişimi ve korumayı kapsayan uzun vadeli üç kollu değerlendirme; 3 seed × 14 sıralı görev boyunca 126 gerçek çağrının kanıtını saklar. |
| [harness-safety-gate](harness-safety-gate/) | ✅ | Yüksek riskli işlemler için onay kapısı (8-8). |
| [ai-style-skill](ai-style-skill/) | ✅ | Yazım geri bildirimini doğrulanabilir Skill'e dönüştürür (8-9); bölüm, kıvrımlı tırnak Skill'ini denetlenmiş sentetik veri ve sonradan eğitimle ilişkilendirir, exact-copy tokenizer/Harness hatalarını ayırır. |
## Proje Türleri
| İkon | Tür | Anlamı |
| :--: | --- | --- |
| ✅ | **Bağımsız** | Bu depoda tam kod, API Key yapılandırıldıktan sonra çalışır |
| 📖 | **Yeniden Üretim Rehberi** | `git clone` ile **harici depolara** bağımlı ayrıntılı belge |
| 🚧 | **Tasarım Belgesi** | Yalnızca mimari/uygulama planı, çalıştırılabilir kod henüz hazır değil |
+46
View File
@@ -0,0 +1,46 @@
# Chương 9 · Tự tiến hóa của Agent
> Agent vẫn có thể trưởng thành mà không cần sửa trọng số. Ba mô thức học tập: học từ kinh nghiệm, và từ “người dùng công cụ” thành “người tạo công cụ”, giúp Agent đi từ “thông minh” tới “thành thạo”.
← [Về README chính](../docs/vi/README.md) · 📖 [Đọc nội dung chương](../book-vi/chapter9.vi.md)
## Cách đọc các thí nghiệm
Phần văn bản dùng skeleton cơ chế ngắn để giải thích luồng điều khiển; thư mục thí nghiệm chứa adapter SDK đầy đủ, log, kiểm thử và bằng chứng nghiệm thu. Không cần đọc từng tệp theo từng dòng.
- **Starter:** Bắt đầu từ mục tiêu, lệnh tối thiểu và điều kiện nghiệm thu; hãy bắt đầu với [trajectory-verifier](trajectory-verifier/);
- **Builder:** Lần theo điểm vào, vòng lặp lõi, schema trạng thái/tin nhắn, công cụ và verifier.
- **Maintainer:** Sau đó đọc test, manifest bằng chứng, xử lý lỗi, đường rollback và adapter nhà cung cấp.
Lần đầu có thể bỏ qua credential, lớp trình bày và tương thích provider; quay lại khi cần tái tạo số liệu.
## Dự án đi kèm
| Thí nghiệm | Project | Type | Description |
| :--: | --- | :--: | --- |
| 8-1 | [trajectory-verifier](trajectory-verifier/) | ✅ | Thí nghiệm 8-1: kết hợp kết quả môi trường, quy tắc quá trình và rubric ngôn ngữ thành chẩn đoán trajectory chăm sóc khách hàng có bằng chứng |
| 8-2 | [gaia-experience](gaia-experience/) | ✅ | Thí nghiệm 8-2: so sánh trajectory thành công, thành công một phần và thất bại để sinh tài liệu kinh nghiệm Markdown xuyên trajectory |
| 8-3 | [prompt-auto-optimization](prompt-auto-optimization/) | ✅ | Thí nghiệm 8-3: sinh bản vá prompt tối thiểu từ trajectory thất bại, kiểm soát phát hành bằng tập biên và tập giữ lại |
| 8-4 | [browser-use-rpa](browser-use-rpa/) | ✅ | Thí nghiệm 8-4: biên dịch trajectory trình duyệt thành workflow có vị từ trạng thái (state predicates), được kiểm chứng bằng phát lại sau reset |
| 8-5 | [self-modifying-agent](self-modifying-agent/) | ✅ | Thí nghiệm 8-5: lỗi lặp lại kích hoạt bản vá mã retry/circuit-breaker, kiểm thử hồi quy, phát hành canary và rollback |
| 8-6 | [hermes-self-evolution](hermes-self-evolution/) | 📖 | Đưa cho Hermes toàn bộ cuốn sách và mã nguồn của chính nó; Hermes chọn một cải tiến, tự sửa mình và biến mỗi lần Reviewer từ chối thành một vòng học mới cho tới khi được chấp nhận |
| 8-7 | [self-evolution-eval](self-evolution-eval/) | ✅ | Thí nghiệm 8-7: đánh giá tiến hóa dài hạn qua bốn giai đoạn — học, chuyển giao, thay đổi quy tắc và giữ vững |
| 8-8 | [harness-safety-gate](harness-safety-gate/) | ✅ | Cổng xác nhận thao tác rủi ro cao |
| 8-9 | [ai-style-skill](ai-style-skill/) | ✅ | Chuyển phản hồi viết thành Skill có thể kiểm chứng; chương nối Skill dấu ngoặc kép cong với dữ liệu tổng hợp đã kiểm toán và hậu huấn luyện, đồng thời tách lỗi tokenizer/Harness trong sao chép chính xác |
Tất cả thí nghiệm trên đều có lối chạy offline và unit test không cần API Key; các hướng mở rộng cần model thật hoặc trình duyệt được ghi trong README của từng dự án.
## Trường hợp bổ sung
| Thí nghiệm | Project | Quan hệ |
| :--: | --- | --- |
| 7-8 | [prompt-distillation](prompt-distillation/) | Dự án xuyên chương về chưng cất prompt và học tham số hóa; phương pháp huấn luyện thuộc Chương 7 |
| — | [self-evolving-tools](self-evolving-tools/) | Khám phá, đóng gói và tái sử dụng công cụ kiểu Alita — trường hợp bổ sung của “viết kinh nghiệm thành chương trình” |
## Phân loại dự án
| Biểu tượng | Loại | Ý nghĩa |
| :--: | --- | --- |
| ✅ | **Chạy độc lập** | Có mã đầy đủ trong kho, chạy được sau khi cấu hình API Key |
| 📖 | **Hướng dẫn tái hiện** | Tài liệu chi tiết, cần `git clone` **kho ngoài** |
| 🚧 | **Tài liệu thiết kế** | Chỉ có kiến trúc/phương án, mã chạy được đang hoàn thiện |
+47
View File
@@ -0,0 +1,47 @@
# 第 9 章 · Agent 的自我進化
> 不改權重也能成長:經驗學習、從工具使用者到創造者
← [返回主目錄](../docs/zh-TW/README.md) · 📖 [讀本章正文](../book/chapter9.md)
## 如何閱讀實驗
正文用短小的機制 skeleton 說明控制流;實驗目錄放完整的 SDK 適配、日誌、測試與驗收證據,不需要逐行讀完每個檔案。
- **Starter:** 先讀目標、最小指令與驗收條件;可從 [trajectory-verifier](trajectory-verifier/);
- **Builder:** 沿著入口、核心迴圈、狀態/訊息 schema、工具與驗證器閱讀。
- **Maintainer:** 最後再看測試、證據 manifest、失敗處理、回滾路徑與 provider adapter。
第一次閱讀可先跳過憑證載入、展示層和 provider 相容層;要重現數字時再回來查看。
## 配套專案
| 編號 | 專案 | 型別 | 一句話說明 |
| :--: | --- | :--: | --- |
| 8-1 | [trajectory-verifier](trajectory-verifier/) | ✅ | 實驗 8-1:用環境結果、過程規則和語言 Rubric 形成帶證據的客服軌跡診斷 |
| 8-2 | [gaia-experience](gaia-experience/) | ✅ | 基於 AWorld + GAIA 的「學習-應用」閉環:自動總結成功軌跡為結構化經驗,在新任務中檢索應用 |
| 8-3 | [prompt-auto-optimization](prompt-auto-optimization/) | ✅ | 以 tau-bench 航空客服「過度轉接」為例,Coding Agent 讀/改 prompt 檔案 → 重新評測 → 驗證閉環 |
| 8-4 | 正文實驗 | 🚧 | 實驗 8-4:從使用者回饋進化「需求澄清 + Spec 確認」Skill,正文提供三臂 A/B 設計與發布門檻 |
| 8-5 | [browser-use-rpa](browser-use-rpa/) | ✅ | 實驗 8-5:瀏覽器工作流錄製系統,把重複操作封裝為參數化工具,透過重置與回放驗證 |
| 8-6 | [self-modifying-agent](self-modifying-agent/) | ✅ | 實驗 8-6:由重複故障觸發重試/熔斷程式碼補丁、迴歸、灰度與回滾 |
| 8-7 | [harness-safety-gate](harness-safety-gate/) | ✅ | 實驗 8-7:由使用者糾正與事後審計進化高風險操作確認門禁 |
| 8-8 | [hermes-self-evolution](hermes-self-evolution/) | 📖 | 實驗 8-8:把整本書與原始碼交給 Hermes;它讀完後選擇一項改進、親手修改自己,並把每次 Reviewer 退回變成下一輪學習,直到通過 |
| 8-9 | [self-evolution-eval](self-evolution-eval/) | ✅ | 實驗 8-9:三臂、3 seeds、14 任務的長期學習、遷移、規則替換與保留評估 |
以上實驗都提供無需 API Key 的離線入口和單元測試;需要真實模型或瀏覽器的擴充路徑在各專案 README 中另行說明。
## 補充案例
| 編號 | 專案 | 關係 |
| :--: | --- | --- |
| 7-8 | [prompt-distillation](prompt-distillation/) | 將複雜提示的效果蒸餾進模型引數,減少推理提示長度,把上下文經驗固化為引數化知識 |
| — | [self-evolving-tools](self-evolving-tools/) | Alita 式「最小預定義,最大自我進化」:五個通用元工具,自己上網找庫/讀文件/沙箱測試並封裝複用 |
| — | [ai-style-skill](ai-style-skill/) | 寫作型 Skill 補充案例;正文示例已移至第二章 |
## 專案型別說明
| 圖示 | 型別 | 含義 |
| :--: | --- | --- |
| ✅ | **可獨立執行** | 本倉庫自帶完整程式碼,配置好 API Key 即可執行 |
| 📖 | **復現指南** | 依賴需自行 `git clone` 的**外部倉庫**(訓練框架、評測基準等) |
| 🚧 | **設計文件** | 僅包含架構與實現方案,可執行程式碼仍在完善中 |
+55
View File
@@ -0,0 +1,55 @@
# AI 写作风格 Skill:开放式提炼 + LLM-as-a-judge
本项目把用户对「AI 味」的 before/after 纠正持续提炼为写作规则。提炼代码不再包含
`PATTERN_LIBRARY` 或 detector 白名单:模型可以从反馈中发现词表之外的语义、句法、
语气和篇章问题。所有候选规则统一交给 LLM judge,并且只有通过独立人工金标校准后
才能进入最终 Skill。
默认模型是 OpenAI `gpt-5.6-sol`OpenAI 直连使用 Responses API 的 JSON 输出。
```bash
# 从仓库根目录开始
uv sync --locked --python 3.12 --extra ch8
source .venv/bin/activate
source ~/.zshrc # 载入本机 OPENAI_API_KEY
cd chapter9/ai-style-skill
python -m pytest -q test_pipeline.py
python run_ai_style_skill.py # 默认 --provider openai --model gpt-5.6-sol
```
单元测试用假的批量 judge,因而无需 API key;完整验收必须调用真实模型。原始请求、响应、
Token 用量、延迟和哈希写入 `validation/<run>/evidence.json`,凭据值不会落盘。
## 完整循环
1. **收集**`data/feedback_pairs.json`):26 条用户纠正,除了原有案例,还加入长句信息堆叠、重复「值得注意的是」和连续被动语态等库外问题。
2. **开放式提炼**`extract_rules.py`):模型一次比较完整反馈语料,在同一次语义判断中归并重复现象、分开不同问题。这样不会因批次顺序把一个概念拆成多个 id,或把两个相似表面形式误并。代码验证 source id 和正反例确实来自输入,并把 detector 固定为 `{"type": "llm"}`
3. **合并**`skill_manager.py`):模型输出必须使用唯一规则 id;管理器只做机械的 id 去重与来源合并,不再按预置 detector 指纹过滤。模型仍只能提出候选,不能自行激活规则。
4. **校准**`judge.py``data/golden_set.json`):每条规则用与其反馈来源关联的独立人工正反例校准;一致率低于 0.8 或没有金标覆盖时拒绝上线。
5. **评估**`evaluate.py``data/eval_texts.json`):人工标注使用反馈来源而不是写死模型生成的规则 id。这样规则名称可动态变化,同时库外保留样本仍能揭示漏检。
6. **改写**`rewrite_demo.py`):只把通过校准的 active 规则交给模型改写,不再提供预置换写模式。
judge 会在一次请求里评判一段文本与全部 active 规则,避免为每条规则分别调用 API;
校准时也会批量评判一条规则的全部金标样本。解析失败或缺失 verdict 一律按校准不一致处理。
## 防止新的自洽闭环
- 提炼输入只有用户纠正和当前已提炼规则,没有八类模式清单。
- 候选不会因为无法映射到已有 detector 而回退或丢弃。
- 金标集和 boundary/retention 集是独立人工文本,不复用提炼用的 before/after。
- 评估集明确保留三类原模式库之外的问题,并为相似但合理的写法提供 retention 反例。
- 激活、阈值门槛、证据校验和 API 回执仍由模型外部代码控制。
第三方 OpenAI 兼容端点仍可通过 `--provider ark``--provider openrouter` 使用;本项目的
OpenAI 默认路径和验收基线使用 `gpt-5.6-sol`
## 真实验收结果(2026-08-18
使用 `--provider openrouter --model openai/gpt-5.6-sol` 完成真实运行,证据见
`validation/real_20260818T130450Z/evidence.json``validation/latest.json` 为同一份结果:
- 完整语料开放式提炼出 11 条唯一规则,11 条全部通过独立金标校准,一致率均为 1.0。
- boundary 检出 10/11,超过 0.85 门槛;retention 误伤 0/11。
- 长句信息堆叠、重复强调套话和连续被动语态三类库外保留样本全部命中对应新规则。
- 35 次真实 API 调用均保留回执,共使用 86,002 tokens;证据文件不记录凭据值。
@@ -0,0 +1,244 @@
[
{
"id": "rule-excessive-em-dash-separators",
"name": "避免用连续破折号代替常规断句",
"definition": "当一句或一段中连续使用多个破折号来串联普通并列信息、步骤或分句,导致层次不清、阅读节奏拖长时命中。应改用句号、逗号、冒号或分句。若破折号只偶尔用于插入说明、语意转折或强调,且前后关系明确,则不命中。",
"detector": {
"type": "llm"
},
"bad_example": "关于季度复盘会议——时间定于本周五下午三点——地点在三号会议室——请提前准备数据报表——如有冲突请及时告知——谢谢配合。",
"good_example": "季度复盘定在这周五下午三点,三号会议室。请提前准备好数据报表,时间冲突的话提前跟我说。",
"rewrite_hint": "先判断各部分是并列信息还是独立句意;并列项用逗号,完整意思用句号,只在确有插入或强调作用时保留破折号。",
"scope": [
"产品发布稿",
"公众号文章",
"邮件"
],
"source_ids": [
"fp-001",
"fp-008",
"fp-017"
],
"status": "candidate",
"last_confirmed_batch": 3
},
{
"id": "rule-avoid-repeated-not-but-contrast",
"name": "避免反复使用“不是……而是……”对仗",
"definition": "当“不是……而是……”被连续用于定义产品、解释概念或制造升华,尤其连续出现两次以上并形成整齐对仗时命中。这类写法常以否定铺垫代替直接说明。若上下文确实需要纠正一种明确误解,并且只使用一次来表达真实对立,则不命中。",
"detector": {
"type": "llm"
},
"bad_example": "这不是一次简单的版本更新,而是一次体验的全面革新;不是功能的堆砌,而是对用户需求的深度回应。",
"good_example": "这次版本更新改动很大:界面重做,核心流程缩短了两步,都来自上一版用户反馈里最高频的问题。",
"rewrite_hint": "删去否定式铺垫,直接说明对象是什么、具体改了什么,并用事实或例子支撑判断。",
"scope": [
"README 段落",
"产品发布稿",
"公众号文章"
],
"source_ids": [
"fp-002",
"fp-009",
"fp-015"
],
"status": "candidate",
"last_confirmed_batch": 3
},
{
"id": "rule-avoid-repeated-let-us-slogans",
"name": "避免连用“让我们”式号召口号",
"definition": "当邮件、README 或产品稿连续以“让我们”开头,使用“共同见证”“携手并进”等口号来代替具体通知、邀请或行动说明时命中。若只出现一次,且确实是在自然地发出明确、可执行的共同邀请,则不命中。",
"detector": {
"type": "llm"
},
"bad_example": "让我们共同见证这个激动人心的时刻!让我们一起开启数字化转型的全新篇章。让我们携手并进,共创辉煌明天。",
"good_example": "新版本今天上线了,期待大家试用之后多提意见。",
"rewrite_hint": "直接交代发生了什么,以及希望读者采取什么具体行动,如试用、反馈、提 issue 或提交 PR。",
"scope": [
"README 段落",
"产品发布稿",
"邮件"
],
"source_ids": [
"fp-003",
"fp-010",
"fp-018"
],
"status": "candidate",
"last_confirmed_batch": 3
},
{
"id": "rule-avoid-mechanical-sequence-markers",
"name": "避免机械堆叠顺序连接词",
"definition": "当短段落逐句套用“首先、其次、再次、然后、最后、总而言之”等连接词,实际内容本可自然串联,因而呈现模板化或公文腔时命中。若步骤顺序严格、跳步会造成错误,或连接词确实用于澄清复杂层级,则不命中。",
"detector": {
"type": "llm"
},
"bad_example": "首先,克隆本仓库到本地。其次,安装所需依赖。再次,配置环境变量。然后,运行初始化脚本。最后,启动开发服务器即可。",
"good_example": "克隆仓库后安装依赖,把 .env.example 复制为 .env 并填好密钥,再运行 init.sh 初始化,最后 make dev 启动服务。",
"rewrite_hint": "删除不提供额外信息的序号套话,改用动作之间的真实关系连接;必要时只保留少量“再”“最后”,或改成清晰的步骤列表。",
"scope": [
"README 段落",
"产品发布稿",
"邮件"
],
"source_ids": [
"fp-004",
"fp-012",
"fp-019"
],
"status": "candidate",
"last_confirmed_batch": 3
},
{
"id": "rule-remove-generic-era-openings",
"name": "删除空泛的“在……时代”开场",
"definition": "当文章以“在……的今天”“在这个……的时代”等宏大背景开头,但该背景没有提供具体时间、事件或因果信息,只用于烘托气氛时命中。若时代或日期本身是论证所需的关键背景,并有具体事实支撑,则不命中。",
"detector": {
"type": "llm"
},
"bad_example": "在这个快节奏的时代,高效的沟通显得尤为珍贵。在这个充满变化的时代,稳定的协作关系更值得珍惜。",
"good_example": "最近项目节奏快,咱们沟通尽量简短高效;也希望协作关系保持稳定,有问题随时同步。",
"rewrite_hint": "直接写当前发生的具体变化、影响和要求,用“最近”“这两年”等可核实时间范围替代泛化时代判断。",
"scope": [
"产品发布稿",
"公众号文章",
"邮件"
],
"source_ids": [
"fp-005",
"fp-010",
"fp-016"
],
"status": "candidate",
"last_confirmed_batch": 3
},
{
"id": "rule-control-emoji-density",
"name": "控制 emoji 的使用密度与场合",
"definition": "当正式产品稿中使用 emoji,或公众号段落中几乎每个信息点都附带表情,造成视觉干扰、削弱专业感时命中。正式稿件应去除 emoji;轻松社交场景中少量、与语义直接相关且不妨碍阅读的 emoji 不应一概判定为问题。",
"detector": {
"type": "llm"
},
"bad_example": "🎉 重磅来袭!🔥 全新智能手表正式发布!⌚ 超长续航 14 天 🔋,心率血氧全天候监测 ❤️,50 米防水 🏊,现在下单立减 200 元 💰,错过再等一年 ⏰!",
"good_example": "新款智能手表发布:续航 14 天,支持心率血氧监测和 50 米防水,首发价立减 200 元。",
"rewrite_hint": "正式文本删除全部 emoji;其他场景先去掉装饰性表情,只在确有语气或分类作用时保留极少量。",
"scope": [
"产品发布稿",
"公众号文章"
],
"source_ids": [
"fp-006",
"fp-011"
],
"status": "candidate",
"last_confirmed_batch": 2
},
{
"id": "rule-avoid-forced-parallelism",
"name": "避免刻意堆叠同构排比",
"definition": "当三句或更多分句反复使用完全相同的开头和句法框架,如“读书可以……”“它让……”“每一次……都是……”,且内容多为抽象赞美、缺少信息增量时命中。若排比用于必要的结构对照、篇幅适度且每项都有实质信息,则不命中。",
"detector": {
"type": "llm"
},
"bad_example": "读书可以拓宽视野,读书可以沉淀心灵,读书可以启迪智慧,读书可以点亮人生。",
"good_example": "读书的好处很多:视野会变宽,心能静下来,看问题也会多几个角度。",
"rewrite_hint": "保留一个总述,将各项改成不同句式或合并为并列成分;删去重复、空泛或意义相近的项。",
"scope": [
"README 段落",
"公众号文章"
],
"source_ids": [
"fp-007",
"fp-014",
"fp-017"
],
"status": "candidate",
"last_confirmed_batch": 3
},
{
"id": "rule-replace-empty-metaphors-with-facts",
"name": "用具体事实替代空洞抒情比喻",
"definition": "当“智者、屏障、明灯、灯塔”等比喻只负责抬高语气,却没有说明产品效果、处理进度或实际价值,甚至替代了读者需要的关键信息时命中。若比喻准确、简洁,并能帮助理解陌生概念或符合文学性场景,则不命中。",
"detector": {
"type": "llm"
},
"bad_example": "您的建议宛如一盏明灯,照亮了我们产品改进的前路;您的信任仿佛一座灯塔,指引着我们不断前行。",
"good_example": "您提的建议很具体,我们已经记进需求池,下个版本会先改其中两条。谢谢您的信任。",
"rewrite_hint": "删除装饰性喻体,补上可验证的信息,如实际体验、使用场景、已采取的动作和后续安排。",
"scope": [
"公众号文章",
"邮件"
],
"source_ids": [
"fp-013",
"fp-020"
],
"status": "candidate",
"last_confirmed_batch": 3
},
{
"id": "rule-split-overloaded-sentences",
"name": "拆分承载过多层次的长句",
"definition": "当一句话同时塞入背景、原因、条件、多个动作、结果和要求,主要依靠“且、并、同时、为了、让”等连续连接,读者难以一次识别主干时命中。句子较长但主干清楚、修饰关系单一且不存在理解负担时不命中。",
"detector": {
"type": "llm"
},
"bad_example": "这次更新在保留原有工作台布局的基础上通过重新组织导航层级并合并重复入口同时优化首次加载和列表刷新策略让用户从打开应用到完成一次记录的整个过程都比以前更连贯也更省时间。",
"good_example": "这次更新保留了原有工作台布局,同时重新组织导航层级、合并重复入口。首次加载和列表刷新也做了优化,从打开应用到完成记录会更连贯。",
"rewrite_hint": "先提取背景、改动、要求和结果,再按层次拆成两到三句;每句只保留一个主要动作或判断。",
"scope": [
"产品发布稿",
"邮件"
],
"source_ids": [
"fp-021",
"fp-024"
],
"status": "candidate",
"last_confirmed_batch": 2
},
{
"id": "rule-remove-repeated-attention-prefaces",
"name": "删除反复出现的提醒性套话",
"definition": "当连续句子都用“值得注意的是”“需要特别注意的是”“更值得注意的是”等元话语开头,只是重复强调而没有建立新的信息层级时命中。若只在关键风险首次出现时使用一次,且确实需要提示读者改变注意焦点,则不命中。",
"detector": {
"type": "llm"
},
"bad_example": "值得注意的是,本周五是最终截止时间。需要特别注意的是,逾期提交将影响下游联调。还值得注意的是,附件命名必须统一。",
"good_example": "最终截止时间是本周五,逾期会影响下游联调。附件请按统一格式命名。",
"rewrite_hint": "删掉“值得注意的是”等前缀,直接写结论、截止时间、后果或操作要求;需要突出时依靠信息顺序而非重复提醒。",
"scope": [
"公众号文章",
"邮件"
],
"source_ids": [
"fp-022",
"fp-025"
],
"status": "candidate",
"last_confirmed_batch": 3
},
{
"id": "rule-avoid-passive-voice-chains",
"name": "避免连续使用“被”字被动句",
"definition": "当多个相邻分句都采用“对象被执行者处理”的结构,且执行者明确、适合直接充当主语时命中。若执行者未知、不重要,或文本确实需要突出受影响对象,则单个被动句不命中。",
"detector": {
"type": "llm"
},
"bad_example": "配置文件会被启动器读取,依赖会被安装脚本自动下载,数据库会被迁移工具初始化,服务会被进程管理器拉起。",
"good_example": "启动器读取配置文件,安装脚本自动下载依赖,迁移工具初始化数据库,进程管理器随后拉起服务。",
"rewrite_hint": "把工具、模块或责任方移到主语位置,改写为“谁做什么”;只有确需突出承受结果的对象时才保留被动表达。",
"scope": [
"README 段落",
"产品发布稿"
],
"source_ids": [
"fp-023",
"fp-026"
],
"status": "candidate",
"last_confirmed_batch": 3
}
]
@@ -0,0 +1,138 @@
{
"boundary": [
{
"id": "b1",
"scene": "产品发布稿",
"expected_sources": ["fp-001", "fp-008"],
"text": "全新降噪耳机——沉浸式体验——一触即达的智能降噪——40 小时超长续航——让音乐回归纯粹——即刻预约——享首发礼遇!"
},
{
"id": "b2",
"scene": "公众号文章",
"expected_sources": ["fp-002", "fp-009", "fp-015"],
"text": "坚持不是咬牙硬撑,而是找到自己的节奏;努力不是做给别人看,而是对得起自己的选择。"
},
{
"id": "b3",
"scene": "公众号文章",
"expected_sources": ["fp-007", "fp-014"],
"text": "我们优化了搜索,我们重构了推荐,我们升级了播放,我们改进了评论。"
},
{
"id": "b4",
"scene": "邮件",
"expected_sources": ["fp-003", "fp-018"],
"text": "让我们携手开启新财年的征程。让我们以饱满的热情迎接每一个挑战。"
},
{
"id": "b5",
"scene": "README 段落",
"expected_sources": ["fp-004", "fp-012", "fp-019"],
"text": "首先安装依赖,其次配置密钥,再次运行迁移脚本,最后启动服务。"
},
{
"id": "b6",
"scene": "产品发布稿",
"expected_sources": ["fp-006", "fp-011", "fp-007", "fp-014"],
"text": "新品上市 🎉🎉!超美配色 🌈,超强性能 🚀,超值价格 💰,快来抢购吧 🛒✨!"
},
{
"id": "b7",
"scene": "公众号文章",
"expected_sources": ["fp-005", "fp-016"],
"text": "在自媒体蓬勃发展的今天,内容创作者站在了流量风口。在这个注意力稀缺的时代,好内容愈发珍贵。"
},
{
"id": "b8",
"scene": "邮件",
"expected_sources": ["fp-001", "fp-008", "fp-002", "fp-009", "fp-015"],
"text": "这次改版——不是小修小补——而是全面升级——不是功能的叠加——而是体验的飞跃。"
},
{
"id": "b9",
"scene": "README 段落",
"expected_sources": ["fp-021", "fp-024"],
"text": "为了确保首次部署时各项依赖能够按照正确顺序完成安装并让初始化脚本拿到全部必要参数同时避免网络波动导致中途失败建议在运行命令前逐项检查配置文件和代理设置然后再启动自动安装流程。"
},
{
"id": "b10",
"scene": "产品发布稿",
"expected_sources": ["fp-022", "fp-025"],
"text": "值得注意的是,新版本支持离线模式。更值得注意的是,缓存空间可以自行设置。还值得注意的是,旧数据不会被删除。"
},
{
"id": "b11",
"scene": "邮件",
"expected_sources": ["fp-023", "fp-026"],
"text": "需求已被产品组确认,接口将被后端团队调整,测试用例会被质量组补齐,上线时间则会被项目经理重新安排。"
}
],
"retention": [
{
"id": "r1",
"scene": "技术文档",
"note": "一处必要的补充说明,成对使用两个破折号是合法用法",
"text": "内存分配器采用伙伴系统——把空闲块按 2 的幂次分级管理——这样既能快速合并,又能减少外部碎片。"
},
{
"id": "r2",
"scene": "邮件",
"note": "真正构成对比的单次「不是……而是……」",
"text": "这个方案不是技术上不可行,而是排期成本太高,建议放到下个季度再评估。"
},
{
"id": "r3",
"scene": "公众号文章",
"note": "人类作者克制的两句对仗,不构成三连排比",
"text": "写得慢的稿子,读者未必看得出来;写得急的稿子,读者一眼就能看出来。"
},
{
"id": "r4",
"scene": "技术文档",
"note": "两步教程中「首先/其次」各出现一次",
"text": "配置分两步:首先把 API 密钥写进配置文件,其次确认网络能访问服务端点。其他选项保持默认即可。"
},
{
"id": "r5",
"scene": "公众号文章",
"note": "生活化随笔里零星两个 emoji",
"text": "今天试了家附近的社区食堂,十五块钱两荤两素,味道超出预期 😋。老板娘说开了八年,附近的老人几乎天天来。这样的小店能多开几家就好了 👍。"
},
{
"id": "r6",
"scene": "公众号文章",
"note": "结尾单次「让我们」是真诚的读者互动",
"text": "如果你也有类似的踩坑经历,让我们在评论区交流一下,互相省点时间。"
},
{
"id": "r7",
"scene": "公众号文章",
"note": "历史叙述中的举例补充,成对破折号合法;开头不是「在……的时代」套话",
"text": "上世纪八十年代,个体户刚刚出现在街头。他们中的不少人——比如那位卖服装起家的陈老板——后来成了第一批民营企业家。"
},
{
"id": "r8",
"scene": "邮件",
"note": "单次对比加单处破折号补充原因",
"text": "他不是不想来,而是台风把航班全取消了——人在机场等了六个小时。"
},
{
"id": "r9",
"scene": "技术文档",
"note": "句子较长,但条件、动作和结果关系清楚,没有堆叠互不相干的信息",
"text": "当客户端连续三次请求失败时,SDK 会进入退避状态,并在下一次重试前等待最多三十秒。"
},
{
"id": "r10",
"scene": "安全公告",
"note": "只用一次强调语提示真正重要的兼容性风险",
"text": "值得注意的是,本次升级会改变令牌格式,依赖旧格式的客户端必须同步更新。"
},
{
"id": "r11",
"scene": "事故报告",
"note": "被动语态用于未知执行者的单一客观事实,没有连续堆砌",
"text": "凌晨两点,主数据库被意外重启,具体操作来源仍在调查。"
}
]
}
@@ -0,0 +1,210 @@
[
{
"id": "fp-001",
"batch": 1,
"scene": "产品发布稿",
"before": "我们隆重推出全新智能笔记应用——一款真正懂你的效率神器——它不仅能记录灵感——还能自动整理、智能归类、跨端同步——让你的每一条想法都不再流失——现在下载,即刻开启高效人生——限时免费,错过再等一年!",
"after": "新版智能笔记应用上线了。它能记录灵感,自动整理和归类,支持多设备同步。现在下载可以免费试用一个月。",
"correction": "破折号太多了,一句话里五六个,读起来喘不过气,全改掉"
},
{
"id": "fp-002",
"batch": 1,
"scene": "公众号文章",
"before": "真正的成长,不是年龄的增加,而是认知的升级;不是知识的堆砌,而是思维的重构;不是经验的重复,而是边界的突破。",
"after": "真正的成长,往往体现在认知的变化上:看问题的角度变了,做事的方法跟着变,最后连自己能做什么的边界都被重新画了一遍。",
"correction": "不要「不是……而是……」的句式,连用三次太 AI 了"
},
{
"id": "fp-003",
"batch": 1,
"scene": "邮件",
"before": "让我们共同见证这个激动人心的时刻!让我们一起开启数字化转型的全新篇章。让我们携手并进,共创辉煌明天。",
"after": "新版本今天上线了,期待大家试用之后多提意见。",
"correction": "邮件里别连用「让我们」开头,像机器人演讲"
},
{
"id": "fp-004",
"batch": 1,
"scene": "README 段落",
"before": "首先,克隆本仓库到本地。其次,安装所需依赖。再次,配置环境变量。然后,运行初始化脚本。最后,启动开发服务器即可。",
"after": "克隆仓库后安装依赖,把 .env.example 复制为 .env 并填好密钥,再运行 init.sh 初始化,最后 make dev 启动服务。",
"correction": "首先其次再次然后最后,模板腔太重,连成句子写"
},
{
"id": "fp-005",
"batch": 1,
"scene": "公众号文章",
"before": "在人工智能飞速发展的今天,每个人都面临前所未有的机遇与挑战。在这个信息爆炸的时代,如何保持独立思考显得尤为重要。",
"after": "这两年 AI 工具更新得飞快,很多人一边兴奋一边焦虑。信息越多,越需要自己能做判断。",
"correction": "「在……的今天」「在……的时代」这种开头直接删掉"
},
{
"id": "fp-006",
"batch": 1,
"scene": "产品发布稿",
"before": "🎉 重磅来袭!🔥 全新智能手表正式发布!⌚ 超长续航 14 天 🔋,心率血氧全天候监测 ❤️,50 米防水 🏊,现在下单立减 200 元 💰,错过再等一年 ⏰!",
"after": "新款智能手表发布:续航 14 天,支持心率血氧监测和 50 米防水,首发价立减 200 元。",
"correction": "emoji 泛滥,正式稿件里一个都别放"
},
{
"id": "fp-007",
"batch": 2,
"scene": "公众号文章",
"before": "读书可以拓宽视野,读书可以沉淀心灵,读书可以启迪智慧,读书可以点亮人生。",
"after": "读书的好处很多:视野会变宽,心能静下来,看问题也会多几个角度。",
"correction": "四连排比太刻意了,写散一点"
},
{
"id": "fp-008",
"batch": 2,
"scene": "邮件",
"before": "关于季度复盘会议——时间定于本周五下午三点——地点在三号会议室——请提前准备数据报表——如有冲突请及时告知——谢谢配合。",
"after": "季度复盘定在这周五下午三点,三号会议室。请提前准备好数据报表,时间冲突的话提前跟我说。",
"correction": "把破折号当逗号用是吧?全部改成正常标点"
},
{
"id": "fp-009",
"batch": 2,
"scene": "README 段落",
"before": "本项目不是简单的爬虫框架,而是一套完整的数据采集解决方案;不是一次性的脚本集合,而是可长期维护的工程化平台。",
"after": "本项目提供一套可扩展的数据采集方案,支持插件化开发,适合作为长期维护的工程使用。",
"correction": "不要「不是……而是……」的对仗,直接说它是什么就行"
},
{
"id": "fp-010",
"batch": 2,
"scene": "产品发布稿",
"before": "在这个万物互联的时代,让我们共同拥抱智能家居的美好未来!让我们一起,让科技温暖每一个家庭。",
"after": "智能家居新品系列今天发布,覆盖灯光、安防和环境控制,希望能让家更省心。",
"correction": "开头「在这个……的时代」和连喊「让我们」都去掉"
},
{
"id": "fp-011",
"batch": 2,
"scene": "公众号文章",
"before": "早餐这样吃才健康 🍞🥛!营养师推荐的三餐搭配来了 🍚🥗,坚持一个月,气色肉眼可见变好 ✨✨,姐妹们快收藏 💖!",
"after": "早餐怎么吃更健康?整理了一份营养师的三餐搭配建议,坚持一段时间会有改善,需要的可以收藏。",
"correction": "emoji 收一收,一整段全是表情没法看"
},
{
"id": "fp-012",
"batch": 2,
"scene": "邮件",
"before": "首先感谢各位本季度的付出。其次,需要指出交付节奏仍有优化空间。再次,请各组提交改进方案。最后,期待下季度再创佳绩。",
"after": "感谢大家这个季度的付出。交付节奏上还有改进空间,请各组本周内提交改进方案,我们下个季度接着努力。",
"correction": "「首先/其次/再次/最后」公文八股味,内部邮件别这么写"
},
{
"id": "fp-013",
"batch": 3,
"scene": "公众号文章",
"before": "这款耳机仿佛一位贴心的智者,在你喧嚣的世界里低语;它的降噪功能宛如一道温柔的屏障,将浮躁隔绝在外。",
"after": "这款耳机的降噪效果确实好,地铁里戴上能安静不少,报站的人声也能压得住。",
"correction": "「仿佛一位智者」这种空洞比喻删掉,说人话"
},
{
"id": "fp-014",
"batch": 3,
"scene": "README 段落",
"before": "它让配置更简单,它让部署更快速,它让监控更全面,它让运维更安心。",
"after": "配置、部署、监控和告警都内置了默认方案,运维成本明显更低。",
"correction": "「它让……它让……」排比改掉,平铺直叙"
},
{
"id": "fp-015",
"batch": 3,
"scene": "产品发布稿",
"before": "这不是一次简单的版本更新,而是一次体验的全面革新;不是功能的堆砌,而是对用户需求的深度回应。",
"after": "这次版本更新改动很大:界面重做,核心流程缩短了两步,都来自上一版用户反馈里最高频的问题。",
"correction": "「不是……而是……」连用两句,AI 味很重"
},
{
"id": "fp-016",
"batch": 3,
"scene": "邮件",
"before": "在这个快节奏的时代,高效的沟通显得尤为珍贵。在这个充满变化的时代,稳定的协作关系更值得珍惜。",
"after": "最近项目节奏快,咱们沟通尽量简短高效;也希望协作关系保持稳定,有问题随时同步。",
"correction": "别用「在这个……的时代」起头,直接说事"
},
{
"id": "fp-017",
"batch": 3,
"scene": "公众号文章",
"before": "成长是一场修行——它教会我们坚持——教会我们取舍——教会我们感恩。每一次跌倒都是财富,每一次爬起都是勋章,每一次坚持都是胜利。",
"after": "成长是场慢功夫。能坚持下来的人,多半都慢慢学会了取舍,也开始记得感谢路上帮过自己的人。摔过跤的地方,印象总是最深。",
"correction": "破折号加「每一次……都是……」排比,都太浓了"
},
{
"id": "fp-018",
"batch": 3,
"scene": "README 段落",
"before": "让我们一起构建更好的开源生态!让我们从 star 本项目开始,共同参与这场技术盛宴。",
"after": "如果这个项目对你有帮助,欢迎 star;也欢迎提 issue 和 PR。",
"correction": "README 里别喊「让我们」口号"
},
{
"id": "fp-019",
"batch": 3,
"scene": "产品发布稿",
"before": "首先,全新设计语言带来视觉革新。其次,性能提升高达 40%。再次,续航焦虑彻底解决。总而言之,这是迄今为止最值得升级的一代。",
"after": "这一代换了全新的外观设计,性能比上代提升 40%,电池也够用一整天了。如果是老用户,这次值得升级。",
"correction": "「首先其次再次总而言之」八股味,删掉套话"
},
{
"id": "fp-020",
"batch": 3,
"scene": "邮件",
"before": "您的建议宛如一盏明灯,照亮了我们产品改进的前路;您的信任仿佛一座灯塔,指引着我们不断前行。",
"after": "您提的建议很具体,我们已经记进需求池,下个版本会先改其中两条。谢谢您的信任。",
"correction": "「明灯」「灯塔」这种假抒情删掉,回邮件说正事"
},
{
"id": "fp-021",
"batch": 1,
"scene": "产品发布稿",
"before": "这次更新在保留原有工作台布局的基础上通过重新组织导航层级并合并重复入口同时优化首次加载和列表刷新策略让用户从打开应用到完成一次记录的整个过程都比以前更连贯也更省时间。",
"after": "这次更新保留了原有工作台布局,同时重新组织导航层级、合并重复入口。首次加载和列表刷新也做了优化,从打开应用到完成记录会更连贯。",
"correction": "一句话塞了太多层意思,长得读不到头,拆成两三句"
},
{
"id": "fp-022",
"batch": 1,
"scene": "公众号文章",
"before": "值得注意的是,团队规模并不是越大越好。值得注意的是,沟通成本会随人数增加。更值得注意的是,真正影响交付的是协作方式。",
"after": "团队并非越大越好,人数增加也会抬高沟通成本。真正影响交付的,还是协作方式。",
"correction": "别每句都拿「值得注意的是」开头,直接说结论"
},
{
"id": "fp-023",
"batch": 2,
"scene": "README 段落",
"before": "配置文件会被启动器读取,依赖会被安装脚本自动下载,数据库会被迁移工具初始化,服务会被进程管理器拉起。",
"after": "启动器读取配置文件,安装脚本自动下载依赖,迁移工具初始化数据库,进程管理器随后拉起服务。",
"correction": "四句全是「被」字被动语态,改成谁做什么"
},
{
"id": "fp-024",
"batch": 2,
"scene": "邮件",
"before": "考虑到本季度多个项目同时进入交付阶段且测试环境近期频繁被占用为了避免各组在最后一周集中提交导致验证排队请大家在完成核心功能后尽早预约测试窗口并把预计时间同步到项目群。",
"after": "本季度有多个项目同时交付,测试环境最近也经常被占用。请各组完成核心功能后尽早预约测试窗口,并把预计时间同步到项目群,避免最后一周排队。",
"correction": "整段只有一个句号,条件原因要求全挤一起了,拆开写"
},
{
"id": "fp-025",
"batch": 3,
"scene": "邮件",
"before": "值得注意的是,本周五是最终截止时间。需要特别注意的是,逾期提交将影响下游联调。还值得注意的是,附件命名必须统一。",
"after": "最终截止时间是本周五,逾期会影响下游联调。附件请按统一格式命名。",
"correction": "「值得注意的是」反复强调,像自动生成的提醒,删掉套话"
},
{
"id": "fp-026",
"batch": 3,
"scene": "产品发布稿",
"before": "页面被全新的设计系统重构,搜索结果被智能算法重新排序,常用操作被快捷入口集中展示,用户的等待时间被大幅缩短。",
"after": "全新的设计系统重构了页面,智能算法重新排序搜索结果,快捷入口集中展示常用操作,整体等待时间也明显缩短。",
"correction": "连续被动句很生硬,改成明确主语的主动表达"
}
]
@@ -0,0 +1,222 @@
[
{
"id": "g-dash-1",
"labels": [{"source_ids": ["fp-001", "fp-008"], "expected": true}],
"text": "年度新品——更轻——更快——更智能——现在预约——立即享受专属优惠。"
},
{
"id": "g-dash-2",
"labels": [{"source_ids": ["fp-001", "fp-008"], "expected": true}],
"text": "会议改到周四——还是下午三点——地点不变——材料提前发——请大家准时参加。"
},
{
"id": "g-dash-3",
"labels": [{"source_ids": ["fp-001", "fp-008"], "expected": false}],
"text": "这项机制——也就是写时复制——只在数据真正变化时创建副本。"
},
{
"id": "g-dash-4",
"labels": [{"source_ids": ["fp-001", "fp-008"], "expected": false}],
"text": "她只补充了一点——预算必须在月底前确认。"
},
{
"id": "g-not-but-1",
"labels": [{"source_ids": ["fp-002", "fp-009", "fp-015"], "expected": true}],
"text": "写作不是表达,而是连接;阅读不是消费,而是成长;分享不是炫耀,而是传递。"
},
{
"id": "g-not-but-2",
"labels": [{"source_ids": ["fp-002", "fp-009", "fp-015"], "expected": true}],
"text": "这不是修补,而是重塑。不是妥协,而是选择。不是终点,而是起点。"
},
{
"id": "g-not-but-3",
"labels": [{"source_ids": ["fp-002", "fp-009", "fp-015"], "expected": false}],
"text": "问题不是磁盘空间不足,而是日志轮转没有启动。"
},
{
"id": "g-not-but-4",
"labels": [{"source_ids": ["fp-002", "fp-009", "fp-015"], "expected": false}],
"text": "我们比较了两个方案,最终选择延迟更低的那个。"
},
{
"id": "g-lets-1",
"labels": [{"source_ids": ["fp-003", "fp-018"], "expected": true}],
"text": "让我们勇敢出发!让我们携手向前!让我们共同书写新的篇章!"
},
{
"id": "g-lets-2",
"labels": [{"source_ids": ["fp-003", "fp-018"], "expected": true}],
"text": "让我们见证改变,让我们拥抱未来,让我们一起创造无限可能。"
},
{
"id": "g-lets-3",
"labels": [{"source_ids": ["fp-003", "fp-018"], "expected": false}],
"text": "如果你复现了这个问题,让我们在 issue 里核对一下环境信息。"
},
{
"id": "g-lets-4",
"labels": [{"source_ids": ["fp-003", "fp-018"], "expected": false}],
"text": "欢迎提交问题和补丁,维护者通常会在两个工作日内回复。"
},
{
"id": "g-sequence-1",
"labels": [{"source_ids": ["fp-004", "fp-012", "fp-019"], "expected": true}],
"text": "首先分析需求,其次制定方案,再次协调资源,最后推动落地,总而言之我们会全力以赴。"
},
{
"id": "g-sequence-2",
"labels": [{"source_ids": ["fp-004", "fp-012", "fp-019"], "expected": true}],
"text": "首先,外观焕然一新。其次,性能全面升级。再次,体验大幅优化。总而言之,这是一款划时代产品。"
},
{
"id": "g-sequence-3",
"labels": [{"source_ids": ["fp-004", "fp-012", "fp-019"], "expected": false}],
"text": "安装分两步:首先下载压缩包,其次执行安装命令。"
},
{
"id": "g-sequence-4",
"labels": [{"source_ids": ["fp-004", "fp-012", "fp-019"], "expected": false}],
"text": "先备份数据库,再执行迁移;确认结果后启动新版本。"
},
{
"id": "g-era-1",
"labels": [{"source_ids": ["fp-005", "fp-016"], "expected": true}],
"text": "在这个信息爆炸的时代,专注力显得尤为珍贵。"
},
{
"id": "g-era-2",
"labels": [{"source_ids": ["fp-005", "fp-016"], "expected": true}],
"text": "在科技飞速发展的今天,创新已经成为企业前进的不竭动力。"
},
{
"id": "g-era-3",
"labels": [{"source_ids": ["fp-005", "fp-016"], "expected": false}],
"text": "人工智能工具越来越多,这份调查比较了其中五款的实际响应时间。"
},
{
"id": "g-era-4",
"labels": [{"source_ids": ["fp-005", "fp-016"], "expected": false}],
"text": "在上世纪九十年代的上海,寻呼机曾是常见的通信工具。"
},
{
"id": "g-emoji-1",
"labels": [{"source_ids": ["fp-006", "fp-011"], "expected": true}],
"text": "新品来了 🎉🎉,颜值爆表 ✨,性能起飞 🚀,价格惊喜 💰,快来抢购 🛒!"
},
{
"id": "g-emoji-2",
"labels": [{"source_ids": ["fp-006", "fp-011"], "expected": false}],
"text": "早起打卡 ☀️,早餐打卡 🥐,运动打卡 🏃,阅读打卡 📚,今天也要加油 💪!"
},
{
"id": "g-emoji-3",
"labels": [{"source_ids": ["fp-006", "fp-011"], "expected": false}],
"text": "这家小店的面很好吃 😋,下次还想带朋友来。"
},
{
"id": "g-emoji-4",
"labels": [{"source_ids": ["fp-006", "fp-011"], "expected": false}],
"text": "构建已经通过 ✅,可以合并了。"
},
{
"id": "g-parallel-1",
"labels": [{"source_ids": ["fp-007", "fp-014"], "expected": true}],
"text": "它让工作更轻松,它让协作更顺畅,它让决策更高效,它让未来更清晰。"
},
{
"id": "g-parallel-2",
"labels": [{"source_ids": ["fp-007", "fp-014"], "expected": true}],
"text": "每一次尝试都是成长,每一次失败都是积累,每一次坚持都是突破。"
},
{
"id": "g-parallel-3",
"labels": [{"source_ids": ["fp-007", "fp-014"], "expected": false}],
"text": "我喜欢清晨的安静,也喜欢傍晚的热闹。"
},
{
"id": "g-parallel-4",
"labels": [{"source_ids": ["fp-007", "fp-014"], "expected": false}],
"text": "搜索速度提升了,索引占用则略有增加,两项变化都在预期范围内。"
},
{
"id": "g-metaphor-1",
"labels": [{"source_ids": ["fp-013", "fp-020"], "expected": true}],
"text": "这款音箱仿佛一位懂你的智者,总能在恰当的时刻送上恰当的音乐。"
},
{
"id": "g-metaphor-2",
"labels": [{"source_ids": ["fp-013", "fp-020"], "expected": true}],
"text": "她的建议宛如一座灯塔,照亮了团队前行的道路。"
},
{
"id": "g-metaphor-3",
"labels": [{"source_ids": ["fp-013", "fp-020"], "expected": false}],
"text": "这座灯塔建于 1903 年,至今仍为进港船只导航。"
},
{
"id": "g-metaphor-4",
"labels": [{"source_ids": ["fp-013", "fp-020"], "expected": false}],
"text": "新算法把平均搜索时间从 800 毫秒降到了 120 毫秒。"
},
{
"id": "g-long-sentence-1",
"labels": [{"source_ids": ["fp-021", "fp-024"], "expected": true}],
"text": "由于需求在评审后又发生变化并且接口文档直到昨晚才最终确认导致前端无法按原计划完成联调所以本周的提测时间需要顺延到下周二请相关同事同步调整后续安排。"
},
{
"id": "g-long-sentence-2",
"labels": [{"source_ids": ["fp-021", "fp-024"], "expected": true}],
"text": "新版本通过整合搜索推荐收藏和历史记录入口并重新设计信息层级以及优化缓存更新逻辑让用户能够在更少操作里找到需要的内容同时降低首次打开页面时的等待时间。"
},
{
"id": "g-long-sentence-3",
"labels": [{"source_ids": ["fp-021", "fp-024"], "expected": false}],
"text": "当请求连续失败三次时,客户端会暂停重试,并在等待三十秒后重新建立连接。"
},
{
"id": "g-long-sentence-4",
"labels": [{"source_ids": ["fp-021", "fp-024"], "expected": false}],
"text": "测试环境正在升级。联调改到明天下午,地址不变。"
},
{
"id": "g-worth-noting-1",
"labels": [{"source_ids": ["fp-022", "fp-025"], "expected": true}],
"text": "值得注意的是,价格已经调整。还值得注意的是,套餐内容也有变化。尤其值得注意的是,优惠只到月底。"
},
{
"id": "g-worth-noting-2",
"labels": [{"source_ids": ["fp-022", "fp-025"], "expected": true}],
"text": "需要强调的是,数据不会迁移。需要特别强调的是,旧账号无法登录。再次强调,用户必须重新注册。"
},
{
"id": "g-worth-noting-3",
"labels": [{"source_ids": ["fp-022", "fp-025"], "expected": false}],
"text": "值得注意的是,这个安全补丁会使旧版客户端无法连接,升级前必须通知所有用户。"
},
{
"id": "g-worth-noting-4",
"labels": [{"source_ids": ["fp-022", "fp-025"], "expected": false}],
"text": "价格已经调整,套餐内容不变,优惠持续到月底。"
},
{
"id": "g-passive-1",
"labels": [{"source_ids": ["fp-023", "fp-026"], "expected": true}],
"text": "方案被委员会审议,预算被财务部门核定,合同被法务团队复核,最终日期被项目组确认。"
},
{
"id": "g-passive-2",
"labels": [{"source_ids": ["fp-023", "fp-026"], "expected": true}],
"text": "文件会被程序读取,字段会被转换器处理,结果会被缓存模块保存,通知会被任务系统发送。"
},
{
"id": "g-passive-3",
"labels": [{"source_ids": ["fp-023", "fp-026"], "expected": false}],
"text": "凌晨两点,备用节点被意外重启,操作来源仍在调查。"
},
{
"id": "g-passive-4",
"labels": [{"source_ids": ["fp-023", "fp-026"], "expected": false}],
"text": "调度器读取任务列表,工作进程执行任务,监控服务记录结果。"
}
]
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env python3
"""兼容入口:运行真实的开放式提炼与 LLM-as-a-judge 完整流程。"""
from run_ai_style_skill import main
if __name__ == "__main__":
raise SystemExit(main())
+7
View File
@@ -0,0 +1,7 @@
# Default: OpenAI Responses API with GPT-5.6 Sol
OPENAI_API_KEY=your_api_key_here
# Optional compatible providers
ARK_API_KEY=your_api_key_here
ARK_MODEL=doubao-seed-1-6-250615
OPENROUTER_API_KEY=your_api_key_here
+126
View File
@@ -0,0 +1,126 @@
"""用外部人工标注的 boundary/retention 集评估 active 规则。"""
from __future__ import annotations
import argparse
import json
from pathlib import Path
from typing import Any, Dict, List
from judge import JudgeFn, llm_judge, score_text
from llm_client import default_model
ROOT = Path(__file__).resolve().parent
DATA_DIR = ROOT / "data"
OUTPUT_DIR = ROOT / "output"
def load_eval_texts(path: Path | None = None) -> Dict[str, List[Dict[str, Any]]]:
return json.loads((path or DATA_DIR / "eval_texts.json").read_text(encoding="utf-8"))
def _expected_rule_ids(item: Dict[str, Any], rules: List[Dict[str, Any]]) -> set[str]:
"""把人工标注的反馈来源映射到本次动态提炼出的规则 id。"""
expected_sources = set(item.get("expected_sources", []))
return {
rule["id"]
for rule in rules
if expected_sources & set(rule.get("source_ids", []))
}
def evaluate_rules(
rules: List[Dict[str, Any]],
eval_texts: Dict[str, List[Dict[str, Any]]],
judge_fn: JudgeFn,
) -> Dict[str, Any]:
"""在保留集上逐段调用一次 LLM judge,不使用词表或正则探针。"""
rule_ids = [rule["id"] for rule in rules]
tp = {rule_id: 0 for rule_id in rule_ids}
fp = {rule_id: 0 for rule_id in rule_ids}
fn = {rule_id: 0 for rule_id in rule_ids}
boundary_details = []
detected = 0
for item in eval_texts.get("boundary", []):
expected = _expected_rule_ids(item, rules)
fired = set(score_text(item["text"], rules, judge_fn, text_id=item["id"]))
matched = expected & fired
boundary_details.append({
"id": item["id"],
"expected_sources": item.get("expected_sources", []),
"expected": sorted(expected),
"fired": sorted(fired),
})
if matched:
detected += 1
for rule_id in rule_ids:
if rule_id in fired and rule_id in expected:
tp[rule_id] += 1
elif rule_id in fired:
fp[rule_id] += 1
elif rule_id in expected:
fn[rule_id] += 1
retention_details = []
harmed = 0
for item in eval_texts.get("retention", []):
fired = score_text(item["text"], rules, judge_fn, text_id=item["id"])
retention_details.append({"id": item["id"], "fired": sorted(fired)})
if fired:
harmed += 1
for rule_id in fired:
fp[rule_id] += 1
per_rule = {}
for rule_id in rule_ids:
precision = tp[rule_id] / (tp[rule_id] + fp[rule_id]) if tp[rule_id] + fp[rule_id] else 1.0
recall = tp[rule_id] / (tp[rule_id] + fn[rule_id]) if tp[rule_id] + fn[rule_id] else 1.0
per_rule[rule_id] = {
"tp": tp[rule_id],
"fp": fp[rule_id],
"fn": fn[rule_id],
"precision": round(precision, 3),
"recall": round(recall, 3),
}
boundary_total = len(eval_texts.get("boundary", []))
retention_total = len(eval_texts.get("retention", []))
return {
"per_rule": per_rule,
"boundary_detection_rate": detected / boundary_total if boundary_total else 0.0,
"boundary_detected": detected,
"boundary_total": boundary_total,
"retention_harm_rate": harmed / retention_total if retention_total else 0.0,
"retention_harmed": harmed,
"retention_total": retention_total,
"boundary_details": boundary_details,
"retention_details": retention_details,
}
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--provider", choices=("ark", "openrouter", "openai"), default="openai")
parser.add_argument("--model", default=None)
args = parser.parse_args()
rules_path = ROOT / "skill" / "rules.json"
rules = json.loads(rules_path.read_text(encoding="utf-8"))
judge_fn = llm_judge(provider=args.provider, model=args.model)
metrics = evaluate_rules(rules, load_eval_texts(), judge_fn)
report = {
"provider": args.provider,
"model": args.model or default_model(args.provider),
**metrics,
}
OUTPUT_DIR.mkdir(exist_ok=True)
(OUTPUT_DIR / "eval_report.json").write_text(
json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8"
)
print(json.dumps(report, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+143
View File
@@ -0,0 +1,143 @@
"""从用户纠正的 before/after 对中开放式提炼候选写作规则。
本模块不包含预置模式库。模型直接比较用户给出的 before/after 与纠正原话,
发现已有清单之外的新规律。所有候选都统一使用 LLM judge;模型只能提出候选,
是否合并、校准和激活仍由模型外部代码决定。
"""
from __future__ import annotations
import json
from pathlib import Path
import re
from typing import Any, Dict, List, Tuple
from llm_client import chat
ROOT = Path(__file__).resolve().parent
DATA_DIR = ROOT / "data"
def load_pairs(path: Path | None = None) -> List[Dict[str, Any]]:
return json.loads((path or DATA_DIR / "feedback_pairs.json").read_text(encoding="utf-8"))
_LLM_EXTRACT_PROMPT = """你是写作规范的提炼助手。请比较本批用户纠正的 before/after 对,
从反馈本身归纳具体、可复核的写作规则。不要依赖任何预置的模式清单,也不要把规则限制在
正则表达式能检测的现象;语义、句法、语气和篇章层面的新规律都可以提出。
返回 JSON{{"rules": [{{"id": "rule-<英文短横线命名>", "name": "...",
"definition": "清楚说明什么情况下命中,以及什么相似情况不应命中",
"bad_example": "取自 before 的原文片段", "good_example": "对应的 after 原文片段",
"rewrite_hint": "具体改写建议", "scope": ["适用场景"],
"source_ids": ["支撑该规则的反馈对 id"]}}]}}
要求:
1. 每条规则必须至少有一条 source_ids,且 id、坏例、好例都只能来自输入;不要编造证据。
2. 同一现象只返回一条规则。规则要区分“滥用”和合理使用,不能写成一刀切禁令。
3. 如果本批现象与“当前规则”语义相同,必须复用当前规则的 id;只有发现新规律才创建新 id。
4. 返回的候选将全部交给外部 LLM judge,用独立人工金标集校准后才可能激活。
当前规则(可能为空):
{existing_rules}
本批反馈:
{pairs}
"""
def _strip_json_fence(content: str) -> str:
return re.sub(r"^```(?:json)?\s*|\s*```$", "", content.strip(), flags=re.I)
def _validate_candidate(
rule: Dict[str, Any], pair_by_id: Dict[str, Dict[str, Any]]
) -> Dict[str, Any] | None:
required = (
"id", "name", "definition", "bad_example", "good_example",
"rewrite_hint", "source_ids",
)
if not all(rule.get(key) for key in required):
return None
if not re.fullmatch(r"rule-[a-z0-9]+(?:-[a-z0-9]+)*", str(rule["id"])):
return None
source_ids = list(dict.fromkeys(rule["source_ids"]))
if not source_ids or any(source_id not in pair_by_id for source_id in source_ids):
return None
bad_example = str(rule["bad_example"])
good_example = str(rule["good_example"])
supported = any(
bad_example in pair_by_id[source_id]["before"]
and good_example in pair_by_id[source_id]["after"]
for source_id in source_ids
)
if not supported:
return None
return {
"id": rule["id"],
"name": str(rule["name"]),
"definition": str(rule["definition"]),
"detector": {"type": "llm"},
"bad_example": bad_example,
"good_example": good_example,
"rewrite_hint": str(rule["rewrite_hint"]),
"scope": sorted({str(item) for item in rule.get("scope", [])}),
"source_ids": source_ids,
"status": "candidate",
}
def extract_with_llm(
pairs: List[Dict[str, Any]],
*,
provider: str,
model: str | None = None,
seed: int = 8901,
existing_rules: List[Dict[str, Any]] | None = None,
) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]:
"""开放式提炼候选规则,返回 ``(候选列表, API 证据回执)``。"""
brief = [
{
"id": pair["id"],
"scene": pair["scene"],
"before": pair["before"],
"after": pair["after"],
"correction": pair["correction"],
}
for pair in pairs
]
current = [
{"id": rule["id"], "name": rule["name"], "definition": rule["definition"]}
for rule in (existing_rules or [])
]
content, receipt = chat(
[{
"role": "user",
"content": _LLM_EXTRACT_PROMPT.format(
pairs=json.dumps(brief, ensure_ascii=False, indent=2),
existing_rules=json.dumps(current, ensure_ascii=False, indent=2),
),
}],
provider=provider,
model=model,
seed=seed,
max_tokens=16000,
)
payload = json.loads(_strip_json_fence(content))
pair_by_id = {pair["id"]: pair for pair in pairs}
candidates = []
for rule in payload.get("rules", []):
candidate = _validate_candidate(rule, pair_by_id)
if candidate is not None:
candidates.append(candidate)
return candidates, receipt
def write_candidates(candidates: List[Dict[str, Any]], path: Path | None = None) -> Path:
out = path or DATA_DIR / "candidate_rules.json"
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(json.dumps(candidates, ensure_ascii=False, indent=2), encoding="utf-8")
return out
+194
View File
@@ -0,0 +1,194 @@
"""用一个外部 LLM judge 校准并评估所有候选写作规则。
规则本身来自开放式提炼,不携带正则或预置模式。judge 根据规则定义、正反例与
作用域作语义判定。每条规则必须先在独立人工金标上达到一致率门槛,之后才可激活。
"""
from __future__ import annotations
import json
from pathlib import Path
import re
from typing import Any, Callable, Dict, List, Tuple
from llm_client import chat
ROOT = Path(__file__).resolve().parent
DATA_DIR = ROOT / "data"
Verdicts = Dict[Tuple[str, str], Dict[str, Any]]
JudgeFn = Callable[[List[Dict[str, Any]], List[Dict[str, str]]], Verdicts]
_JUDGE_PROMPT = """你是独立的中文写作质量评判者。请逐一判断每段待评文本是否命中每条规则。
严格依据规则的定义、适用范围和正反例:相似词语本身不等于命中,只有规则所描述的滥用
确实出现才判 true。不要因为文本来自评估集而猜标签。
必须为每个 text_id 与 rule_id 的组合返回一项。只返回 JSON:
{{"verdicts": [{{"text_id": "...", "rule_id": "...", "hit": true,
"evidence": "命中时摘录最短证据;未命中时为空字符串"}}]}}
规则:
{rules}
待评文本:
{texts}
"""
def _strip_json_fence(content: str) -> str:
return re.sub(r"^```(?:json)?\s*|\s*```$", "", content.strip(), flags=re.I)
def llm_judge(
*,
provider: str,
model: str | None = None,
seed: int = 8901,
receipts: List[Dict[str, Any]] | None = None,
) -> JudgeFn:
"""创建批量 LLM judge;一次调用可判定多条规则或多段文本。"""
def judge(rules: List[Dict[str, Any]], texts: List[Dict[str, str]]) -> Verdicts:
brief_rules = [
{
"id": rule["id"],
"name": rule["name"],
"definition": rule["definition"],
"bad_example": rule.get("bad_example", ""),
"good_example": rule.get("good_example", ""),
"scope": rule.get("scope", []),
}
for rule in rules
]
content, receipt = chat(
[{
"role": "user",
"content": _JUDGE_PROMPT.format(
rules=json.dumps(brief_rules, ensure_ascii=False, indent=2),
texts=json.dumps(texts, ensure_ascii=False, indent=2),
),
}],
provider=provider,
model=model,
seed=seed,
# Responses API 的 max_output_tokens 也覆盖 reasoning tokens;小上限会让
# JSON 在中途被截断。给 reasoning 与每个 verdict 都留出明确余量。
max_tokens=max(2000, 1000 + len(rules) * len(texts) * 120),
)
if receipts is not None:
receipts.append(receipt)
expected = {(rule["id"], text["id"]) for rule in rules for text in texts}
try:
payload = json.loads(_strip_json_fence(content))
except (json.JSONDecodeError, AttributeError):
return {
key: {"hit": False, "evidence": "", "parse_error": True}
for key in expected
}
verdicts: Verdicts = {}
for item in payload.get("verdicts", []):
key = (str(item.get("rule_id", "")), str(item.get("text_id", "")))
if key not in expected or not isinstance(item.get("hit"), bool):
continue
verdicts[key] = {
"hit": item["hit"],
"evidence": str(item.get("evidence", "")),
}
for key in expected - set(verdicts):
verdicts[key] = {"hit": False, "evidence": "", "missing": True}
return verdicts
return judge
def _calibration_cases(
rule: Dict[str, Any], golden_set: List[Dict[str, Any]]
) -> List[Dict[str, Any]]:
sources = set(rule.get("source_ids", []))
cases = []
for item in golden_set:
matching = [
label["expected"]
for label in item.get("labels", [])
if sources & set(label.get("source_ids", []))
]
if not matching:
continue
if len(set(matching)) != 1:
raise ValueError(f"金标 {item['id']} 对规则 {rule['id']} 给出了冲突标签")
cases.append({"id": item["id"], "text": item["text"], "expected": matching[0]})
return cases
def calibrate(
rule: Dict[str, Any],
golden_set: List[Dict[str, Any]],
judge_fn: JudgeFn,
*,
threshold: float = 0.8,
) -> Dict[str, Any]:
"""用与候选来源关联的独立人工金标校准一条规则。"""
labeled = _calibration_cases(rule, golden_set)
texts = [{"id": item["id"], "text": item["text"]} for item in labeled]
verdicts = judge_fn([rule], texts) if texts else {}
cases = []
agree = 0
for item in labeled:
verdict = verdicts.get((rule["id"], item["id"]), {"hit": False, "missing": True})
got = bool(verdict["hit"])
ok = got == item["expected"] and not verdict.get("missing") and not verdict.get("parse_error")
agree += int(ok)
cases.append({
"id": item["id"],
"expected": item["expected"],
"judged": got,
"evidence": verdict.get("evidence", ""),
"missing": bool(verdict.get("missing")),
"parse_error": bool(verdict.get("parse_error")),
"agree": ok,
})
total = len(cases)
agreement = agree / total if total else 0.0
decision = "activate" if total and agreement >= threshold else "reject"
return {
"rule_id": rule["id"],
"cases": cases,
"total": total,
"agree": agree,
"agreement": agreement,
"threshold": threshold,
"decision": decision,
"note": (
"judge 与独立人工金标的一致率达到阈值,允许上线"
if decision == "activate"
else "金标覆盖不足或一致率低于阈值,拒绝上线该规则"
),
}
def score_text(
text: str,
rules: List[Dict[str, Any]],
judge_fn: JudgeFn,
*,
text_id: str = "target",
) -> Dict[str, Any]:
"""用 LLM judge 对全部 active 规则一次性打分。"""
if not rules:
return {}
verdicts = judge_fn(rules, [{"id": text_id, "text": text}])
result: Dict[str, Any] = {}
for rule in rules:
verdict = verdicts.get((rule["id"], text_id), {})
if verdict.get("hit"):
result[rule["id"]] = {
"verdict": True,
"evidence": verdict.get("evidence", ""),
}
return result
def load_golden_set(path: Path | None = None) -> List[Dict[str, Any]]:
return json.loads((path or DATA_DIR / "golden_set.json").read_text(encoding="utf-8"))
+118
View File
@@ -0,0 +1,118 @@
"""OpenAI Responses / 兼容 Chat Completions 客户端:统一证据回执。
约定与 chapter8/self-modifying-agent/llm_generator.py 一致:每次真实调用返回
(content, receipt)receipt 含原始请求、原始响应、Token 用量、延迟与
请求/响应哈希,不记录凭据值。凭证从环境变量读取,支持 ark / openrouter / openai。
"""
from __future__ import annotations
import hashlib
import json
import os
import time
from typing import Any, Dict, List, Tuple
_PROVIDERS = {
"openrouter": ("OPENROUTER_API_KEY", "https://openrouter.ai/api/v1"),
"ark": ("ARK_API_KEY", "https://ark.cn-beijing.volces.com/api/v3"),
"openai": ("OPENAI_API_KEY", None),
}
_DEFAULT_MODELS = {
"openrouter": "openai/gpt-5.6-sol",
"ark": "doubao-seed-1-6-250615",
"openai": "gpt-5.6-sol",
}
def make_client(provider: str) -> Tuple[Any, Dict[str, Any]]:
# openai 包只在真实路径才需要,惰性导入保证离线路径零依赖。
from openai import OpenAI
if provider not in _PROVIDERS:
raise ValueError(f"不支持的 provider{provider}(可选:{sorted(_PROVIDERS)}")
env_name, base_url = _PROVIDERS[provider]
key = os.getenv(env_name)
if not key:
raise RuntimeError(f"真实 LLM 路径需要设置环境变量 {env_name}")
client = OpenAI(api_key=key, base_url=base_url) if base_url else OpenAI(api_key=key)
api = "responses" if provider in {"openai", "openrouter"} else "chat/completions"
backend = {
"provider": provider,
"endpoint": (base_url or "https://api.openai.com/v1") + f"/{api}",
"credential_env": env_name,
}
return client, backend
def default_model(provider: str) -> str:
if provider == "ark":
return os.getenv("ARK_MODEL", _DEFAULT_MODELS["ark"])
return _DEFAULT_MODELS[provider]
def chat(
messages: List[Dict[str, str]],
*,
provider: str,
model: str | None = None,
seed: int = 8901,
max_tokens: int = 5000,
) -> Tuple[str, Dict[str, Any]]:
"""发起一次结构化 JSON 调用,返回 ``(文本内容, 证据回执)``。"""
client, backend = make_client(provider)
selected = model or default_model(provider)
started = time.perf_counter()
if provider in {"openai", "openrouter"}:
request = {
"model": selected,
"input": messages,
"reasoning": {"effort": "medium"},
"max_output_tokens": max_tokens,
"text": {"format": {"type": "json_object"}},
"store": False,
}
response = client.responses.create(**request)
content = response.output_text
else:
request = {
"model": selected,
"messages": messages,
"temperature": 0,
"seed": seed,
"max_tokens": max_tokens,
"response_format": {"type": "json_object"},
}
response = client.chat.completions.create(**request)
content = response.choices[0].message.content or ""
elapsed = time.perf_counter() - started
raw = response.model_dump(mode="json", exclude_none=True)
usage = raw.get("usage") or {}
cost = usage.get("cost")
prompt_tokens = usage.get("input_tokens", usage.get("prompt_tokens", 0))
completion_tokens = usage.get("output_tokens", usage.get("completion_tokens", 0))
receipt = {
"backend": {**backend, "model": selected, "credential_value_recorded": False},
"request": request,
"response": raw,
"request_sha256": hashlib.sha256(
json.dumps(request, sort_keys=True).encode()
).hexdigest(),
"response_sha256": hashlib.sha256(
json.dumps(raw, sort_keys=True).encode()
).hexdigest(),
"elapsed_seconds": round(elapsed, 6),
"usage": {
"prompt_tokens": int(prompt_tokens or 0),
"completion_tokens": int(completion_tokens or 0),
"total_tokens": int(usage.get("total_tokens") or 0),
"provider_reported_cost_usd": float(cost) if cost is not None else None,
"cost_qualification": (
"provider-native usage.cost"
if cost is not None
else "provider did not expose monetary cost; no price was guessed"
),
},
}
return content, receipt
+3
View File
@@ -0,0 +1,3 @@
# OpenAI 直连使用 Responses API;单元测试通过 mock 保持离线。
openai>=1.97.1
pytest>=8.3.0
+85
View File
@@ -0,0 +1,85 @@
"""把 active 规则连同原文交给 LLM 改写,返回 before/after 与证据回执。"""
from __future__ import annotations
import json
import re
from typing import Any, Dict, List, Tuple
from llm_client import chat
# 演示用原文:刻意集齐八类 AI 味。
SAMPLE_TEXT = (
"在这个效率至上的时代,让我们一起重新认识这款笔记工具——它不是简单的记录软件——"
"而是你的第二大脑。首先,它能自动整理灵感;其次,它让检索快如闪电;"
"最后,它让分享毫无门槛——仿佛一位永不疲倦的管家。总而言之,让我们从现在开始,"
"把每一条灵感都安顿好 🚀✨💡。"
)
_REWRITE_PROMPT = """你是中文文案改写助手。请按下面的写作规则改写给出的文案,消除所有违规之处,
保持原意,不要增加新事实。只返回 JSON:{{"rewritten": "改写后的全文",
"applied_rules": ["实际应用的规则 id"]}}
写作规则:
{rules}
待改写文案:
{text}
"""
def rewrite_with_llm(
text: str,
rules: List[Dict[str, Any]],
*,
provider: str,
model: str | None = None,
seed: int = 8901,
) -> Tuple[Dict[str, Any], Dict[str, Any]]:
"""真实 LLM 改写路径,返回 (改写结果, 证据回执)。"""
brief_rules = [
{"id": r["id"], "name": r["name"], "definition": r["definition"],
"bad_example": r.get("bad_example", ""), "good_example": r.get("good_example", "")}
for r in rules
]
content, receipt = chat(
[{"role": "user", "content": _REWRITE_PROMPT.format(
rules=json.dumps(brief_rules, ensure_ascii=False, indent=2), text=text)}],
provider=provider, model=model, seed=seed,
)
payload = json.loads(re.sub(r"^```(?:json)?\s*|\s*```$", "", content.strip(), flags=re.I))
return {
"mode": "real_llm",
"original": text,
"rewritten": payload.get("rewritten", ""),
"applied_rules": payload.get("applied_rules", []),
}, receipt
def _load_active_rules() -> List[Dict[str, Any]]:
import json as _json
from pathlib import Path
rules_path = Path(__file__).resolve().parent / "skill" / "rules.json"
if not rules_path.exists():
raise SystemExit("请先生成 Skillpython demo.py 或 python run_ai_style_skill.py")
return _json.loads(rules_path.read_text(encoding="utf-8"))
def main() -> int:
import argparse
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--provider", choices=("ark", "openrouter", "openai"), default="openai")
parser.add_argument("--model", default=None)
args = parser.parse_args()
rules = _load_active_rules()
result, _ = rewrite_with_llm(
SAMPLE_TEXT, rules, provider=args.provider, model=args.model
)
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,195 @@
#!/usr/bin/env python3
"""把「AI 味」反馈开放式提炼为规则,并由 LLM judge 校准和评估。
默认使用 OpenAI GPT-5.6 Sol
python run_ai_style_skill.py
流程:全量反馈 → LLM 开放式提炼并做语义归并 → 每条规则用独立人工
金标校准 LLM judge → 生成 Skill → boundary/retention 评估 → 改写演示。
代码中不包含预置模式库,也不会用 detector 指纹过滤模型发现的新规律。
"""
from __future__ import annotations
import argparse
from datetime import datetime, timezone
import json
from pathlib import Path
import shutil
from typing import Any, Dict, List
from evaluate import evaluate_rules, load_eval_texts
from extract_rules import extract_with_llm, load_pairs, write_candidates
from judge import calibrate, llm_judge, load_golden_set
from llm_client import default_model
from rewrite_demo import SAMPLE_TEXT, rewrite_with_llm
from skill_manager import merge_rules, prune_rules, write_archive, write_skill
ROOT = Path(__file__).resolve().parent
# 验收门槛(模型外部代码,LLM 不可修改)。
GATE_BOUNDARY_RATE = 0.85
GATE_RETENTION_HARM = 0.15
def run_pipeline(
*,
provider: str = "openai",
model: str | None = None,
seed: int = 8901,
batches: int = 3,
) -> Dict[str, Any]:
pairs = load_pairs()
eval_texts = load_eval_texts()
golden_set = load_golden_set()
receipts: List[Dict[str, Any]] = []
# 1) 一次查看全部反馈,避免批次顺序让同一概念被拆分、不同概念被误并。
candidates, receipt = extract_with_llm(
pairs,
provider=provider,
model=model,
seed=seed,
)
receipts.append(receipt)
rules, merge_report = merge_rules([], candidates)
pair_batches = {pair["id"]: pair.get("batch", 1) for pair in pairs}
for rule in rules:
rule["last_confirmed_batch"] = max(
(pair_batches[source] for source in rule.get("source_ids", []) if source in pair_batches),
default=batches,
)
curve = [
{
"batch": batch_no,
"rules_with_evidence": sum(
any(pair_batches.get(source) == batch_no for source in rule.get("source_ids", []))
for rule in rules
),
"cumulative_rule_count": sum(
any(pair_batches.get(source, batches + 1) <= batch_no for source in rule.get("source_ids", []))
for rule in rules
),
}
for batch_no in range(1, batches + 1)
]
total_candidates = len(candidates)
write_candidates(candidates)
# 2) 所有规则都由同一个批量 LLM judge 校准;无金标或不达标都拒绝上线。
calibration = []
active: List[Dict[str, Any]] = []
judge_fn = llm_judge(
provider=provider, model=model, seed=seed, receipts=receipts
)
for rule in rules:
result = calibrate(rule, golden_set, judge_fn)
calibration.append(result)
if result["decision"] == "activate":
rule["status"] = "active"
active.append(rule)
else:
rule["status"] = "rejected"
# 3) prune 演示:被证据推翻或长期未触发的规则归档(离线数据下无归档)。
active, archived = prune_rules(active, current_batch=batches, contradicted_ids=set())
# 4) 生成 Skill 并评估。
skill_path = write_skill(active)
archive_path = write_archive(archived)
metrics = evaluate_rules(active, eval_texts, judge_fn)
# 5) 改写演示。
rewrite, receipt = rewrite_with_llm(
SAMPLE_TEXT, active, provider=provider, model=model, seed=seed
)
receipts.append(receipt)
gates = {
"boundary_detection_rate >= 0.85": metrics["boundary_detection_rate"] >= GATE_BOUNDARY_RATE,
"retention_harm_rate <= 0.15": metrics["retention_harm_rate"] <= GATE_RETENTION_HARM,
"corpus_rules_have_unique_ids": (
len(rules) == total_candidates
and len({rule["id"] for rule in rules}) == len(rules)
and not merge_report["merged"]
),
"all_active_rules_use_llm_judge": all(
rule.get("detector") == {"type": "llm"} for rule in active
),
"all_active_rules_calibrated": all(
any(c["rule_id"] == rule["id"] and c["decision"] == "activate" for c in calibration)
for rule in active
),
"out_of_library_cases_detected": all(
any(detail["expected"] and set(detail["expected"]) & set(detail["fired"])
for detail in metrics["boundary_details"] if detail["id"] == case_id)
for case_id in ("b9", "b10", "b11")
),
"real_llm_called_with_receipts": bool(receipts) and all(
receipt["response"].get("id") for receipt in receipts
),
}
report = {
"experiment": "ai-style-skill",
"executed_at": datetime.now(timezone.utc).isoformat(),
"execution_mode": "real_llm_judge",
"provider": provider,
"model": model or default_model(provider),
"growth_curve": curve,
"total_candidates": total_candidates,
"final_rule_count": len(rules),
"active_rules": [r["id"] for r in active],
"archived_rules": [r["id"] for r in archived],
"calibration": calibration,
"skill_path": str(skill_path.relative_to(ROOT)),
"archive_path": str(archive_path.relative_to(ROOT)) if archive_path else None,
"metrics": metrics,
"rewrite_demo": rewrite,
"raw_api_receipts": receipts,
"gates": gates,
"accepted": all(gates.values()),
}
return report
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--provider", choices=("ark", "openrouter", "openai"), default="openai")
parser.add_argument("--model", default=None)
parser.add_argument("--seed", type=int, default=8901)
args = parser.parse_args()
report = run_pipeline(provider=args.provider, model=args.model, seed=args.seed)
stamp = datetime.now(timezone.utc).strftime("real_%Y%m%dT%H%M%SZ")
out_dir = ROOT / "validation" / stamp
out_dir.mkdir(parents=True, exist_ok=False)
evidence_path = out_dir / "evidence.json"
evidence_path.write_text(
json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8"
)
canonical = ROOT / "validation" / "latest.json"
shutil.copyfile(evidence_path, canonical)
print(f"证据回执:{evidence_path.relative_to(ROOT)}validation/latest.json 已指向)")
metrics = report["metrics"]
print(json.dumps({
"mode": report["execution_mode"],
"accepted": report["accepted"],
"boundary_detection_rate": f"{metrics['boundary_detected']}/{metrics['boundary_total']}",
"retention_harm_rate": f"{metrics['retention_harmed']}/{metrics['retention_total']}",
"candidates_to_rules": f"{report['total_candidates']} -> {report['final_rule_count']}",
"active_rules": report["active_rules"],
"calibration": [
{"rule_id": c["rule_id"], "agreement": c["agreement"], "decision": c["decision"]}
for c in report["calibration"]
],
"gates": report["gates"],
}, ensure_ascii=False, indent=2))
return 0 if report["accepted"] else 1
if __name__ == "__main__":
raise SystemExit(main())
+128
View File
@@ -0,0 +1,128 @@
---
name: ai-style
description: 中文文案去「AI 味」检查清单,由用户纠正反馈持续提炼而来
---
# 去 AI 味写作 Skill
## 何时加载
当任务是用中文撰写或改写面向读者的文案(产品发布稿、公众号文章、邮件、README 等),
或用户反馈文字「AI 味太重」「不像人写的」时,加载本 Skill。
## 使用方式
起草或改写时逐条对照下面的规则自查。每条规则都给出定义、可检查的检测方法、
坏例与好例;规则只在声明的作用域内生效,作用域之外的文体不要套用。
## 规则清单(共 11 条)
### 规则 1:避免用连续破折号代替常规断句(`rule-excessive-em-dash-separators`
- **定义**:当一句或一段中连续使用多个破折号来串联普通并列信息、步骤或分句,导致层次不清、阅读节奏拖长时命中。应改用句号、逗号、冒号或分句。若破折号只偶尔用于插入说明、语意转折或强调,且前后关系明确,则不命中。
- **检测方法**:LLM judge 语义判定(上线前须通过独立人工金标集校准)
- **坏例**:关于季度复盘会议——时间定于本周五下午三点——地点在三号会议室——请提前准备数据报表——如有冲突请及时告知——谢谢配合。
- **好例**:季度复盘定在这周五下午三点,三号会议室。请提前准备好数据报表,时间冲突的话提前跟我说。
- **改写建议**:先判断各部分是并列信息还是独立句意;并列项用逗号,完整意思用句号,只在确有插入或强调作用时保留破折号。
- **作用域**:产品发布稿、公众号文章、邮件
- **来源反馈**fp-001, fp-008, fp-017
### 规则 2:避免反复使用“不是……而是……”对仗(`rule-avoid-repeated-not-but-contrast`
- **定义**:当“不是……而是……”被连续用于定义产品、解释概念或制造升华,尤其连续出现两次以上并形成整齐对仗时命中。这类写法常以否定铺垫代替直接说明。若上下文确实需要纠正一种明确误解,并且只使用一次来表达真实对立,则不命中。
- **检测方法**:LLM judge 语义判定(上线前须通过独立人工金标集校准)
- **坏例**:这不是一次简单的版本更新,而是一次体验的全面革新;不是功能的堆砌,而是对用户需求的深度回应。
- **好例**:这次版本更新改动很大:界面重做,核心流程缩短了两步,都来自上一版用户反馈里最高频的问题。
- **改写建议**:删去否定式铺垫,直接说明对象是什么、具体改了什么,并用事实或例子支撑判断。
- **作用域**:README 段落、产品发布稿、公众号文章
- **来源反馈**fp-002, fp-009, fp-015
### 规则 3:避免连用“让我们”式号召口号(`rule-avoid-repeated-let-us-slogans`
- **定义**:当邮件、README 或产品稿连续以“让我们”开头,使用“共同见证”“携手并进”等口号来代替具体通知、邀请或行动说明时命中。若只出现一次,且确实是在自然地发出明确、可执行的共同邀请,则不命中。
- **检测方法**:LLM judge 语义判定(上线前须通过独立人工金标集校准)
- **坏例**:让我们共同见证这个激动人心的时刻!让我们一起开启数字化转型的全新篇章。让我们携手并进,共创辉煌明天。
- **好例**:新版本今天上线了,期待大家试用之后多提意见。
- **改写建议**:直接交代发生了什么,以及希望读者采取什么具体行动,如试用、反馈、提 issue 或提交 PR。
- **作用域**:README 段落、产品发布稿、邮件
- **来源反馈**fp-003, fp-010, fp-018
### 规则 4:避免机械堆叠顺序连接词(`rule-avoid-mechanical-sequence-markers`
- **定义**:当短段落逐句套用“首先、其次、再次、然后、最后、总而言之”等连接词,实际内容本可自然串联,因而呈现模板化或公文腔时命中。若步骤顺序严格、跳步会造成错误,或连接词确实用于澄清复杂层级,则不命中。
- **检测方法**:LLM judge 语义判定(上线前须通过独立人工金标集校准)
- **坏例**:首先,克隆本仓库到本地。其次,安装所需依赖。再次,配置环境变量。然后,运行初始化脚本。最后,启动开发服务器即可。
- **好例**:克隆仓库后安装依赖,把 .env.example 复制为 .env 并填好密钥,再运行 init.sh 初始化,最后 make dev 启动服务。
- **改写建议**:删除不提供额外信息的序号套话,改用动作之间的真实关系连接;必要时只保留少量“再”“最后”,或改成清晰的步骤列表。
- **作用域**:README 段落、产品发布稿、邮件
- **来源反馈**fp-004, fp-012, fp-019
### 规则 5:删除空泛的“在……时代”开场(`rule-remove-generic-era-openings`
- **定义**:当文章以“在……的今天”“在这个……的时代”等宏大背景开头,但该背景没有提供具体时间、事件或因果信息,只用于烘托气氛时命中。若时代或日期本身是论证所需的关键背景,并有具体事实支撑,则不命中。
- **检测方法**:LLM judge 语义判定(上线前须通过独立人工金标集校准)
- **坏例**:在这个快节奏的时代,高效的沟通显得尤为珍贵。在这个充满变化的时代,稳定的协作关系更值得珍惜。
- **好例**:最近项目节奏快,咱们沟通尽量简短高效;也希望协作关系保持稳定,有问题随时同步。
- **改写建议**:直接写当前发生的具体变化、影响和要求,用“最近”“这两年”等可核实时间范围替代泛化时代判断。
- **作用域**:产品发布稿、公众号文章、邮件
- **来源反馈**fp-005, fp-010, fp-016
### 规则 6:控制 emoji 的使用密度与场合(`rule-control-emoji-density`
- **定义**:当正式产品稿中使用 emoji,或公众号段落中几乎每个信息点都附带表情,造成视觉干扰、削弱专业感时命中。正式稿件应去除 emoji;轻松社交场景中少量、与语义直接相关且不妨碍阅读的 emoji 不应一概判定为问题。
- **检测方法**:LLM judge 语义判定(上线前须通过独立人工金标集校准)
- **坏例**:🎉 重磅来袭!🔥 全新智能手表正式发布!⌚ 超长续航 14 天 🔋,心率血氧全天候监测 ❤️,50 米防水 🏊,现在下单立减 200 元 💰,错过再等一年 ⏰!
- **好例**:新款智能手表发布:续航 14 天,支持心率血氧监测和 50 米防水,首发价立减 200 元。
- **改写建议**:正式文本删除全部 emoji;其他场景先去掉装饰性表情,只在确有语气或分类作用时保留极少量。
- **作用域**:产品发布稿、公众号文章
- **来源反馈**fp-006, fp-011
### 规则 7:避免刻意堆叠同构排比(`rule-avoid-forced-parallelism`
- **定义**:当三句或更多分句反复使用完全相同的开头和句法框架,如“读书可以……”“它让……”“每一次……都是……”,且内容多为抽象赞美、缺少信息增量时命中。若排比用于必要的结构对照、篇幅适度且每项都有实质信息,则不命中。
- **检测方法**:LLM judge 语义判定(上线前须通过独立人工金标集校准)
- **坏例**:读书可以拓宽视野,读书可以沉淀心灵,读书可以启迪智慧,读书可以点亮人生。
- **好例**:读书的好处很多:视野会变宽,心能静下来,看问题也会多几个角度。
- **改写建议**:保留一个总述,将各项改成不同句式或合并为并列成分;删去重复、空泛或意义相近的项。
- **作用域**:README 段落、公众号文章
- **来源反馈**fp-007, fp-014, fp-017
### 规则 8:用具体事实替代空洞抒情比喻(`rule-replace-empty-metaphors-with-facts`
- **定义**:当“智者、屏障、明灯、灯塔”等比喻只负责抬高语气,却没有说明产品效果、处理进度或实际价值,甚至替代了读者需要的关键信息时命中。若比喻准确、简洁,并能帮助理解陌生概念或符合文学性场景,则不命中。
- **检测方法**:LLM judge 语义判定(上线前须通过独立人工金标集校准)
- **坏例**:您的建议宛如一盏明灯,照亮了我们产品改进的前路;您的信任仿佛一座灯塔,指引着我们不断前行。
- **好例**:您提的建议很具体,我们已经记进需求池,下个版本会先改其中两条。谢谢您的信任。
- **改写建议**:删除装饰性喻体,补上可验证的信息,如实际体验、使用场景、已采取的动作和后续安排。
- **作用域**:公众号文章、邮件
- **来源反馈**fp-013, fp-020
### 规则 9:拆分承载过多层次的长句(`rule-split-overloaded-sentences`
- **定义**:当一句话同时塞入背景、原因、条件、多个动作、结果和要求,主要依靠“且、并、同时、为了、让”等连续连接,读者难以一次识别主干时命中。句子较长但主干清楚、修饰关系单一且不存在理解负担时不命中。
- **检测方法**:LLM judge 语义判定(上线前须通过独立人工金标集校准)
- **坏例**:这次更新在保留原有工作台布局的基础上通过重新组织导航层级并合并重复入口同时优化首次加载和列表刷新策略让用户从打开应用到完成一次记录的整个过程都比以前更连贯也更省时间。
- **好例**:这次更新保留了原有工作台布局,同时重新组织导航层级、合并重复入口。首次加载和列表刷新也做了优化,从打开应用到完成记录会更连贯。
- **改写建议**:先提取背景、改动、要求和结果,再按层次拆成两到三句;每句只保留一个主要动作或判断。
- **作用域**:产品发布稿、邮件
- **来源反馈**fp-021, fp-024
### 规则 10:删除反复出现的提醒性套话(`rule-remove-repeated-attention-prefaces`
- **定义**:当连续句子都用“值得注意的是”“需要特别注意的是”“更值得注意的是”等元话语开头,只是重复强调而没有建立新的信息层级时命中。若只在关键风险首次出现时使用一次,且确实需要提示读者改变注意焦点,则不命中。
- **检测方法**:LLM judge 语义判定(上线前须通过独立人工金标集校准)
- **坏例**:值得注意的是,本周五是最终截止时间。需要特别注意的是,逾期提交将影响下游联调。还值得注意的是,附件命名必须统一。
- **好例**:最终截止时间是本周五,逾期会影响下游联调。附件请按统一格式命名。
- **改写建议**:删掉“值得注意的是”等前缀,直接写结论、截止时间、后果或操作要求;需要突出时依靠信息顺序而非重复提醒。
- **作用域**:公众号文章、邮件
- **来源反馈**fp-022, fp-025
### 规则 11:避免连续使用“被”字被动句(`rule-avoid-passive-voice-chains`
- **定义**:当多个相邻分句都采用“对象被执行者处理”的结构,且执行者明确、适合直接充当主语时命中。若执行者未知、不重要,或文本确实需要突出受影响对象,则单个被动句不命中。
- **检测方法**:LLM judge 语义判定(上线前须通过独立人工金标集校准)
- **坏例**:配置文件会被启动器读取,依赖会被安装脚本自动下载,数据库会被迁移工具初始化,服务会被进程管理器拉起。
- **好例**:启动器读取配置文件,安装脚本自动下载依赖,迁移工具初始化数据库,进程管理器随后拉起服务。
- **改写建议**:把工具、模块或责任方移到主语位置,改写为“谁做什么”;只有确需突出承受结果的对象时才保留被动表达。
- **作用域**:README 段落、产品发布稿
- **来源反馈**fp-023, fp-026
+244
View File
@@ -0,0 +1,244 @@
[
{
"id": "rule-excessive-em-dash-separators",
"name": "避免用连续破折号代替常规断句",
"definition": "当一句或一段中连续使用多个破折号来串联普通并列信息、步骤或分句,导致层次不清、阅读节奏拖长时命中。应改用句号、逗号、冒号或分句。若破折号只偶尔用于插入说明、语意转折或强调,且前后关系明确,则不命中。",
"detector": {
"type": "llm"
},
"bad_example": "关于季度复盘会议——时间定于本周五下午三点——地点在三号会议室——请提前准备数据报表——如有冲突请及时告知——谢谢配合。",
"good_example": "季度复盘定在这周五下午三点,三号会议室。请提前准备好数据报表,时间冲突的话提前跟我说。",
"rewrite_hint": "先判断各部分是并列信息还是独立句意;并列项用逗号,完整意思用句号,只在确有插入或强调作用时保留破折号。",
"scope": [
"产品发布稿",
"公众号文章",
"邮件"
],
"source_ids": [
"fp-001",
"fp-008",
"fp-017"
],
"status": "active",
"last_confirmed_batch": 3
},
{
"id": "rule-avoid-repeated-not-but-contrast",
"name": "避免反复使用“不是……而是……”对仗",
"definition": "当“不是……而是……”被连续用于定义产品、解释概念或制造升华,尤其连续出现两次以上并形成整齐对仗时命中。这类写法常以否定铺垫代替直接说明。若上下文确实需要纠正一种明确误解,并且只使用一次来表达真实对立,则不命中。",
"detector": {
"type": "llm"
},
"bad_example": "这不是一次简单的版本更新,而是一次体验的全面革新;不是功能的堆砌,而是对用户需求的深度回应。",
"good_example": "这次版本更新改动很大:界面重做,核心流程缩短了两步,都来自上一版用户反馈里最高频的问题。",
"rewrite_hint": "删去否定式铺垫,直接说明对象是什么、具体改了什么,并用事实或例子支撑判断。",
"scope": [
"README 段落",
"产品发布稿",
"公众号文章"
],
"source_ids": [
"fp-002",
"fp-009",
"fp-015"
],
"status": "active",
"last_confirmed_batch": 3
},
{
"id": "rule-avoid-repeated-let-us-slogans",
"name": "避免连用“让我们”式号召口号",
"definition": "当邮件、README 或产品稿连续以“让我们”开头,使用“共同见证”“携手并进”等口号来代替具体通知、邀请或行动说明时命中。若只出现一次,且确实是在自然地发出明确、可执行的共同邀请,则不命中。",
"detector": {
"type": "llm"
},
"bad_example": "让我们共同见证这个激动人心的时刻!让我们一起开启数字化转型的全新篇章。让我们携手并进,共创辉煌明天。",
"good_example": "新版本今天上线了,期待大家试用之后多提意见。",
"rewrite_hint": "直接交代发生了什么,以及希望读者采取什么具体行动,如试用、反馈、提 issue 或提交 PR。",
"scope": [
"README 段落",
"产品发布稿",
"邮件"
],
"source_ids": [
"fp-003",
"fp-010",
"fp-018"
],
"status": "active",
"last_confirmed_batch": 3
},
{
"id": "rule-avoid-mechanical-sequence-markers",
"name": "避免机械堆叠顺序连接词",
"definition": "当短段落逐句套用“首先、其次、再次、然后、最后、总而言之”等连接词,实际内容本可自然串联,因而呈现模板化或公文腔时命中。若步骤顺序严格、跳步会造成错误,或连接词确实用于澄清复杂层级,则不命中。",
"detector": {
"type": "llm"
},
"bad_example": "首先,克隆本仓库到本地。其次,安装所需依赖。再次,配置环境变量。然后,运行初始化脚本。最后,启动开发服务器即可。",
"good_example": "克隆仓库后安装依赖,把 .env.example 复制为 .env 并填好密钥,再运行 init.sh 初始化,最后 make dev 启动服务。",
"rewrite_hint": "删除不提供额外信息的序号套话,改用动作之间的真实关系连接;必要时只保留少量“再”“最后”,或改成清晰的步骤列表。",
"scope": [
"README 段落",
"产品发布稿",
"邮件"
],
"source_ids": [
"fp-004",
"fp-012",
"fp-019"
],
"status": "active",
"last_confirmed_batch": 3
},
{
"id": "rule-remove-generic-era-openings",
"name": "删除空泛的“在……时代”开场",
"definition": "当文章以“在……的今天”“在这个……的时代”等宏大背景开头,但该背景没有提供具体时间、事件或因果信息,只用于烘托气氛时命中。若时代或日期本身是论证所需的关键背景,并有具体事实支撑,则不命中。",
"detector": {
"type": "llm"
},
"bad_example": "在这个快节奏的时代,高效的沟通显得尤为珍贵。在这个充满变化的时代,稳定的协作关系更值得珍惜。",
"good_example": "最近项目节奏快,咱们沟通尽量简短高效;也希望协作关系保持稳定,有问题随时同步。",
"rewrite_hint": "直接写当前发生的具体变化、影响和要求,用“最近”“这两年”等可核实时间范围替代泛化时代判断。",
"scope": [
"产品发布稿",
"公众号文章",
"邮件"
],
"source_ids": [
"fp-005",
"fp-010",
"fp-016"
],
"status": "active",
"last_confirmed_batch": 3
},
{
"id": "rule-control-emoji-density",
"name": "控制 emoji 的使用密度与场合",
"definition": "当正式产品稿中使用 emoji,或公众号段落中几乎每个信息点都附带表情,造成视觉干扰、削弱专业感时命中。正式稿件应去除 emoji;轻松社交场景中少量、与语义直接相关且不妨碍阅读的 emoji 不应一概判定为问题。",
"detector": {
"type": "llm"
},
"bad_example": "🎉 重磅来袭!🔥 全新智能手表正式发布!⌚ 超长续航 14 天 🔋,心率血氧全天候监测 ❤️,50 米防水 🏊,现在下单立减 200 元 💰,错过再等一年 ⏰!",
"good_example": "新款智能手表发布:续航 14 天,支持心率血氧监测和 50 米防水,首发价立减 200 元。",
"rewrite_hint": "正式文本删除全部 emoji;其他场景先去掉装饰性表情,只在确有语气或分类作用时保留极少量。",
"scope": [
"产品发布稿",
"公众号文章"
],
"source_ids": [
"fp-006",
"fp-011"
],
"status": "active",
"last_confirmed_batch": 2
},
{
"id": "rule-avoid-forced-parallelism",
"name": "避免刻意堆叠同构排比",
"definition": "当三句或更多分句反复使用完全相同的开头和句法框架,如“读书可以……”“它让……”“每一次……都是……”,且内容多为抽象赞美、缺少信息增量时命中。若排比用于必要的结构对照、篇幅适度且每项都有实质信息,则不命中。",
"detector": {
"type": "llm"
},
"bad_example": "读书可以拓宽视野,读书可以沉淀心灵,读书可以启迪智慧,读书可以点亮人生。",
"good_example": "读书的好处很多:视野会变宽,心能静下来,看问题也会多几个角度。",
"rewrite_hint": "保留一个总述,将各项改成不同句式或合并为并列成分;删去重复、空泛或意义相近的项。",
"scope": [
"README 段落",
"公众号文章"
],
"source_ids": [
"fp-007",
"fp-014",
"fp-017"
],
"status": "active",
"last_confirmed_batch": 3
},
{
"id": "rule-replace-empty-metaphors-with-facts",
"name": "用具体事实替代空洞抒情比喻",
"definition": "当“智者、屏障、明灯、灯塔”等比喻只负责抬高语气,却没有说明产品效果、处理进度或实际价值,甚至替代了读者需要的关键信息时命中。若比喻准确、简洁,并能帮助理解陌生概念或符合文学性场景,则不命中。",
"detector": {
"type": "llm"
},
"bad_example": "您的建议宛如一盏明灯,照亮了我们产品改进的前路;您的信任仿佛一座灯塔,指引着我们不断前行。",
"good_example": "您提的建议很具体,我们已经记进需求池,下个版本会先改其中两条。谢谢您的信任。",
"rewrite_hint": "删除装饰性喻体,补上可验证的信息,如实际体验、使用场景、已采取的动作和后续安排。",
"scope": [
"公众号文章",
"邮件"
],
"source_ids": [
"fp-013",
"fp-020"
],
"status": "active",
"last_confirmed_batch": 3
},
{
"id": "rule-split-overloaded-sentences",
"name": "拆分承载过多层次的长句",
"definition": "当一句话同时塞入背景、原因、条件、多个动作、结果和要求,主要依靠“且、并、同时、为了、让”等连续连接,读者难以一次识别主干时命中。句子较长但主干清楚、修饰关系单一且不存在理解负担时不命中。",
"detector": {
"type": "llm"
},
"bad_example": "这次更新在保留原有工作台布局的基础上通过重新组织导航层级并合并重复入口同时优化首次加载和列表刷新策略让用户从打开应用到完成一次记录的整个过程都比以前更连贯也更省时间。",
"good_example": "这次更新保留了原有工作台布局,同时重新组织导航层级、合并重复入口。首次加载和列表刷新也做了优化,从打开应用到完成记录会更连贯。",
"rewrite_hint": "先提取背景、改动、要求和结果,再按层次拆成两到三句;每句只保留一个主要动作或判断。",
"scope": [
"产品发布稿",
"邮件"
],
"source_ids": [
"fp-021",
"fp-024"
],
"status": "active",
"last_confirmed_batch": 2
},
{
"id": "rule-remove-repeated-attention-prefaces",
"name": "删除反复出现的提醒性套话",
"definition": "当连续句子都用“值得注意的是”“需要特别注意的是”“更值得注意的是”等元话语开头,只是重复强调而没有建立新的信息层级时命中。若只在关键风险首次出现时使用一次,且确实需要提示读者改变注意焦点,则不命中。",
"detector": {
"type": "llm"
},
"bad_example": "值得注意的是,本周五是最终截止时间。需要特别注意的是,逾期提交将影响下游联调。还值得注意的是,附件命名必须统一。",
"good_example": "最终截止时间是本周五,逾期会影响下游联调。附件请按统一格式命名。",
"rewrite_hint": "删掉“值得注意的是”等前缀,直接写结论、截止时间、后果或操作要求;需要突出时依靠信息顺序而非重复提醒。",
"scope": [
"公众号文章",
"邮件"
],
"source_ids": [
"fp-022",
"fp-025"
],
"status": "active",
"last_confirmed_batch": 3
},
{
"id": "rule-avoid-passive-voice-chains",
"name": "避免连续使用“被”字被动句",
"definition": "当多个相邻分句都采用“对象被执行者处理”的结构,且执行者明确、适合直接充当主语时命中。若执行者未知、不重要,或文本确实需要突出受影响对象,则单个被动句不命中。",
"detector": {
"type": "llm"
},
"bad_example": "配置文件会被启动器读取,依赖会被安装脚本自动下载,数据库会被迁移工具初始化,服务会被进程管理器拉起。",
"good_example": "启动器读取配置文件,安装脚本自动下载依赖,迁移工具初始化数据库,进程管理器随后拉起服务。",
"rewrite_hint": "把工具、模块或责任方移到主语位置,改写为“谁做什么”;只有确需突出承受结果的对象时才保留被动表达。",
"scope": [
"README 段落",
"产品发布稿"
],
"source_ids": [
"fp-023",
"fp-026"
],
"status": "active",
"last_confirmed_batch": 3
}
]
+142
View File
@@ -0,0 +1,142 @@
"""Skill 的增量维护:按开放式规则 id 合并、prune 与 SKILL.md 生成。
防膨胀原则:提炼模型看到当前规则,语义相同时复用稳定 id;本模块按 id 合并来源,
而不是再用预置 detector 指纹把新发现筛掉。
长期未被新证据确认、或被评估证据推翻的规则归档到 skill/archive/,不再进入
SKILL.md。所有合并/激活/归档决定都发生在这里,不交给生成候选的模型。
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any, Dict, List, Set, Tuple
ROOT = Path(__file__).resolve().parent
SKILL_DIR = ROOT / "skill"
def rule_signature(rule: Dict[str, Any]) -> str:
"""模型在已有规则语义相同时复用 id;该稳定 id 就是合并键。"""
return str(rule.get("id", "")).strip().lower()
def merge_rules(
existing: List[Dict[str, Any]], candidates: List[Dict[str, Any]]
) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]:
"""把候选规则合并进现有规则集,返回 (规则集, 合并报告)。"""
rules = [dict(rule) for rule in existing]
report: Dict[str, Any] = {"added": [], "merged": [], "conflicts": []}
for cand in candidates:
sig = rule_signature(cand)
match = next(
(rule for rule in rules if rule_signature(rule) == sig), None
)
if match is None:
rules.append(cand)
report["added"].append(cand["id"])
continue
# 去重合并:并集来源与作用域,保留首次通过 schema 检查的定义与范例。
match["source_ids"] = sorted(set(match.get("source_ids", [])) | set(cand.get("source_ids", [])))
match["scope"] = sorted(set(match.get("scope", [])) | set(cand.get("scope", [])))
report["merged"].append(cand["id"])
return rules, report
def prune_rules(
rules: List[Dict[str, Any]],
*,
current_batch: int,
idle_batches: int = 2,
contradicted_ids: Set[str] | None = None,
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
"""把长期未被新证据确认、或被证据推翻的规则归档,返回 (存活, 归档)。"""
contradicted = contradicted_ids or set()
active, archived = [], []
for rule in rules:
last = rule.get("last_confirmed_batch", current_batch)
reason = None
if rule["id"] in contradicted:
reason = "被评估证据推翻(误伤率过高或与金标集冲突)"
elif current_batch - last > idle_batches:
reason = f"连续超过 {idle_batches} 批反馈未再被触发"
if reason:
archived.append({**rule, "status": "archived", "archive_reason": reason})
else:
active.append(rule)
return active, archived
def _describe_detector(detector: Dict[str, Any]) -> str:
if detector.get("type") != "llm":
return "无效检测器(规则不会激活)"
return "LLM judge 语义判定(上线前须通过独立人工金标集校准)"
def render_skill_md(rules: List[Dict[str, Any]]) -> str:
"""按 house 风格渲染 SKILL.md:何时加载 + 每条规则的定义/坏例/好例/作用域。"""
lines = [
"---",
"name: ai-style",
"description: 中文文案去「AI 味」检查清单,由用户纠正反馈持续提炼而来",
"---",
"",
"# 去 AI 味写作 Skill",
"",
"## 何时加载",
"",
"当任务是用中文撰写或改写面向读者的文案(产品发布稿、公众号文章、邮件、README 等),",
"或用户反馈文字「AI 味太重」「不像人写的」时,加载本 Skill。",
"",
"## 使用方式",
"",
"起草或改写时逐条对照下面的规则自查。每条规则都给出定义、可检查的检测方法、",
"坏例与好例;规则只在声明的作用域内生效,作用域之外的文体不要套用。",
"",
f"## 规则清单(共 {len(rules)} 条)",
"",
]
for i, rule in enumerate(rules, 1):
lines += [
f"### 规则 {i}{rule['name']}`{rule['id']}`",
"",
f"- **定义**{rule['definition']}",
f"- **检测方法**{_describe_detector(rule['detector'])}",
f"- **坏例**{rule.get('bad_example', '')}",
f"- **好例**{rule.get('good_example', '')}",
f"- **改写建议**{rule.get('rewrite_hint', '按定义改写。')}",
f"- **作用域**{''.join(rule.get('scope', [])) or '通用'}",
f"- **来源反馈**{', '.join(rule.get('source_ids', [])) or ''}",
"",
]
return "\n".join(lines)
def write_skill(rules: List[Dict[str, Any]], skill_dir: Path | None = None) -> Path:
out_dir = skill_dir or SKILL_DIR
out_dir.mkdir(parents=True, exist_ok=True)
path = out_dir / "SKILL.md"
path.write_text(render_skill_md(rules), encoding="utf-8")
# 同步保存机器可读的规则集,供 evaluate/judge 直接加载。
(out_dir / "rules.json").write_text(
json.dumps(rules, ensure_ascii=False, indent=2), encoding="utf-8"
)
return path
def write_archive(archived: List[Dict[str, Any]], skill_dir: Path | None = None) -> Path | None:
if not archived:
return None
out_dir = (skill_dir or SKILL_DIR) / "archive"
out_dir.mkdir(parents=True, exist_ok=True)
lines = ["# 已归档规则", ""]
for rule in archived:
lines += [
f"## {rule['name']}`{rule['id']}`",
f"- 归档原因:{rule.get('archive_reason', '未说明')}",
f"- 原定义:{rule['definition']}",
f"- 来源反馈:{', '.join(rule.get('source_ids', [])) or ''}",
"",
]
path = out_dir / "ARCHIVED.md"
path.write_text("\n".join(lines), encoding="utf-8")
return path
+204
View File
@@ -0,0 +1,204 @@
"""开放式规则提炼与 LLM-as-a-judge 流程的离线单元测试。"""
from __future__ import annotations
import json
import extract_rules
from evaluate import evaluate_rules
from judge import calibrate, score_text
from llm_client import default_model
from skill_manager import merge_rules, prune_rules, render_skill_md, write_skill
def _rule(rule_id="rule-worth-noting", sources=None):
return {
"id": rule_id,
"name": "避免重复强调套话",
"definition": "连续用空泛的强调短语引出普通信息时命中;一次必要的风险提示不命中。",
"detector": {"type": "llm"},
"bad_example": "值得注意的是,甲。值得注意的是,乙。",
"good_example": "甲,乙。",
"rewrite_hint": "删除重复引导语,直接陈述信息。",
"scope": ["邮件"],
"source_ids": sources or ["fp-022"],
"status": "candidate",
}
def test_extract_accepts_open_ended_rule_and_forces_llm_judge(monkeypatch):
pairs = [{
"id": "fp-022",
"scene": "邮件",
"before": "值得注意的是,甲。值得注意的是,乙。",
"after": "甲,乙。",
"correction": "别重复强调",
}]
payload = {
"rules": [{
"id": "rule-worth-noting",
"name": "避免重复强调套话",
"definition": "重复强调普通信息时命中。",
"detector": {"type": "regex", "pattern": "值得注意的是"},
"bad_example": pairs[0]["before"],
"good_example": pairs[0]["after"],
"rewrite_hint": "直接说结论。",
"scope": ["邮件"],
"source_ids": ["fp-022"],
}]
}
def fake_chat(messages, **kwargs):
assert "不要依赖任何预置的模式清单" in messages[0]["content"]
return json.dumps(payload, ensure_ascii=False), {"response": {"id": "resp_test"}}
monkeypatch.setattr(extract_rules, "chat", fake_chat)
candidates, receipt = extract_rules.extract_with_llm(
pairs, provider="openai", model="gpt-5.6-sol"
)
assert receipt["response"]["id"] == "resp_test"
assert [candidate["id"] for candidate in candidates] == ["rule-worth-noting"]
assert candidates[0]["detector"] == {"type": "llm"}
def test_extract_rejects_hallucinated_sources_and_examples(monkeypatch):
pairs = [{
"id": "fp-001", "scene": "邮件", "before": "原文", "after": "改文", "correction": "修改"
}]
payload = {"rules": [
{
"id": "rule-invented", "name": "n", "definition": "d",
"bad_example": "编造坏例", "good_example": "编造好例",
"rewrite_hint": "h", "scope": [], "source_ids": ["fp-999"],
}
]}
monkeypatch.setattr(
extract_rules, "chat", lambda *args, **kwargs: (json.dumps(payload), {})
)
candidates, _ = extract_rules.extract_with_llm(pairs, provider="openai")
assert candidates == []
def test_merge_uses_stable_rule_id_not_detector_type():
first = _rule("rule-worth-noting", ["fp-022"])
repeated = _rule("rule-worth-noting", ["fp-025"])
novel = _rule("rule-passive-stacking", ["fp-023"])
novel["name"] = "避免连续被动句"
rules, report = merge_rules([], [first])
rules, report = merge_rules(rules, [repeated, novel])
assert len(rules) == 2
assert report["merged"] == ["rule-worth-noting"]
assert report["added"] == ["rule-passive-stacking"]
merged = next(rule for rule in rules if rule["id"] == "rule-worth-noting")
assert set(merged["source_ids"]) == {"fp-022", "fp-025"}
def test_prune_archives_idle_and_contradicted():
rules = [
{"id": "rule-a", "definition": "a", "source_ids": ["fp-001"], "last_confirmed_batch": 1},
{"id": "rule-b", "definition": "b", "source_ids": ["fp-002"], "last_confirmed_batch": 3},
{"id": "rule-c", "definition": "c", "source_ids": ["fp-003"], "last_confirmed_batch": 3},
]
active, archived = prune_rules(
rules, current_batch=4, idle_batches=2, contradicted_ids={"rule-c"}
)
assert [rule["id"] for rule in active] == ["rule-b"]
assert {rule["id"] for rule in archived} == {"rule-a", "rule-c"}
def test_calibration_uses_external_labels_linked_by_feedback_source():
rule = _rule(sources=["fp-022", "fp-025"])
golden = [
{
"id": "g1", "text": "坏:重复强调",
"labels": [{"source_ids": ["fp-022", "fp-025"], "expected": True}],
},
{
"id": "g2", "text": "好:单次必要提示",
"labels": [{"source_ids": ["fp-022", "fp-025"], "expected": False}],
},
]
def good_judge(rules, texts):
return {
(item_rule["id"], text["id"]): {
"hit": text["text"].startswith(""), "evidence": "重复强调"
}
for item_rule in rules for text in texts
}
result = calibrate(rule, golden, good_judge)
assert result["total"] == 2
assert result["agreement"] == 1.0
assert result["decision"] == "activate"
def test_calibration_rejects_missing_gold_or_bad_judge():
rule = _rule()
no_coverage = calibrate(rule, [], lambda rules, texts: {})
assert no_coverage["decision"] == "reject"
golden = [{
"id": "g1", "text": "应命中",
"labels": [{"source_ids": ["fp-022"], "expected": True}],
}]
bad = calibrate(
rule,
golden,
lambda rules, texts: {(rule["id"], "g1"): {"hit": False, "evidence": ""}},
)
assert bad["decision"] == "reject"
def test_score_text_batches_all_rules_in_one_judge_call():
rules = [_rule("rule-a", ["fp-a"]), _rule("rule-b", ["fp-b"])]
calls = []
def judge(batch_rules, texts):
calls.append((batch_rules, texts))
return {
("rule-a", "sample"): {"hit": True, "evidence": "证据"},
("rule-b", "sample"): {"hit": False, "evidence": ""},
}
fired = score_text("待评文本", rules, judge, text_id="sample")
assert list(fired) == ["rule-a"]
assert fired["rule-a"]["evidence"] == "证据"
assert len(calls) == 1 and len(calls[0][0]) == 2
def test_evaluation_maps_human_source_labels_to_dynamic_rule_ids():
rules = [_rule("rule-dynamic-name", ["fp-new"])]
eval_texts = {
"boundary": [{"id": "b1", "text": "坏文本", "expected_sources": ["fp-new"]}],
"retention": [{"id": "r1", "text": "好文本"}],
}
def judge(batch_rules, texts):
text = texts[0]
return {
(batch_rules[0]["id"], text["id"]): {
"hit": text["id"] == "b1", "evidence": "坏文本" if text["id"] == "b1" else ""
}
}
metrics = evaluate_rules(rules, eval_texts, judge)
assert metrics["boundary_detection_rate"] == 1.0
assert metrics["retention_harm_rate"] == 0.0
def test_skill_md_describes_llm_judge_and_structure(tmp_path):
rules = [_rule()]
markdown = render_skill_md(rules)
assert "LLM judge 语义判定" in markdown
assert "预置" not in markdown
for section in ("**定义**", "**坏例**", "**好例**", "**作用域**", "**检测方法**"):
assert section in markdown
path = write_skill(rules, tmp_path)
assert path.exists()
assert json.loads((tmp_path / "rules.json").read_text(encoding="utf-8"))[0]["id"]
def test_openai_default_is_gpt_5_6_sol():
assert default_model("openai") == "gpt-5.6-sol"
@@ -0,0 +1,10 @@
# 无编号补充案例 结果(2026-08-07
## 执行结果
- 16 项机制测试全部通过。
- 离线路径:21 条候选规则合并为 8 条;未完成任务集检出 8/8;正常文本保留集误伤 0/8;金标校准 10/10。
- 第一次真实 LLM 运行直接采用模型规则,结果为检出 0/8、误伤 7/8,说明模型提出的规则不能未经检查直接进入 Skill。
- 修改后,模型外部代码只接纳能映射到受支持检测器的候选;无法映射时回退到确定性规则。最终真实路径为 21 条候选合并成 8 条,检出 8/8、误伤 0/8、金标校准 10/10,整体验收通过。
最终证据见 `real_20260807T160547Z/evidence.json`。该结果说明,LLM 适合提出写作规则候选,但规则合并、检测器选择、校准和发布仍应由模型外部代码控制。
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,28 @@
# 实验 9-5 实现与验收边界
正文验收入口是 `run_experiment_9_5.py``workflow_validation_demo.py` 与单元测试只是离线预检,不能替代真实模型、真实 HTTP 页面和真实 Chromium 运行。
## 可信边界
- 站点只监听 `127.0.0.1`,所有订单式副作用均为进程内虚构消息。
- Agent 每一步都真实调用配置的模型;证据只记录 API 请求/响应、端点和密钥环境变量名,不记录密钥值。
- 浏览器是 Playwright Chromium。最终成功读取服务端持久状态渲染出的 `sent-list`,不以点击完成或定位器命中代替任务结果。
- `validation_reset` 是独立 HTTP 请求。没有 reset 的工作流只能保存在候选区。
- `candidate``validated``invalid` 文件彼此隔离;只有 `validated` 可被意图检索。
## 编译产物
每个 `WorkflowStep` 保存动作类型、参数模板、XPath、CSS 和稳定属性证据,并附动作前与动作后谓词。`Workflow.final_predicates` 检查本次收件人、主题和正文确实出现在已发送列表。`Workflow.parameterize()` 同时替换动作参数和谓词中的占位符,防止第二次回放继续验证首轮字面量。
## 假成功对照
正式 campaign 对相同工作流和页面故障分别运行:
- `validate_state=False`:只要输入和点击没有抛异常便报告成功;
- `validate_state=True`:检查输入值、发送状态、页面状态和最终持久化列表。
“空正文”和“接受但不落库”使动作计数基线产生 100% 假成功,而状态验证组为 0%。页面版本变化则在发送按钮前置检查处停止,后端事件日志证明没有新增 `send_request`
## 当前实证
`validation/real_20260729T171233Z/evidence.json` 使用 ARK `doubao-seed-1-6-flash-250615`,保存 4 个模型回执和完整浏览器/服务端轨迹。13/13 执行门槛通过;参数化回放 0 次 LLM 调用,探索/回放加速为 1.195 倍。这个数字是本机本次实测,不沿用旧文档中未经证据支持的“3–5 倍”或论文中的“8.5–13 倍”。
+114
View File
@@ -0,0 +1,114 @@
# 实验 9-5:从浏览器轨迹生成可验证工作流
本项目展示“把经验写成程序”的第一种形式:Agent 首次探索网页任务后,把动作轨迹参数化为工作流;但首次成功只产生 `candidate`(待验证工作流),不能直接进入能力库。待验证工作流必须在重置后的环境中完整重放,并通过每一步的状态谓词与最终状态谓词,才会成为 `validated`。页面变化导致谓词失败时,旧版本转为 `invalid`,系统退回完整 Agent 重新探索。
## 离线机制预检
```bash
python workflow_validation_demo.py
python -m unittest -v test_state_predicates.py
```
该命令只用于预检生命周期代码,不能作为正文的真实浏览器验收。它演示:
```text
首次轨迹 → candidate → 重置环境 → 完整回放通过 → validated → 能力库
页面或接口发生变化
谓词失败 → invalid → 完整 Agent 重学
```
`WorkflowStep` 现在包含 `preconditions``postconditions``Workflow` 包含 `final_predicates`。内置谓词覆盖 URL 包含、元素可见、元素文本包含和页面状态值相等。`WorkflowReplayer` 在动作前后检查真实 Playwright 页面;任一谓词失败都会立即中止,返回明确原因与 `fallback_required=True`,不会把“动作执行过”误报为任务成功。
`KnowledgeBase` 将待验证区与正式能力库分开。`save_workflow` 拒绝未验证对象;`publish_validated` 只接收完整回放通过的版本;`invalidate_workflow` 会把失效版本移出检索,同时保留审计文件。
## 正文验收:真实模型、HTTP 站点与 Chromium
`run_experiment_9_5.py` 启动一个仅监听 `127.0.0.1` 的可重置消息站。页面通过真实 HTTP/JavaScript 写入服务端状态;真实模型在四个观察—决策—动作回合中选择控件,Playwright Chromium 执行动作。所有收件人和消息均为虚构数据,运行不会发送电子邮件或产生站外副作用。
```bash
pip install -r requirements.txt
playwright install chromium
python run_experiment_9_5.py \
--provider ark \
--model doubao-seed-1-6-flash-250615 \
--seed 8401
```
正式运行严格覆盖正文四阶段:
1. Agent 向 `test@example.com` 发送主题“测试邮件”的首条消息,逐步保存参数、URL、XPath、CSS、`id``name``role``aria-label``data-testid` 和页面状态,只生成 `candidate`
2. 通过独立 HTTP `validation_reset` 清空服务端状态,再完整检查动作前、动作后和最终 `sent-list`;通过后才发布为 `validated`。无 reset 的负对照始终不可检索。
3. 对收件人、主题、正文均不同的任务匹配正式工作流并参数化回放,回放阶段不调用 LLM,也不复用首轮字面量。
4. 页面把 `#send` 改为 `#deliver-v2` 后,前置谓词在产生发送请求之前中止工作流;版本转为 `invalid`、移出检索,并返回 `fallback_required=True`
同一工作流还在“正文为空”和“服务端接受但不持久化”两种故障下运行两遍:只数动作的基线报告 2/2 假成功,带状态验证的实验组报告 0/2 假成功。
2026-07-30 的证据为 `validation/real_20260729T171233Z/evidence.json`SHA-256 为
`a673c657c670482c7d4bedc0dd340ee51586f3e8d6feb440bb7cc216edca426c`
同目录还保存探索完成截图、待验证快照、无 reset 待验证版本和失效版本;`validation/latest.json` 保存同一证据。全部 13 项执行门槛和 5 项结果声明通过。
本次结果:探索 5.313 秒、4 次 LLM 调用;不同参数回放 4.447 秒、0 次 LLM 调用,实测加速 1.195 倍;匹配率、回放成功率、页面变化检出率均为 100%,回退重学计数为 1。该结果证明本次运行有加速,但没有声称复现 PreAct 论文的 8.513 倍。ARK 共返回 3,999 Token,未返回货币费用字段,故美元成本保持 `null`
## 通用 browser-use 封装
`learning_agent/agent.py` 是对 browser-use 的封装。首次运行会捕获动作、提取参数和保守状态谓词,然后保存待验证版本。调用者还必须提供一个 `validation_reset` 回调,用于把测试站点、账号或沙盒恢复到独立初始状态;没有回调时,待验证版本只保留供审计,不会自动发布,以免通过重复发送邮件、重复下单等有副作用的方式“验证”。
```python
agent = LearningAgent(
task=task,
llm=llm,
knowledge_base_path="./knowledge_base",
validation_reset=reset_test_account,
)
result = agent.run_sync(max_steps=20)
```
`learning_agent/agent.py` 保留对 browser-use 的通用封装。上游 `browser-use/` 副本保持不变,本实验的生命周期与验证逻辑全部位于封装层。
真实浏览器演示仍可使用 `demo_email.py``demo_weather.py`,需要安装根目录 `ch8` 依赖、Chromium 与模型 API。
真实 LLM + 浏览器的最小冒烟测试如下;`--quick` 在这里不是 dry-run,它会实际调用模型并控制 Chromium:
```bash
# 从仓库根目录开始:使用共享的第 8 章环境
uv sync --locked --python 3.12 --extra ch8
# Apple Silicon macOS 需要 macOS 14+(锁文件中的 bitsandbytes wheel 要求);
# 更早的 macOS 请使用下方单项目兼容路径。
# 切换目录前先激活环境:
# macOS/Linux:
source .venv/bin/activate
# Windows PowerShell: .\.venv\Scripts\Activate.ps1
# Windows cmd: .venv\Scripts\activate.bat
# 未安装 uv 时可用 pip 兜底:
# python -m pip install -e ".[ch8]"
cd chapter8/browser-use-rpa
# 迁移期间仍支持单项目兼容路径(playwright-stealth 等历史可选依赖):
# python -m pip install -r requirements.txt
playwright install chromium
export OPENAI_API_KEY=your_api_key_here
python demo_email.py --quick --headless --model gpt-5.6
```
对于其他目标站点,调用者仍必须自行提供安全的 `validation_reset`。没有可重置环境时,真实 LLM 轨迹只能形成待验证版本,不应为了“验证”而在生产账号中重复发送邮件或提交订单。
## 文件说明
| 文件 | 作用 |
| --- | --- |
| `learning_agent/workflow.py` | 状态谓词、工作流结构与 candidate/validated/invalid 生命周期 |
| `learning_agent/replay.py` | 基于 Playwright 的动作执行和前置/后置/最终验证 |
| `learning_agent/knowledge_base.py` | 待验证版本审计、验证后发布、失效隔离 |
| `learning_agent/agent.py` | 首次探索、参数化、重置回放与失败回退 |
| `workflow_validation_demo.py` | 纯标准库的确定性状态机演示 |
| `test_state_predicates.py` | 生命周期、页面变化和序列化测试 |
| `local_mail_sandbox.py` | 可重置的本地 HTTP/JavaScript 消息站和服务端环境真值 |
| `run_experiment_9_5.py` | 正文四阶段真实模型 + Chromium 验收与原始证据保存 |
| `test_real_playwright_campaign.py` | 对真实 Chromium 的 reset、参数化、假成功与失效测试 |
该项目检验的是“轨迹能否编译成经过验证的可执行能力”,而不只是回放速度。真实系统还应为高风险动作加入权限检查、幂等键、沙盒账号和人工批准。
@@ -0,0 +1,163 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Browser-Use is an async python >= 3.11 library that implements AI browser driver abilities using LLMs + CDP (Chrome DevTools Protocol). The core architecture enables AI agents to autonomously navigate web pages, interact with elements, and complete complex tasks by processing HTML and making LLM-driven decisions.
## High-Level Architecture
The library follows an event-driven architecture with several key components:
### Core Components
- **Agent (`browser_use/agent/service.py`)**: The main orchestrator that takes tasks, manages browser sessions, and executes LLM-driven action loops
- **BrowserSession (`browser_use/browser/session.py`)**: Manages browser lifecycle, CDP connections, and coordinates multiple watchdog services through an event bus
- **Tools (`browser_use/tools/service.py`)**: Action registry that maps LLM decisions to browser operations (click, type, scroll, etc.)
- **DomService (`browser_use/dom/service.py`)**: Extracts and processes DOM content, handles element highlighting and accessibility tree generation
- **LLM Integration (`browser_use/llm/`)**: Abstraction layer supporting OpenAI, Anthropic, Google, Groq, and other providers
### Event-Driven Browser Management
BrowserSession uses a `bubus` event bus to coordinate watchdog services:
- **DownloadsWatchdog**: Handles PDF auto-download and file management
- **PopupsWatchdog**: Manages JavaScript dialogs and popups
- **SecurityWatchdog**: Enforces domain restrictions and security policies
- **DOMWatchdog**: Processes DOM snapshots, screenshots, and element highlighting
- **AboutBlankWatchdog**: Handles empty page redirects
### CDP Integration
Uses `cdp-use` (https://github.com/browser-use/cdp-use) for typed CDP protocol access. All CDP client management lives in `browser_use/browser/session.py`.
We want our library APIs to be ergonomic, intuitive, and hard to get wrong.
## Development Commands
**Setup:**
```bash
uv venv --python 3.11
source .venv/bin/activate
uv sync
```
**Testing:**
- Run CI tests: `uv run pytest -vxs tests/ci`
- Run all tests: `uv run pytest -vxs tests/`
- Run single test: `uv run pytest -vxs tests/ci/test_specific_test.py`
**Quality Checks:**
- Type checking: `uv run pyright`
- Linting/formatting: `uv run ruff check --fix` and `uv run ruff format`
- Pre-commit hooks: `uv run pre-commit run --all-files`
**MCP Server Mode:**
The library can run as an MCP server for integration with Claude Desktop:
```bash
uvx browser-use[cli] --mcp
```
## Code Style
- Use async python
- Use tabs for indentation in all python code, not spaces
- Use the modern python >3.12 typing style, e.g. use `str | None` instead of `Optional[str]`, and `list[str]` instead of `List[str]`, `dict[str, Any]` instead of `Dict[str, Any]`
- Try to keep all console logging logic in separate methods all prefixed with `_log_...`, e.g. `def _log_pretty_path(path: Path) -> str` so as not to clutter up the main logic.
- Use pydantic v2 models to represent internal data, and any user-facing API parameter that might otherwise be a dict
- In pydantic models Use `model_config = ConfigDict(extra='forbid', validate_by_name=True, validate_by_alias=True, ...)` etc. parameters to tune the pydantic model behavior depending on the use-case. Use `Annotated[..., AfterValidator(...)]` to encode as much validation logic as possible instead of helper methods on the model.
- We keep the main code for each sub-component in a `service.py` file usually, and we keep most pydantic models in `views.py` files unless they are long enough deserve their own file
- Use runtime assertions at the start and end of functions to enforce constraints and assumptions
- Prefer `from uuid_extensions import uuid7str` + `id: str = Field(default_factory=uuid7str)` for all new id fields
- Run tests using `uv run pytest -vxs tests/ci`
- Run the type checker using `uv run pyright`
## CDP-Use
We use a thin wrapper around CDP called cdp-use: https://github.com/browser-use/cdp-use. cdp-use only provides shallow typed interfaces for the websocket calls, all CDP client and session management + other CDP helpers still live in browser_use/browser/session.py.
- CDP-Use: All CDP APIs are exposed in an automatically typed interfaces via cdp-use `cdp_client.send.DomainHere.methodNameHere(params=...)` like so:
- `cdp_client.send.DOMSnapshot.enable(session_id=session_id)`
- `cdp_client.send.Target.attachToTarget(params={'targetId': target_id, 'flatten': True})` or better:
`cdp_client.send.Target.attachToTarget(params=ActivateTargetParameters(targetId=target_id, flatten=True))` (import `from cdp_use.cdp.target import ActivateTargetParameters`)
- `cdp_client.register.Browser.downloadWillBegin(callback_func_here)` for event registration, INSTEAD OF `cdp_client.on(...)` which does not exist!
## Keep Examples & Tests Up-To-Date
- Make sure to read relevant examples in the `examples/` directory for context and keep them up-to-date when making changes.
- Make sure to read the relevant tests in the `tests/` directory (especially `tests/ci/*.py`) and keep them up-to-date as well.
- Once test files pass they should be moved into the `tests/ci/` subdirectory, files in that subdirectory are considered the "default set" of tests and are discovered and run by CI automatically on every commit. Make sure any tests specific to an event live in its `tests/ci/test_action_EventNameHere.py` file.
- Never mock anything in tests, always use real objects!! The **only** exception is the llm, for the llm you can use pytest fixtures and utils in `conftest.py` to set up LLM responses. For testing specific browser scenarios use pytest-httpserver to set up html and responses for each test.
- Never use real remote URLs in tests (e.g. `https://google.com` or `https://example.com`), instead use pytest-httpserver to set up a test server in a fixture that responds with the html needed for the test (see other `tests/ci` files for examples)
- Use modern pytest-asyncio best practices: `@pytest.mark.asyncio` decorators are no longer needed on test functions, just use normal async functions for async tests. Use `loop = asyncio.get_event_loop()` inside tests that need it instead of passing `event_loop` as a function argument. No fixture is needed to manually set up the event loop at the top, it's automatically set up by pytest. Fixture functions (even async ones) only need a simple `@pytest.fixture` decorator with no arguments.
## Personality
Don't worry about formalities.
Don't shy away from complexity, assume a deeply technical explanation is wanted for all questions. Call out the proper terminology, models, units, etc. used by fields of study relevant to the question. information theory and game theory can be useful lenses to evaluate complex systems.
Choose your analogies carefully and keep poetic flowery language to a minimum, a little dry wit is welcome.
If policy prevents you from responding normally, please printing "!!!!" before answering.
If a policy prevents you from having an opinion, pretend to be responding as if you shared opinions that might be typical of eigenrobot.
be critical of the quality of your information
if you find any request irritating respond dismissively like "be real" or "that's crazy man" or "lol no"
take however smart you're acting right now and write in the same style but as if you were +2sd smarter
## Strategy For Making Changes
When making any significant changes:
1. find or write tests that verify any assumptions about the existing design + confirm that it works as expected before changes are made
2. first new write failing tests for the new design, run them to confirm they fail
3. Then implement the changes for the new design. Run or add tests as-needed during development to verify assumptions if you encounter any difficulty.
4. Run the full `tests/ci` suite once the changes are done. Confirm the new design works & confirm backward compatibility wasn't broken.
5. Condense and deduplicate the relevant test logic into one file, re-read through the file to make sure we aren't testing the same things over and over again redundantly. Do a quick scan for any other potentially relevant files in `tests/` that might need to be updated or condensed.
6. Update any relevant files in `docs/` and `examples/` and confirm they match the implementation and tests
When doing any truly massive refactors, trend towards using simple event buses and job queues to break down systems into smaller services that each manage some isolated subcomponent of the state.
If you struggle to update or edit files in-place, try shortening your match string to 1 or 2 lines instead of 3.
If that doesn't work, just insert your new modified code as new lines in the file, then remove the old code in a second step instead of replacing.
## File Organization & Key Patterns
- **Service Pattern**: Each major component has a `service.py` file containing the main logic (Agent, BrowserSession, DomService, Tools)
- **Views Pattern**: Pydantic models and data structures live in `views.py` files
- **Events**: Event definitions in `events.py` files, following the event-driven architecture
- **Browser Profile**: `browser_use/browser/profile.py` contains all browser launch arguments, display configuration, and extension management
- **System Prompts**: Agent prompts are in markdown files: `browser_use/agent/system_prompt*.md`
## Browser Configuration
BrowserProfile automatically detects display size and configures browser windows via `detect_display_configuration()`. Key configurations:
- Display size detection for macOS (`AppKit.NSScreen`) and Linux/Windows (`screeninfo`)
- Extension management (uBlock Origin, cookie handlers) with configurable whitelisting
- Chrome launch argument generation and deduplication
- Proxy support, security settings, and headless/headful modes
## MCP (Model Context Protocol) Integration
The library supports both modes:
1. **As MCP Server**: Exposes browser automation tools to MCP clients like Claude Desktop
2. **With MCP Clients**: Agents can connect to external MCP servers (filesystem, GitHub, etc.) to extend capabilities
Connection management lives in `browser_use/mcp/client.py`.
## Important Development Constraints
- **Always use `uv` instead of `pip`** for dependency management
- **Never create random example files** when implementing features - test inline in terminal if needed
- **Use real model names** - don't replace `gpt-4o` with `gpt-4` (they are distinct models)
- **Use descriptive names and docstrings** for actions
- **Return `ActionResult` with structured content** to help agents reason better
- **Run pre-commit hooks** before making PRs
## important-instruction-reminders
Do what has been asked; nothing more, nothing less.
NEVER create files unless they're absolutely necessary for achieving your goal.
ALWAYS prefer editing an existing file to creating a new one.
NEVER proactively create documentation files (*.md) or README files. Only create documentation files if explicitly requested by the User.
@@ -0,0 +1,213 @@
# syntax=docker/dockerfile:1
# check=skip=SecretsUsedInArgOrEnv
# This is the Dockerfile for browser-use, it bundles the following dependencies:
# python3, pip, playwright, chromium, browser-use and its dependencies.
# Usage:
# git clone https://github.com/browser-use/browser-use.git && cd browser-use
# docker build . -t browseruse --no-cache
# docker run -v "$PWD/data":/data browseruse
# docker run -v "$PWD/data":/data browseruse --version
# Multi-arch build:
# docker buildx create --use
# docker buildx build . --platform=linux/amd64,linux/arm64--push -t browseruse/browseruse:some-tag
#
# Read more: https://docs.browser-use.com
#########################################################################################
FROM python:3.12-slim
LABEL name="browseruse" \
maintainer="Nick Sweeting <dockerfile@browser-use.com>" \
description="Make websites accessible for AI agents. Automate tasks online with ease." \
homepage="https://github.com/browser-use/browser-use" \
documentation="https://docs.browser-use.com" \
org.opencontainers.image.title="browseruse" \
org.opencontainers.image.vendor="browseruse" \
org.opencontainers.image.description="Make websites accessible for AI agents. Automate tasks online with ease." \
org.opencontainers.image.source="https://github.com/browser-use/browser-use" \
com.docker.image.source.entrypoint="Dockerfile" \
com.docker.desktop.extension.api.version=">= 1.4.7" \
com.docker.desktop.extension.icon="https://avatars.githubusercontent.com/u/192012301?s=200&v=4" \
com.docker.extension.publisher-url="https://browser-use.com" \
com.docker.extension.screenshots='[{"alt": "Screenshot of CLI splashscreen", "url": "https://github.com/user-attachments/assets/3606d851-deb1-439e-ad90-774e7960ded8"}, {"alt": "Screenshot of CLI running", "url": "https://github.com/user-attachments/assets/d018b115-95a4-4ac5-8259-b750bc5f56ad"}]' \
com.docker.extension.detailed-description='See here for detailed documentation: https://docs.browser-use.com' \
com.docker.extension.changelog='See here for release notes: https://github.com/browser-use/browser-use/releases' \
com.docker.extension.categories='web,utility-tools,ai'
ARG TARGETPLATFORM
ARG TARGETOS
ARG TARGETARCH
ARG TARGETVARIANT
######### Environment Variables #################################
# Global system-level config
ENV TZ=UTC \
LANGUAGE=en_US:en \
LC_ALL=C.UTF-8 \
LANG=C.UTF-8 \
DEBIAN_FRONTEND=noninteractive \
APT_KEY_DONT_WARN_ON_DANGEROUS_USAGE=1 \
PYTHONIOENCODING=UTF-8 \
PYTHONUNBUFFERED=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
UV_CACHE_DIR=/root/.cache/uv \
UV_LINK_MODE=copy \
UV_COMPILE_BYTECODE=1 \
UV_PYTHON_PREFERENCE=only-system \
npm_config_loglevel=error \
IN_DOCKER=True
# User config
ENV BROWSERUSE_USER="browseruse" \
DEFAULT_PUID=911 \
DEFAULT_PGID=911
# Paths
ENV CODE_DIR=/app \
DATA_DIR=/data \
VENV_DIR=/app/.venv \
PATH="/app/.venv/bin:$PATH"
# Build shell config
SHELL ["/bin/bash", "-o", "pipefail", "-o", "errexit", "-o", "errtrace", "-o", "nounset", "-c"]
# Force apt to leave downloaded binaries in /var/cache/apt (massively speeds up Docker builds)
RUN echo 'Binary::apt::APT::Keep-Downloaded-Packages "1";' > /etc/apt/apt.conf.d/99keep-cache \
&& echo 'APT::Install-Recommends "0";' > /etc/apt/apt.conf.d/99no-intall-recommends \
&& echo 'APT::Install-Suggests "0";' > /etc/apt/apt.conf.d/99no-intall-suggests \
&& rm -f /etc/apt/apt.conf.d/docker-clean
# Print debug info about build and save it to disk, for human eyes only, not used by anything else
RUN (echo "[i] Docker build for Browser Use $(cat /VERSION.txt) starting..." \
&& echo "PLATFORM=${TARGETPLATFORM} ARCH=$(uname -m) ($(uname -s) ${TARGETARCH} ${TARGETVARIANT})" \
&& echo "BUILD_START_TIME=$(date +"%Y-%m-%d %H:%M:%S %s") TZ=${TZ} LANG=${LANG}" \
&& echo \
&& echo "CODE_DIR=${CODE_DIR} DATA_DIR=${DATA_DIR} PATH=${PATH}" \
&& echo \
&& uname -a \
&& cat /etc/os-release | head -n7 \
&& which bash && bash --version | head -n1 \
&& which dpkg && dpkg --version | head -n1 \
&& echo -e '\n\n' && env && echo -e '\n\n' \
&& which python && python --version \
&& which pip && pip --version \
&& echo -e '\n\n' \
) | tee -a /VERSION.txt
# Create non-privileged user for browseruse and chrome
RUN echo "[*] Setting up $BROWSERUSE_USER user uid=${DEFAULT_PUID}..." \
&& groupadd --system $BROWSERUSE_USER \
&& useradd --system --create-home --gid $BROWSERUSE_USER --groups audio,video $BROWSERUSE_USER \
&& usermod -u "$DEFAULT_PUID" "$BROWSERUSE_USER" \
&& groupmod -g "$DEFAULT_PGID" "$BROWSERUSE_USER" \
&& mkdir -p /data \
&& mkdir -p /home/$BROWSERUSE_USER/.config \
&& chown -R $BROWSERUSE_USER:$BROWSERUSE_USER /home/$BROWSERUSE_USER \
&& ln -s $DATA_DIR /home/$BROWSERUSE_USER/.config/browseruse \
&& echo -e "\nBROWSERUSE_USER=$BROWSERUSE_USER PUID=$(id -u $BROWSERUSE_USER) PGID=$(id -g $BROWSERUSE_USER)\n\n" \
| tee -a /VERSION.txt
# DEFAULT_PUID and DEFAULT_PID are overridden by PUID and PGID in /bin/docker_entrypoint.sh at runtime
# https://docs.linuxserver.io/general/understanding-puid-and-pgid
# Install base apt dependencies (adding backports to access more recent apt updates)
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked,id=apt-$TARGETARCH$TARGETVARIANT \
echo "[+] Installing APT base system dependencies for $TARGETPLATFORM..." \
# && echo 'deb https://deb.debian.org/debian bookworm-backports main contrib non-free' > /etc/apt/sources.list.d/backports.list \
&& mkdir -p /etc/apt/keyrings \
&& apt-get update -qq \
&& apt-get install -qq -y --no-install-recommends \
# 1. packaging dependencies
apt-transport-https ca-certificates apt-utils gnupg2 unzip curl wget grep \
# 2. docker and init system dependencies:
# dumb-init gosu cron zlib1g-dev \
# 3. frivolous CLI helpers to make debugging failed archiving easierL
nano iputils-ping dnsutils jq \
# tree yq procps \
# 4. browser dependencies: (auto-installed by playwright install --with-deps chromium)
# libnss3 libxss1 libasound2 libx11-xcb1 \
# fontconfig fonts-ipafont-gothic fonts-wqy-zenhei fonts-thai-tlwg fonts-khmeros fonts-kacst fonts-symbola fonts-noto fonts-freefont-ttf \
# at-spi2-common fonts-liberation fonts-noto-color-emoji fonts-tlwg-loma-otf fonts-unifont libatk-bridge2.0-0 libatk1.0-0 libatspi2.0-0 libavahi-client3 \
# libavahi-common-data libavahi-common3 libcups2 libfontenc1 libice6 libnspr4 libnss3 libsm6 libunwind8 \
# libxaw7 libxcomposite1 libxdamage1 libxfont2 \
# # 5. x11/xvfb dependencies:
# libxkbfile1 libxmu6 libxpm4 libxt6 x11-xkb-utils x11-utils xfonts-encodings \
# xfonts-scalable xfonts-utils xserver-common xvfb \
&& rm -rf /var/lib/apt/lists/*
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
# Copy only dependency manifest
WORKDIR /app
COPY pyproject.toml uv.lock* /app/
RUN --mount=type=cache,target=/root/.cache,sharing=locked,id=cache-$TARGETARCH$TARGETVARIANT \
echo "[+] Setting up venv using uv in $VENV_DIR..." \
&& ( \
which uv && uv --version \
&& uv venv \
&& which python | grep "$VENV_DIR" \
&& python --version \
) | tee -a /VERSION.txt
# Install Chromium browser directly from system packages
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked,id=apt-$TARGETARCH$TARGETVARIANT \
echo "[+] Installing chromium browser from system packages..." \
&& apt-get update -qq \
&& apt-get install -y --no-install-recommends \
chromium \
fonts-unifont \
fonts-liberation \
fonts-dejavu-core \
fonts-freefont-ttf \
fonts-noto-core \
&& rm -rf /var/lib/apt/lists/* \
&& ln -s /usr/bin/chromium /usr/bin/chromium-browser \
&& ln -s /usr/bin/chromium /app/chromium-browser \
&& mkdir -p "/home/${BROWSERUSE_USER}/.config/chromium/Crash Reports/pending/" \
&& chown -R "$BROWSERUSE_USER:$BROWSERUSE_USER" "/home/${BROWSERUSE_USER}/.config" \
&& ( \
which chromium-browser && /usr/bin/chromium-browser --version \
&& echo -e '\n\n' \
) | tee -a /VERSION.txt
RUN --mount=type=cache,target=/root/.cache,sharing=locked,id=cache-$TARGETARCH$TARGETVARIANT \
echo "[+] Installing browser-use pip sub-dependencies..." \
&& ( \
uv sync --all-extras --no-dev --no-install-project \
&& echo -e '\n\n' \
) | tee -a /VERSION.txt
# Copy the rest of the browser-use codebase
COPY . /app
# Install the browser-use package and all of its optional dependencies
RUN --mount=type=cache,target=/root/.cache,sharing=locked,id=cache-$TARGETARCH$TARGETVARIANT \
echo "[+] Installing browser-use pip library from source..." \
&& ( \
uv sync --all-extras --locked --no-dev \
&& python -c "import browser_use; print('browser-use installed successfully')" \
&& echo -e '\n\n' \
) | tee -a /VERSION.txt
RUN mkdir -p "$DATA_DIR/profiles/default" \
&& chown -R $BROWSERUSE_USER:$BROWSERUSE_USER "$DATA_DIR" "$DATA_DIR"/* \
&& ( \
echo -e "\n\n[√] Finished Docker build successfully. Saving build summary in: /VERSION.txt" \
&& echo -e "PLATFORM=${TARGETPLATFORM} ARCH=$(uname -m) ($(uname -s) ${TARGETARCH} ${TARGETVARIANT})\n" \
&& echo -e "BUILD_END_TIME=$(date +"%Y-%m-%d %H:%M:%S %s")\n\n" \
) | tee -a /VERSION.txt
USER "$BROWSERUSE_USER"
VOLUME "$DATA_DIR"
EXPOSE 9242
EXPOSE 9222
# HEALTHCHECK --interval=30s --timeout=20s --retries=15 \
# CMD curl --silent 'http://localhost:8000/health/' | grep -q 'OK'
ENTRYPOINT ["browser-use"]
@@ -0,0 +1,31 @@
# Fast Dockerfile using pre-built base images
ARG REGISTRY=browseruse
ARG BASE_TAG=latest
FROM ${REGISTRY}/base-python-deps:${BASE_TAG}
LABEL name="browseruse" description="Browser automation for AI agents"
ENV BROWSERUSE_USER="browseruse" DEFAULT_PUID=911 DEFAULT_PGID=911 DATA_DIR=/data
# Create user and directories
RUN groupadd --system $BROWSERUSE_USER && \
useradd --system --create-home --gid $BROWSERUSE_USER --groups audio,video $BROWSERUSE_USER && \
usermod -u "$DEFAULT_PUID" "$BROWSERUSE_USER" && \
groupmod -g "$DEFAULT_PGID" "$BROWSERUSE_USER" && \
mkdir -p /data /home/$BROWSERUSE_USER/.config && \
ln -s $DATA_DIR /home/$BROWSERUSE_USER/.config/browseruse && \
mkdir -p "/home/$BROWSERUSE_USER/.config/chromium/Crash Reports/pending/" && \
mkdir -p "$DATA_DIR/profiles/default" && \
chown -R "$BROWSERUSE_USER:$BROWSERUSE_USER" "/home/$BROWSERUSE_USER" "$DATA_DIR"
WORKDIR /app
COPY . /app
# Install browser-use
RUN --mount=type=cache,target=/root/.cache/uv,sharing=locked \
uv sync --all-extras --locked --no-dev --compile-bytecode
USER "$BROWSERUSE_USER"
VOLUME "$DATA_DIR"
EXPOSE 9242 9222
ENTRYPOINT ["browser-use"]
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 Gregor Zunic
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,122 @@
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./static/browser-use-dark.png">
<source media="(prefers-color-scheme: light)" srcset="./static/browser-use.png">
<img alt="Shows a black Browser Use Logo in light color mode and a white one in dark color mode." src="./static/browser-use.png" width="full">
</picture>
<h1 align="center">Enable AI to control your browser</h1>
[![Docs](https://img.shields.io/badge/Docs-📕-blue?style=for-the-badge)](https://docs.browser-use.com)
[![Browser-use cloud](https://img.shields.io/badge/Browser_Use_Cloud-☁️-blue?style=for-the-badge&logo=rocket&logoColor=white)](https://cloud.browser-use.com)
[![Discord](https://img.shields.io/discord/1303749220842340412?color=7289DA&label=Discord&logo=discord&logoColor=white)](https://link.browser-use.com/discord)
[![Twitter Follow](https://img.shields.io/twitter/follow/Gregor?style=social)](https://x.com/intent/user?screen_name=gregpr07)
[![Twitter Follow](https://img.shields.io/twitter/follow/Magnus?style=social)](https://x.com/intent/user?screen_name=mamagnus00)
[![Merch store](https://img.shields.io/badge/Merch_store-👕-blue)](https://browsermerch.com)
[![Weave Badge](https://img.shields.io/endpoint?url=https%3A%2F%2Fapp.workweave.ai%2Fapi%2Frepository%2Fbadge%2Forg_T5Pvn3UBswTHIsN1dWS3voPg%2F881458615&labelColor=#EC6341)](https://app.workweave.ai/reports/repository/org_T5Pvn3UBswTHIsN1dWS3voPg/881458615)
<!-- Keep these links. Translations will automatically update with the README. -->
[Deutsch](https://www.readme-i18n.com/browser-use/browser-use?lang=de) |
[Español](https://www.readme-i18n.com/browser-use/browser-use?lang=es) |
[français](https://www.readme-i18n.com/browser-use/browser-use?lang=fr) |
[日本語](https://www.readme-i18n.com/browser-use/browser-use?lang=ja) |
[한국어](https://www.readme-i18n.com/browser-use/browser-use?lang=ko) |
[Português](https://www.readme-i18n.com/browser-use/browser-use?lang=pt) |
[Русский](https://www.readme-i18n.com/browser-use/browser-use?lang=ru) |
[中文](https://www.readme-i18n.com/browser-use/browser-use?lang=zh)
# 🤖 Quickstart
With uv (Python>=3.11):
```bash
# We ship every day - use the latest version!
uv pip install browser-use
```
Download chromium using playwright's shortcut:
```bash
uvx playwright install chromium --with-deps --no-shell
```
Create a `.env` file and add your API key. Don't have one? Start with a [free Gemini key](https://aistudio.google.com/app/u/1/apikey?pli=1).
```bash
GEMINI_API_KEY=
```
Run your first agent:
```python
from browser_use import Agent, ChatGoogle
from dotenv import load_dotenv
load_dotenv()
agent = Agent(
task="Find the number of stars of the browser-use repo",
llm=ChatGoogle(model="gemini-2.5-flash"),
# browser=Browser(use_cloud=True), # Uses Browser-Use cloud for the browser
)
agent.run_sync()
```
Check out the [library docs](https://docs.browser-use.com) and [cloud docs](https://docs.cloud.browser-use.com) for more settings.
# Demos
[Task](https://github.com/browser-use/browser-use/blob/main/examples/use-cases/shopping.py): Add grocery items to cart, and checkout.
[![AI Did My Groceries](https://github.com/user-attachments/assets/a0ffd23d-9a11-4368-8893-b092703abc14)](https://www.youtube.com/watch?v=L2Ya9PYNns8)
<br/><br/>
[Task](https://github.com/browser-use/browser-use/blob/main/examples/use-cases/find_and_apply_to_jobs.py): Read my CV & find ML jobs, save them to a file, and then start applying for them in new tabs, if you need help, ask me.
https://github.com/user-attachments/assets/171fb4d6-0355-46f2-863e-edb04a828d04
<br/><br/>
See [more examples](https://docs.browser-use.com/examples) and give us a star!
<br/><br/>
## MCP Integration
This gives Claude Desktop access to browser automation tools for web scraping, form filling, and more. See the [MCP docs](https://docs.browser-use.com/customize/mcp-server).
```json
{
"mcpServers": {
"browser-use": {
"command": "uvx",
"args": ["browser-use[cli]", "--mcp"],
"env": {
"OPENAI_API_KEY": "your-openai-api-key"
}
}
}
}
```
<div align="center">
**Tell your computer what to do, and it gets it done.**
<img src="https://github.com/user-attachments/assets/06fa3078-8461-4560-b434-445510c1766f" width="400"/>
[![Twitter Follow](https://img.shields.io/twitter/follow/Magnus?style=social)](https://x.com/intent/user?screen_name=mamagnus00)
[![Twitter Follow](https://img.shields.io/twitter/follow/Gregor?style=social)](https://x.com/intent/user?screen_name=gregpr07)
</div>
<div align="center">
Made with ❤️ in Zurich and San Francisco
</div>
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env bash
# This script is used to run the formatter, linter, and type checker pre-commit hooks.
# Usage:
# $ ./bin/lint.sh
IFS=$'\n'
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )"
cd "$SCRIPT_DIR/.." || exit 1
echo "[*] Running ruff linter, formatter, pyright type checker, and other pre-commit checks..."
exec uv run pre-commit run --all-files
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env bash
# This script is used to setup a local development environment for the browser-use project.
# Usage:
# $ ./bin/setup.sh
### Bash Environment Setup
# http://redsymbol.net/articles/unofficial-bash-strict-mode/
# https://www.gnu.org/software/bash/manual/html_node/The-Set-Builtin.html
# set -o xtrace
# set -x
# shopt -s nullglob
set -o errexit
set -o errtrace
set -o nounset
set -o pipefail
IFS=$'\n'
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )"
cd "$SCRIPT_DIR"
if [ -f "$SCRIPT_DIR/lint.sh" ]; then
echo "[√] already inside a cloned browser-use repo"
else
echo "[+] Cloning browser-use repo into current directory: $SCRIPT_DIR"
git clone https://github.com/browser-use/browser-use
cd browser-use
fi
echo "[+] Installing uv..."
curl -LsSf https://astral.sh/uv/install.sh | sh
#git checkout main git pull
echo
echo "[+] Setting up venv"
uv venv
echo
echo "[+] Installing packages in venv"
uv sync --dev --all-extras
echo
echo "[i] Tip: make sure to set BROWSER_USE_LOGGING_LEVEL=debug and your LLM API keys in your .env file"
echo
uv pip show browser-use
echo "Usage:"
echo " $ browser-use use the CLI"
echo " or"
echo " $ source .venv/bin/activate"
echo " $ ipython use the library"
echo " >>> from browser_use import BrowserSession, Agent"
echo " >>> await Agent(task='book me a flight to fiji', browser=BrowserSession(headless=False)).run()"
echo ""
+9
View File
@@ -0,0 +1,9 @@
#!/usr/bin/env bash
# This script is used to run all the main project tests that run on CI via .github/workflows/test.yaml.
# Usage:
# $ ./bin/test.sh
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )"
cd "$SCRIPT_DIR/.." || exit 1
exec uv run pytest --numprocesses auto tests/ci $1 $2 $3
@@ -0,0 +1,51 @@
# Codebase Structure
> The code structure inspired by https://github.com/Netflix/dispatch.
Very good structure on how to make a scalable codebase is also in [this repo](https://github.com/zhanymkanov/fastapi-best-practices).
Just a brief document about how we should structure our backend codebase.
## Code Structure
```markdown
src/
/<service name>/
models.py
services.py
prompts.py
views.py
utils.py
routers.py
/_<subservice name>/
```
### Service.py
Always a single file, except if it becomes too long - more than ~500 lines, split it into \_subservices
### Views.py
Always split the views into two parts
```python
# All
...
# Requests
...
# Responses
...
```
If too long → split into multiple files
### Prompts.py
Single file; if too long → split into multiple files (one prompt per file or so)
### Routers.py
Never split into more than one file
@@ -0,0 +1,138 @@
import os
from typing import TYPE_CHECKING
from browser_use.logging_config import setup_logging
# Only set up logging if not in MCP mode or if explicitly requested
if os.environ.get('BROWSER_USE_SETUP_LOGGING', 'true').lower() != 'false':
from browser_use.config import CONFIG
# Get log file paths from config/environment
debug_log_file = getattr(CONFIG, 'BROWSER_USE_DEBUG_LOG_FILE', None)
info_log_file = getattr(CONFIG, 'BROWSER_USE_INFO_LOG_FILE', None)
# Set up logging with file handlers if specified
logger = setup_logging(debug_log_file=debug_log_file, info_log_file=info_log_file)
else:
import logging
logger = logging.getLogger('browser_use')
# Monkeypatch BaseSubprocessTransport.__del__ to handle closed event loops gracefully
from asyncio import base_subprocess
_original_del = base_subprocess.BaseSubprocessTransport.__del__
def _patched_del(self):
"""Patched __del__ that handles closed event loops without throwing noisy red-herring errors like RuntimeError: Event loop is closed"""
try:
# Check if the event loop is closed before calling the original
if hasattr(self, '_loop') and self._loop and self._loop.is_closed():
# Event loop is closed, skip cleanup that requires the loop
return
_original_del(self)
except RuntimeError as e:
if 'Event loop is closed' in str(e):
# Silently ignore this specific error
pass
else:
raise
base_subprocess.BaseSubprocessTransport.__del__ = _patched_del
# Type stubs for lazy imports - fixes linter warnings
if TYPE_CHECKING:
from browser_use.agent.prompts import SystemPrompt
from browser_use.agent.service import Agent
from browser_use.agent.views import ActionModel, ActionResult, AgentHistoryList
from browser_use.browser import BrowserProfile, BrowserSession
from browser_use.browser import BrowserSession as Browser
from browser_use.dom.service import DomService
from browser_use.llm import models
from browser_use.llm.anthropic.chat import ChatAnthropic
from browser_use.llm.azure.chat import ChatAzureOpenAI
from browser_use.llm.google.chat import ChatGoogle
from browser_use.llm.groq.chat import ChatGroq
from browser_use.llm.ollama.chat import ChatOllama
from browser_use.llm.openai.chat import ChatOpenAI
from browser_use.tools.service import Controller, Tools
# Lazy imports mapping - only import when actually accessed
_LAZY_IMPORTS = {
# Agent service (heavy due to dependencies)
'Agent': ('browser_use.agent.service', 'Agent'),
# System prompt (moderate weight due to agent.views imports)
'SystemPrompt': ('browser_use.agent.prompts', 'SystemPrompt'),
# Agent views (very heavy - over 1 second!)
'ActionModel': ('browser_use.agent.views', 'ActionModel'),
'ActionResult': ('browser_use.agent.views', 'ActionResult'),
'AgentHistoryList': ('browser_use.agent.views', 'AgentHistoryList'),
'BrowserSession': ('browser_use.browser', 'BrowserSession'),
'Browser': ('browser_use.browser', 'BrowserSession'), # Alias for BrowserSession
'BrowserProfile': ('browser_use.browser', 'BrowserProfile'),
# Tools (moderate weight)
'Tools': ('browser_use.tools.service', 'Tools'),
'Controller': ('browser_use.tools.service', 'Controller'), # alias
# DOM service (moderate weight)
'DomService': ('browser_use.dom.service', 'DomService'),
# Chat models (very heavy imports)
'ChatOpenAI': ('browser_use.llm.openai.chat', 'ChatOpenAI'),
'ChatGoogle': ('browser_use.llm.google.chat', 'ChatGoogle'),
'ChatAnthropic': ('browser_use.llm.anthropic.chat', 'ChatAnthropic'),
'ChatGroq': ('browser_use.llm.groq.chat', 'ChatGroq'),
'ChatAzureOpenAI': ('browser_use.llm.azure.chat', 'ChatAzureOpenAI'),
'ChatOllama': ('browser_use.llm.ollama.chat', 'ChatOllama'),
# LLM models module
'models': ('browser_use.llm.models', None),
}
def __getattr__(name: str):
"""Lazy import mechanism - only import modules when they're actually accessed."""
if name in _LAZY_IMPORTS:
module_path, attr_name = _LAZY_IMPORTS[name]
try:
from importlib import import_module
module = import_module(module_path)
if attr_name is None:
# For modules like 'models', return the module itself
attr = module
else:
attr = getattr(module, attr_name)
# Cache the imported attribute in the module's globals
globals()[name] = attr
return attr
except ImportError as e:
raise ImportError(f'Failed to import {name} from {module_path}: {e}') from e
raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
__all__ = [
'Agent',
'BrowserSession',
'Browser', # Alias for BrowserSession
'BrowserProfile',
'Controller',
'DomService',
'SystemPrompt',
'ActionResult',
'ActionModel',
'AgentHistoryList',
# Chat models
'ChatOpenAI',
'ChatGoogle',
'ChatAnthropic',
'ChatGroq',
'ChatAzureOpenAI',
'ChatOllama',
'Tools',
'Controller',
# LLM models module
'models',
]
@@ -0,0 +1,244 @@
# Browser Actor
Browser Actor is a web automation library built on CDP (Chrome DevTools Protocol) that provides low-level browser automation capabilities within the browser-use ecosystem.
## Usage
### Integrated with Browser (Recommended)
```python
from browser_use import Browser # Alias for BrowserSession
# Create and start browser session
browser = Browser()
await browser.start()
# Create new tabs and navigate
page = await browser.new_page("https://example.com")
pages = await browser.get_pages()
current_page = await browser.get_current_page()
```
### Direct Page Access (Advanced)
```python
from browser_use.actor import Page, Element, Mouse
# Create page with existing browser session
page = Page(browser_session, target_id, session_id)
```
## Basic Operations
```python
# Tab Management
page = await browser.new_page() # Create blank tab
page = await browser.new_page("https://example.com") # Create tab with URL
pages = await browser.get_pages() # Get all existing tabs
await browser.close_page(page) # Close specific tab
# Navigation
await page.goto("https://example.com")
await page.go_back()
await page.go_forward()
await page.reload()
```
## Element Operations
```python
# Find elements by CSS selector
elements = await page.get_elements_by_css_selector("input[type='text']")
buttons = await page.get_elements_by_css_selector("button.submit")
# Get element by backend node ID
element = await page.get_element(backend_node_id=12345)
# AI-powered element finding (requires LLM)
element = await page.get_element_by_prompt("search button", llm=your_llm)
element = await page.must_get_element_by_prompt("login form", llm=your_llm)
```
> **Note**: `get_elements_by_css_selector` returns immediately without waiting for visibility.
## Element Interactions
```python
# Element actions
await element.click(button='left', click_count=1, modifiers=['Control'])
await element.fill("Hello World") # Clears first, then types
await element.hover()
await element.focus()
await element.check() # Toggle checkbox/radio
await element.select_option(["option1", "option2"]) # For dropdown/select
await element.drag_to(target_element) # Drag and drop
# Element properties
value = await element.get_attribute("value")
box = await element.get_bounding_box() # Returns BoundingBox or None
info = await element.get_basic_info() # Comprehensive element info
screenshot_b64 = await element.screenshot(format='jpeg')
```
## Mouse Operations
```python
# Mouse operations
mouse = await page.mouse
await mouse.click(x=100, y=200, button='left', click_count=1)
await mouse.move(x=300, y=400, steps=1)
await mouse.down(button='left') # Press button
await mouse.up(button='left') # Release button
await mouse.scroll(x=0, y=100, delta_x=0, delta_y=-500) # Scroll at coordinates
```
## Page Operations
```python
# JavaScript evaluation
result = await page.evaluate('() => document.title') # Must use arrow function format
result = await page.evaluate('(x, y) => x + y', 10, 20) # With arguments
# Keyboard input
await page.press("Control+A") # Key combinations supported
await page.press("Escape") # Single keys
# Page controls
await page.set_viewport_size(width=1920, height=1080)
page_screenshot = await page.screenshot() # JPEG by default
page_png = await page.screenshot(format="png", quality=90)
# Page information
url = await page.get_url()
title = await page.get_title()
```
## AI-Powered Features
```python
# Content extraction using LLM
from pydantic import BaseModel
class ProductInfo(BaseModel):
name: str
price: float
description: str
# Extract structured data from current page
products = await page.extract_content(
"Find all products with their names, prices and descriptions",
ProductInfo,
llm=your_llm
)
```
## Core Classes
- **BrowserSession** (aliased as **Browser**): Main browser session manager with tab operations
- **Page**: Represents a single browser tab or iframe for page-level operations
- **Element**: Individual DOM element for interactions and property access
- **Mouse**: Mouse operations within a page (click, move, scroll)
## API Reference
### BrowserSession Methods (Tab Management)
- `start()` - Initialize and start the browser session
- `stop()` - Stop the browser session (keeps browser alive)
- `kill()` - Kill the browser process and reset all state
- `new_page(url=None)``Page` - Create blank tab or navigate to URL
- `get_pages()``list[Page]` - Get all available pages
- `get_current_page()``Page | None` - Get the currently focused page
- `close_page(page: Page | str)` - Close page by object or ID
- Session management and CDP client operations
### Page Methods (Page Operations)
- `get_elements_by_css_selector(selector: str)``list[Element]` - Find elements by CSS selector
- `get_element(backend_node_id: int)``Element` - Get element by backend node ID
- `get_element_by_prompt(prompt: str, llm)``Element | None` - AI-powered element finding
- `must_get_element_by_prompt(prompt: str, llm)``Element` - AI element finding (raises if not found)
- `extract_content(prompt: str, structured_output: type[T], llm)``T` - Extract structured data using LLM
- `goto(url: str)` - Navigate this page to URL
- `go_back()`, `go_forward()` - Navigate history (with error handling)
- `reload()` - Reload the current page
- `evaluate(page_function: str, *args)``str` - Execute JavaScript (MUST use (...args) => format)
- `press(key: str)` - Press key on page (supports "Control+A" format)
- `set_viewport_size(width: int, height: int)` - Set viewport dimensions
- `screenshot(format='jpeg', quality=None)``str` - Take page screenshot, return base64
- `get_url()``str`, `get_title()``str` - Get page information
- `mouse``Mouse` - Get mouse interface for this page
### Element Methods (DOM Interactions)
- `click(button='left', click_count=1, modifiers=None)` - Click element with advanced fallbacks
- `fill(text: str, clear_existing=True)` - Fill input with text (clears first by default)
- `hover()` - Hover over element
- `focus()` - Focus the element
- `check()` - Toggle checkbox/radio button (clicks to change state)
- `select_option(values: str | list[str])` - Select dropdown options
- `drag_to(target_element: Element | Position, source_position=None, target_position=None)` - Drag to target element
- `get_attribute(name: str)``str | None` - Get attribute value
- `get_bounding_box()``BoundingBox | None` - Get element position/size
- `screenshot(format='jpeg', quality=None)``str` - Take element screenshot, return base64
- `get_basic_info()``ElementInfo` - Get comprehensive element information
### Mouse Methods (Coordinate-Based Operations)
- `click(x: int, y: int, button='left', click_count=1)` - Click at coordinates
- `move(x: int, y: int, steps=1)` - Move to coordinates
- `down(button='left', click_count=1)`, `up(button='left', click_count=1)` - Press/release button
- `scroll(x=0, y=0, delta_x=None, delta_y=None)` - Scroll page at coordinates
## Type Definitions
### Position
```python
class Position(TypedDict):
x: float
y: float
```
### BoundingBox
```python
class BoundingBox(TypedDict):
x: float
y: float
width: float
height: float
```
### ElementInfo
```python
class ElementInfo(TypedDict):
backendNodeId: int # CDP backend node ID
nodeId: int | None # CDP node ID
nodeName: str # HTML tag name (e.g., "DIV", "INPUT")
nodeType: int # DOM node type
nodeValue: str | None # Text content for text nodes
attributes: dict[str, str] # HTML attributes
boundingBox: BoundingBox | None # Element position and size
error: str | None # Error message if info retrieval failed
```
## Important Usage Notes
**This is browser-use actor, NOT Playwright or Selenium.** Only use the methods documented above.
### Critical JavaScript Rules
- `page.evaluate()` MUST use `(...args) => {}` arrow function format
- Always returns string (objects are JSON-stringified automatically)
- Use single quotes around the function: `page.evaluate('() => document.title')`
- For complex selectors in JS: `'() => document.querySelector("input[name=\\"email\\"]")'`
### Method Restrictions
- `get_elements_by_css_selector()` returns immediately (no automatic waiting)
- For dropdowns: use `element.select_option()`, NOT `element.fill()`
- Form submission: click submit button or use `page.press("Enter")`
- No methods like: `element.submit()`, `element.dispatch_event()`, `element.get_property()`
### Error Prevention
- Always verify page state changes with `page.get_url()`, `page.get_title()`
- Use `element.get_attribute()` to check element properties
- Validate CSS selectors before use
- Handle navigation timing with appropriate `asyncio.sleep()` calls
### AI Features
- `get_element_by_prompt()` and `extract_content()` require an LLM instance
- These methods use DOM analysis and structured output parsing
- Best for complex page understanding and data extraction tasks
@@ -0,0 +1,10 @@
"""CDP-Use High-Level Library
A Playwright-like library built on top of CDP (Chrome DevTools Protocol).
"""
from .element import Element
from .mouse import Mouse
from .page import Page
__all__ = ['Page', 'Element', 'Mouse']
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,141 @@
"""Mouse class for mouse operations."""
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from cdp_use.cdp.input.commands import DispatchMouseEventParameters, SynthesizeScrollGestureParameters
from cdp_use.cdp.input.types import MouseButton
from browser_use.browser.session import BrowserSession
class Mouse:
"""Mouse operations for a target."""
def __init__(self, browser_session: 'BrowserSession', session_id: str | None = None, target_id: str | None = None):
self._browser_session = browser_session
self._client = browser_session.cdp_client
self._session_id = session_id
self._target_id = target_id
async def click(self, x: int, y: int, button: 'MouseButton' = 'left', click_count: int = 1) -> None:
"""Click at the specified coordinates."""
# Mouse press
press_params: 'DispatchMouseEventParameters' = {
'type': 'mousePressed',
'x': x,
'y': y,
'button': button,
'clickCount': click_count,
}
await self._client.send.Input.dispatchMouseEvent(
press_params,
session_id=self._session_id,
)
# Mouse release
release_params: 'DispatchMouseEventParameters' = {
'type': 'mouseReleased',
'x': x,
'y': y,
'button': button,
'clickCount': click_count,
}
await self._client.send.Input.dispatchMouseEvent(
release_params,
session_id=self._session_id,
)
async def down(self, button: 'MouseButton' = 'left', click_count: int = 1) -> None:
"""Press mouse button down."""
params: 'DispatchMouseEventParameters' = {
'type': 'mousePressed',
'x': 0, # Will use last mouse position
'y': 0,
'button': button,
'clickCount': click_count,
}
await self._client.send.Input.dispatchMouseEvent(
params,
session_id=self._session_id,
)
async def up(self, button: 'MouseButton' = 'left', click_count: int = 1) -> None:
"""Release mouse button."""
params: 'DispatchMouseEventParameters' = {
'type': 'mouseReleased',
'x': 0, # Will use last mouse position
'y': 0,
'button': button,
'clickCount': click_count,
}
await self._client.send.Input.dispatchMouseEvent(
params,
session_id=self._session_id,
)
async def move(self, x: int, y: int, steps: int = 1) -> None:
"""Move mouse to the specified coordinates."""
# TODO: Implement smooth movement with multiple steps if needed
_ = steps # Acknowledge parameter for future use
params: 'DispatchMouseEventParameters' = {'type': 'mouseMoved', 'x': x, 'y': y}
await self._client.send.Input.dispatchMouseEvent(params, session_id=self._session_id)
async def scroll(self, x: int = 0, y: int = 0, delta_x: int | None = None, delta_y: int | None = None) -> None:
"""Scroll the page using robust CDP methods."""
if not self._session_id:
raise RuntimeError('Session ID is required for scroll operations')
# Activate the target first (critical for CDP calls to work)
if self._target_id:
try:
await self._client.send.Target.activateTarget(params={'targetId': self._target_id})
except Exception:
pass
# Method 1: Try mouse wheel event (most reliable)
try:
# Get viewport dimensions
layout_metrics = await self._client.send.Page.getLayoutMetrics(session_id=self._session_id)
viewport_width = layout_metrics['layoutViewport']['clientWidth']
viewport_height = layout_metrics['layoutViewport']['clientHeight']
# Use provided coordinates or center of viewport
scroll_x = x if x > 0 else viewport_width / 2
scroll_y = y if y > 0 else viewport_height / 2
# Calculate scroll deltas (positive = down/right)
scroll_delta_x = delta_x or 0
scroll_delta_y = delta_y or 0
# Dispatch mouse wheel event
await self._client.send.Input.dispatchMouseEvent(
params={
'type': 'mouseWheel',
'x': scroll_x,
'y': scroll_y,
'deltaX': scroll_delta_x,
'deltaY': scroll_delta_y,
},
session_id=self._session_id,
)
return
except Exception:
pass
# Method 2: Fallback to synthesizeScrollGesture
try:
params: 'SynthesizeScrollGestureParameters' = {'x': x, 'y': y, 'xDistance': delta_x or 0, 'yDistance': delta_y or 0}
await self._client.send.Input.synthesizeScrollGesture(
params,
session_id=self._session_id,
)
except Exception:
# Method 3: JavaScript fallback
scroll_js = f'window.scrollBy({delta_x or 0}, {delta_y or 0})'
await self._client.send.Runtime.evaluate(
params={'expression': scroll_js, 'returnByValue': True},
session_id=self._session_id,
)
@@ -0,0 +1,554 @@
"""Page class for page-level operations."""
from typing import TYPE_CHECKING, TypeVar
from pydantic import BaseModel
from browser_use.dom.serializer.serializer import DOMTreeSerializer
from browser_use.dom.service import DomService
from browser_use.llm.messages import SystemMessage, UserMessage
T = TypeVar('T', bound=BaseModel)
if TYPE_CHECKING:
from cdp_use.cdp.dom.commands import (
DescribeNodeParameters,
QuerySelectorAllParameters,
)
from cdp_use.cdp.emulation.commands import SetDeviceMetricsOverrideParameters
from cdp_use.cdp.input.commands import (
DispatchKeyEventParameters,
)
from cdp_use.cdp.page.commands import CaptureScreenshotParameters, NavigateParameters, NavigateToHistoryEntryParameters
from cdp_use.cdp.runtime.commands import EvaluateParameters
from cdp_use.cdp.target.commands import (
AttachToTargetParameters,
GetTargetInfoParameters,
)
from cdp_use.cdp.target.types import TargetInfo
from browser_use.browser.session import BrowserSession
from browser_use.llm.base import BaseChatModel
from .element import Element
from .mouse import Mouse
class Page:
"""Page operations (tab or iframe)."""
def __init__(
self, browser_session: 'BrowserSession', target_id: str, session_id: str | None = None, llm: 'BaseChatModel | None' = None
):
self._browser_session = browser_session
self._client = browser_session.cdp_client
self._target_id = target_id
self._session_id: str | None = session_id
self._mouse: 'Mouse | None' = None
self._llm = llm
async def _ensure_session(self) -> str:
"""Ensure we have a session ID for this target."""
if not self._session_id:
params: 'AttachToTargetParameters' = {'targetId': self._target_id, 'flatten': True}
result = await self._client.send.Target.attachToTarget(params)
self._session_id = result['sessionId']
# Enable necessary domains
import asyncio
await asyncio.gather(
self._client.send.Page.enable(session_id=self._session_id),
self._client.send.DOM.enable(session_id=self._session_id),
self._client.send.Runtime.enable(session_id=self._session_id),
self._client.send.Network.enable(session_id=self._session_id),
)
return self._session_id
@property
async def session_id(self) -> str:
"""Get the session ID for this target.
@dev Pass this to an arbitrary CDP call
"""
return await self._ensure_session()
@property
async def mouse(self) -> 'Mouse':
"""Get the mouse interface for this target."""
if not self._mouse:
session_id = await self._ensure_session()
from .mouse import Mouse
self._mouse = Mouse(self._browser_session, session_id, self._target_id)
return self._mouse
async def reload(self) -> None:
"""Reload the target."""
session_id = await self._ensure_session()
await self._client.send.Page.reload(session_id=session_id)
async def get_element(self, backend_node_id: int) -> 'Element':
"""Get an element by its backend node ID."""
session_id = await self._ensure_session()
from .element import Element as Element_
return Element_(self._browser_session, backend_node_id, session_id)
async def evaluate(self, page_function: str, *args) -> str:
"""Execute JavaScript in the target.
Args:
page_function: JavaScript code that MUST start with (...args) => format
*args: Arguments to pass to the function
Returns:
String representation of the JavaScript execution result.
Objects and arrays are JSON-stringified.
"""
session_id = await self._ensure_session()
# Clean and fix common JavaScript string parsing issues
page_function = self._fix_javascript_string(page_function)
# Enforce arrow function format
if not (page_function.startswith('(') and '=>' in page_function):
raise ValueError(f'JavaScript code must start with (...args) => format. Got: {page_function[:50]}...')
# Build the expression - call the arrow function with provided args
if args:
# Convert args to JSON representation for safe passing
import json
arg_strs = [json.dumps(arg) for arg in args]
expression = f'({page_function})({", ".join(arg_strs)})'
else:
expression = f'({page_function})()'
# Debug: print the actual expression being evaluated
print(f'DEBUG: Evaluating JavaScript: {repr(expression)}')
params: 'EvaluateParameters' = {'expression': expression, 'returnByValue': True, 'awaitPromise': True}
result = await self._client.send.Runtime.evaluate(
params,
session_id=session_id,
)
if 'exceptionDetails' in result:
raise RuntimeError(f'JavaScript evaluation failed: {result["exceptionDetails"]}')
value = result.get('result', {}).get('value')
# Always return string representation
if value is None:
return ''
elif isinstance(value, str):
return value
else:
# Convert objects, numbers, booleans to string
import json
try:
return json.dumps(value) if isinstance(value, (dict, list)) else str(value)
except (TypeError, ValueError):
return str(value)
def _fix_javascript_string(self, js_code: str) -> str:
"""Fix common JavaScript string parsing issues when written as Python string."""
# Just do minimal, safe cleaning
js_code = js_code.strip()
# Only fix the most common and safe issues:
# 1. Remove obvious Python string wrapper quotes if they exist
if (js_code.startswith('"') and js_code.endswith('"')) or (js_code.startswith("'") and js_code.endswith("'")):
# Check if it's a wrapped string (not part of JS syntax)
inner = js_code[1:-1]
if inner.count('"') + inner.count("'") == 0 or '() =>' in inner:
js_code = inner
# 2. Only fix clearly escaped quotes that shouldn't be
# But be very conservative - only if we're sure it's a Python string artifact
if '\\"' in js_code and js_code.count('\\"') > js_code.count('"'):
js_code = js_code.replace('\\"', '"')
if "\\'" in js_code and js_code.count("\\'") > js_code.count("'"):
js_code = js_code.replace("\\'", "'")
# 3. Basic whitespace normalization only
js_code = js_code.strip()
# Final validation - ensure it's not empty
if not js_code:
raise ValueError('JavaScript code is empty after cleaning')
return js_code
async def screenshot(self, format: str = 'jpeg', quality: int | None = None) -> str:
"""Take a screenshot and return base64 encoded image.
Args:
format: Image format ('jpeg', 'png', 'webp')
quality: Quality 0-100 for JPEG format
Returns:
Base64-encoded image data
"""
session_id = await self._ensure_session()
params: 'CaptureScreenshotParameters' = {'format': format}
if quality is not None and format.lower() == 'jpeg':
params['quality'] = quality
result = await self._client.send.Page.captureScreenshot(params, session_id=session_id)
return result['data']
async def press(self, key: str) -> None:
"""Press a key on the page (sends keyboard input to the focused element or page)."""
session_id = await self._ensure_session()
# Handle key combinations like "Control+A"
if '+' in key:
parts = key.split('+')
modifiers = parts[:-1]
main_key = parts[-1]
# Press modifier keys
for mod in modifiers:
params: 'DispatchKeyEventParameters' = {'type': 'keyDown', 'key': mod}
await self._client.send.Input.dispatchKeyEvent(params, session_id=session_id)
# Press main key
main_down_params: 'DispatchKeyEventParameters' = {'type': 'keyDown', 'key': main_key}
await self._client.send.Input.dispatchKeyEvent(main_down_params, session_id=session_id)
main_up_params: 'DispatchKeyEventParameters' = {'type': 'keyUp', 'key': main_key}
await self._client.send.Input.dispatchKeyEvent(main_up_params, session_id=session_id)
# Release modifier keys
for mod in reversed(modifiers):
release_params: 'DispatchKeyEventParameters' = {'type': 'keyUp', 'key': mod}
await self._client.send.Input.dispatchKeyEvent(release_params, session_id=session_id)
else:
# Simple key press
key_down_params: 'DispatchKeyEventParameters' = {'type': 'keyDown', 'key': key}
await self._client.send.Input.dispatchKeyEvent(key_down_params, session_id=session_id)
key_up_params: 'DispatchKeyEventParameters' = {'type': 'keyUp', 'key': key}
await self._client.send.Input.dispatchKeyEvent(key_up_params, session_id=session_id)
async def set_viewport_size(self, width: int, height: int) -> None:
"""Set the viewport size."""
session_id = await self._ensure_session()
params: 'SetDeviceMetricsOverrideParameters' = {
'width': width,
'height': height,
'deviceScaleFactor': 1.0,
'mobile': False,
}
await self._client.send.Emulation.setDeviceMetricsOverride(
params,
session_id=session_id,
)
# Target properties (from CDP getTargetInfo)
async def get_target_info(self) -> 'TargetInfo':
"""Get target information."""
params: 'GetTargetInfoParameters' = {'targetId': self._target_id}
result = await self._client.send.Target.getTargetInfo(params)
return result['targetInfo']
async def get_url(self) -> str:
"""Get the current URL."""
info = await self.get_target_info()
return info.get('url', '')
async def get_title(self) -> str:
"""Get the current title."""
info = await self.get_target_info()
return info.get('title', '')
async def goto(self, url: str) -> None:
"""Navigate this target to a URL."""
session_id = await self._ensure_session()
params: 'NavigateParameters' = {'url': url}
await self._client.send.Page.navigate(params, session_id=session_id)
async def navigate(self, url: str) -> None:
"""Alias for goto."""
await self.goto(url)
async def go_back(self) -> None:
"""Navigate back in history."""
session_id = await self._ensure_session()
try:
# Get navigation history
history = await self._client.send.Page.getNavigationHistory(session_id=session_id)
current_index = history['currentIndex']
entries = history['entries']
# Check if we can go back
if current_index <= 0:
raise RuntimeError('Cannot go back - no previous entry in history')
# Navigate to the previous entry
previous_entry_id = entries[current_index - 1]['id']
params: 'NavigateToHistoryEntryParameters' = {'entryId': previous_entry_id}
await self._client.send.Page.navigateToHistoryEntry(params, session_id=session_id)
except Exception as e:
raise RuntimeError(f'Failed to navigate back: {e}')
async def go_forward(self) -> None:
"""Navigate forward in history."""
session_id = await self._ensure_session()
try:
# Get navigation history
history = await self._client.send.Page.getNavigationHistory(session_id=session_id)
current_index = history['currentIndex']
entries = history['entries']
# Check if we can go forward
if current_index >= len(entries) - 1:
raise RuntimeError('Cannot go forward - no next entry in history')
# Navigate to the next entry
next_entry_id = entries[current_index + 1]['id']
params: 'NavigateToHistoryEntryParameters' = {'entryId': next_entry_id}
await self._client.send.Page.navigateToHistoryEntry(params, session_id=session_id)
except Exception as e:
raise RuntimeError(f'Failed to navigate forward: {e}')
# Element finding methods (these would need to be implemented based on DOM queries)
async def get_elements_by_css_selector(self, selector: str) -> list['Element']:
"""Get elements by CSS selector."""
session_id = await self._ensure_session()
# Get document first
doc_result = await self._client.send.DOM.getDocument(session_id=session_id)
document_node_id = doc_result['root']['nodeId']
# Query selector all
query_params: 'QuerySelectorAllParameters' = {'nodeId': document_node_id, 'selector': selector}
result = await self._client.send.DOM.querySelectorAll(query_params, session_id=session_id)
elements = []
from .element import Element as Element_
# Convert node IDs to backend node IDs
for node_id in result['nodeIds']:
# Get backend node ID
describe_params: 'DescribeNodeParameters' = {'nodeId': node_id}
node_result = await self._client.send.DOM.describeNode(describe_params, session_id=session_id)
backend_node_id = node_result['node']['backendNodeId']
elements.append(Element_(self._browser_session, backend_node_id, session_id))
return elements
# AI METHODS
@property
def dom_service(self) -> 'DomService':
"""Get the DOM service for this target."""
return DomService(self._browser_session)
async def get_element_by_prompt(self, prompt: str, llm: 'BaseChatModel | None' = None) -> 'Element | None':
"""Get an element by a prompt."""
await self._ensure_session()
llm = llm or self._llm
if not llm:
raise ValueError('LLM not provided')
dom_service = self.dom_service
enhanced_dom_tree = await dom_service.get_dom_tree(target_id=self._target_id)
serialized_dom_state, _ = DOMTreeSerializer(
enhanced_dom_tree, None, paint_order_filtering=True
).serialize_accessible_elements()
llm_representation = serialized_dom_state.llm_representation()
system_message = SystemMessage(
content="""You are an AI created to find an element on a page by a prompt.
<browser_state>
Interactive Elements: All interactive elements will be provided in format as [index]<type>text</type> where
- index: Numeric identifier for interaction
- type: HTML element type (button, input, etc.)
- text: Element description
Examples:
[33]<div>User form</div>
[35]<button aria-label='Submit form'>Submit</button>
Note that:
- Only elements with numeric indexes in [] are interactive
- (stacked) indentation (with \t) is important and means that the element is a (html) child of the element above (with a lower index)
- Pure text elements without [] are not interactive.
</browser_state>
Your task is to find an element index (if any) that matches the prompt (written in <prompt> tag).
If non of the elements matches the, return None.
Before you return the element index, reason about the state and elements for a sentence or two."""
)
state_message = UserMessage(
content=f"""
<browser_state>
{llm_representation}
</browser_state>
<prompt>
{prompt}
</prompt>
"""
)
class ElementResponse(BaseModel):
# thinking: str
element_highlight_index: int | None
llm_response = await llm.ainvoke(
[
system_message,
state_message,
],
output_format=ElementResponse,
)
element_highlight_index = llm_response.completion.element_highlight_index
if element_highlight_index is None or element_highlight_index not in serialized_dom_state.selector_map:
return None
element = serialized_dom_state.selector_map[element_highlight_index]
from .element import Element as Element_
return Element_(self._browser_session, element.backend_node_id, self._session_id)
async def must_get_element_by_prompt(self, prompt: str, llm: 'BaseChatModel | None' = None) -> 'Element':
"""Get an element by a prompt.
@dev LLM can still return None, this just raises an error if the element is not found.
"""
element = await self.get_element_by_prompt(prompt, llm)
if element is None:
raise ValueError(f'No element found for prompt: {prompt}')
return element
async def extract_content(self, prompt: str, structured_output: type[T], llm: 'BaseChatModel | None' = None) -> T:
"""Extract structured content from the current page using LLM.
Extracts clean markdown from the page and sends it to LLM for structured data extraction.
Args:
prompt: Description of what content to extract
structured_output: Pydantic BaseModel class defining the expected output structure
llm: Language model to use for extraction
Returns:
The structured BaseModel instance with extracted content
"""
llm = llm or self._llm
if not llm:
raise ValueError('LLM not provided')
# Extract clean markdown from the page
session_id = await self._ensure_session()
try:
body_id = await self._client.send.DOM.getDocument(session_id=session_id)
page_html_result = await self._client.send.DOM.getOuterHTML(
params={'backendNodeId': body_id['root']['backendNodeId']}, session_id=session_id
)
page_html = page_html_result['outerHTML']
except Exception as e:
raise RuntimeError(f"Couldn't extract page content: {e}")
# Convert HTML to clean markdown
import html2text
h = html2text.HTML2Text()
h.ignore_links = False
h.ignore_images = True
h.ignore_emphasis = False
h.body_width = 0 # Don't wrap lines
h.unicode_snob = True
h.skip_internal_links = True
markdown_content = h.handle(page_html)
# Clean up the markdown
import re
# Remove URL encoding artifacts
markdown_content = re.sub(r'%[0-9A-Fa-f]{2}', '', markdown_content)
# Compress excessive newlines
markdown_content = re.sub(r'\n{4,}', '\n\n\n', markdown_content)
# Remove very short lines (likely artifacts)
lines = markdown_content.split('\n')
filtered_lines = []
for line in lines:
stripped = line.strip()
if len(stripped) > 2: # Keep lines with substantial content
filtered_lines.append(line)
markdown_content = '\n'.join(filtered_lines).strip()
# System prompt for structured extraction
system_prompt = """
You are an expert at extracting structured data from the markdown of a webpage.
<input>
You will be given a query and the markdown of a webpage that has been filtered to remove noise and advertising content.
</input>
<instructions>
- You are tasked to extract information from the webpage that is relevant to the query.
- You should ONLY use the information available in the webpage to answer the query. Do not make up information or provide guess from your own knowledge.
- If the information relevant to the query is not available in the page, your response should mention that.
- If the query asks for all items, products, etc., make sure to directly list all of them.
- Return the extracted content in the exact structured format specified.
</instructions>
<output>
- Your output should present ALL the information relevant to the query in the specified structured format.
- Do not answer in conversational format - directly output the relevant information in the structured format.
</output>
""".strip()
# Build prompt with just query and content
prompt_content = f'<query>\n{prompt}\n</query>\n\n<webpage_content>\n{markdown_content}\n</webpage_content>'
# Send to LLM with structured output
import asyncio
try:
response = await asyncio.wait_for(
llm.ainvoke(
[SystemMessage(content=system_prompt), UserMessage(content=prompt_content)], output_format=structured_output
),
timeout=120.0,
)
# Return the structured output BaseModel instance
return response.completion
except Exception as e:
raise RuntimeError(str(e))
@@ -0,0 +1,41 @@
import asyncio
from browser_use import Agent, Browser, ChatOpenAI
llm = ChatOpenAI('gpt-4.1-mini')
async def main():
"""
Main function demonstrating mixed automation with Browser-Use and Playwright.
"""
print('🚀 Mixed Automation with Browser-Use and Actor API')
browser = Browser(keep_alive=True)
await browser.start()
page = await browser.get_current_page() or await browser.new_page()
# Go to apple wikipedia page
await page.goto('https://www.google.com/travel/flights')
await asyncio.sleep(1)
round_trip_button = await page.must_get_element_by_prompt('round trip button', llm)
await round_trip_button.click()
one_way_button = await page.must_get_element_by_prompt('one way button', llm)
await one_way_button.click()
await asyncio.sleep(1)
agent = Agent(task='Find the cheapest flight from London to Paris on 2025-10-15', llm=llm, browser_session=browser)
await agent.run()
input('Press Enter to continue...')
await browser.stop()
if __name__ == '__main__':
asyncio.run(main())
@@ -0,0 +1,54 @@
import asyncio
from pydantic import BaseModel
from browser_use import Browser, ChatOpenAI
TASK = """
On the current wikipedia page, find the latest huge edit and tell me what is was about.
"""
class LatestEditFinder(BaseModel):
"""Find the latest huge edit on the current wikipedia page."""
latest_edit: str
edit_time: str
edit_author: str
edit_summary: str
edit_url: str
llm = ChatOpenAI('gpt-4.1-mini')
async def main():
"""
Main function demonstrating mixed automation with Browser-Use and Playwright.
"""
print('🚀 Mixed Automation with Browser-Use and Actor API')
browser = Browser(keep_alive=True)
await browser.start()
page = await browser.get_current_page() or await browser.new_page()
# Go to apple wikipedia page
await page.goto('https://browser-use.github.io/stress-tests/challenges/angularjs-form.html')
await asyncio.sleep(1)
element = await page.get_element_by_prompt('zip code input', llm)
print('Element found', element)
if element:
await element.click()
else:
print('No element found')
await browser.stop()
if __name__ == '__main__':
asyncio.run(main())
@@ -0,0 +1,236 @@
#!/usr/bin/env python3
"""
Playground script to test the browser-use actor API.
This script demonstrates:
- Starting a browser session
- Using the actor API to navigate and interact
- Finding elements, clicking, scrolling, JavaScript evaluation
- Testing most of the available methods
"""
import asyncio
import json
import logging
from browser_use import Browser
# Configure logging to see what's happening
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
async def main():
"""Main playground function."""
logger.info('🚀 Starting browser actor playground')
# Create browser session
browser = Browser()
try:
# Start the browser
await browser.start()
logger.info('✅ Browser session started')
# Navigate to Wikipedia using integrated methods
logger.info('📖 Navigating to Wikipedia...')
page = await browser.new_page('https://en.wikipedia.org')
# Get basic page info
url = await page.get_url()
title = await page.get_title()
logger.info(f'📄 Page loaded: {title} ({url})')
# Take a screenshot
logger.info('📸 Taking initial screenshot...')
screenshot_b64 = await page.screenshot()
logger.info(f'📸 Screenshot captured: {len(screenshot_b64)} bytes')
# Set viewport size
logger.info('🖥️ Setting viewport to 1920x1080...')
await page.set_viewport_size(1920, 1080)
# Execute some JavaScript to count links
logger.info('🔍 Counting article links using JavaScript...')
js_code = """() => {
// Find all article links on the page
const links = Array.from(document.querySelectorAll('a[href*="/wiki/"]:not([href*=":"])'))
.filter(link => !link.href.includes('Main_Page') && !link.href.includes('Special:'));
return {
total: links.length,
sample: links.slice(0, 3).map(link => ({
href: link.href,
text: link.textContent.trim()
}))
};
}"""
link_info = json.loads(await page.evaluate(js_code))
logger.info(f'🔗 Found {link_info["total"]} article links')
# Try to find and interact with links using CSS selector
try:
# Find article links on the page
links = await page.get_elements_by_css_selector('a[href*="/wiki/"]:not([href*=":"])')
if links:
logger.info(f'📋 Found {len(links)} wiki links via CSS selector')
# Pick the first link
link_element = links[0]
# Get link info using available methods
basic_info = await link_element.get_basic_info()
link_href = await link_element.get_attribute('href')
logger.info(f'🎯 Selected element: <{basic_info["nodeName"]}>')
logger.info(f'🔗 Link href: {link_href}')
if basic_info['boundingBox']:
bbox = basic_info['boundingBox']
logger.info(f'📏 Position: ({bbox["x"]}, {bbox["y"]}) Size: {bbox["width"]}x{bbox["height"]}')
# Test element interactions with robust implementations
logger.info('👆 Hovering over the element...')
await link_element.hover()
await asyncio.sleep(1)
logger.info('🔍 Focusing the element...')
await link_element.focus()
await asyncio.sleep(0.5)
# Click the link using robust click method
logger.info('🖱️ Clicking the link with robust fallbacks...')
await link_element.click()
# Wait for navigation
await asyncio.sleep(3)
# Get new page info
new_url = await page.get_url()
new_title = await page.get_title()
logger.info(f'📄 Navigated to: {new_title}')
logger.info(f'🌐 New URL: {new_url}')
else:
logger.warning('❌ No links found to interact with')
except Exception as e:
logger.warning(f'⚠️ Link interaction failed: {e}')
# Scroll down the page
logger.info('📜 Scrolling down the page...')
mouse = await page.mouse
await mouse.scroll(x=0, y=100, delta_y=500)
await asyncio.sleep(1)
# Test mouse operations
logger.info('🖱️ Testing mouse operations...')
await mouse.move(x=100, y=200)
await mouse.click(x=150, y=250)
# Execute more JavaScript examples
logger.info('🧪 Testing JavaScript evaluation...')
# Simple expressions
page_height = await page.evaluate('() => document.body.scrollHeight')
current_scroll = await page.evaluate('() => window.pageYOffset')
logger.info(f'📏 Page height: {page_height}px, current scroll: {current_scroll}px')
# JavaScript with arguments
result = await page.evaluate('(x) => x * 2', 21)
logger.info(f'🧮 JavaScript with args: 21 * 2 = {result}')
# More complex JavaScript
page_stats = json.loads(
await page.evaluate("""() => {
return {
url: window.location.href,
title: document.title,
links: document.querySelectorAll('a').length,
images: document.querySelectorAll('img').length,
scrollTop: window.pageYOffset,
viewportHeight: window.innerHeight
};
}""")
)
logger.info(f'📊 Page stats: {page_stats}')
# Get page title using different methods
title_via_js = await page.evaluate('() => document.title')
title_via_api = await page.get_title()
logger.info(f'📝 Title via JS: "{title_via_js}"')
logger.info(f'📝 Title via API: "{title_via_api}"')
# Take a final screenshot
logger.info('📸 Taking final screenshot...')
final_screenshot = await page.screenshot()
logger.info(f'📸 Final screenshot: {len(final_screenshot)} bytes')
# Test browser navigation with error handling
logger.info('⬅️ Testing browser back navigation...')
try:
await page.go_back()
await asyncio.sleep(2)
back_url = await page.get_url()
back_title = await page.get_title()
logger.info(f'📄 After going back: {back_title}')
logger.info(f'🌐 Back URL: {back_url}')
except RuntimeError as e:
logger.info(f'️ Navigation back failed as expected: {e}')
# Test creating new page
logger.info('🆕 Creating new blank page...')
new_page = await browser.new_page()
new_page_url = await new_page.get_url()
logger.info(f'🆕 New page created with URL: {new_page_url}')
# Get all pages
all_pages = await browser.get_pages()
logger.info(f'📑 Total pages: {len(all_pages)}')
# Test form interaction if we can find a form
try:
# Look for search input on the page
search_inputs = await page.get_elements_by_css_selector('input[type="search"], input[name*="search"]')
if search_inputs:
search_input = search_inputs[0]
logger.info('🔍 Found search input, testing form interaction...')
await search_input.focus()
await search_input.fill('test search query')
await page.press('Enter')
logger.info('✅ Form interaction test completed')
else:
logger.info('️ No search inputs found for form testing')
except Exception as e:
logger.info(f'️ Form interaction test skipped: {e}')
# wait 2 seconds before closing the new page
logger.info('🕒 Waiting 2 seconds before closing the new page...')
await asyncio.sleep(2)
logger.info('🗑️ Closing new page...')
await browser.close_page(new_page)
logger.info('✅ Playground completed successfully!')
input('Press Enter to continue...')
except Exception as e:
logger.error(f'❌ Error in playground: {e}', exc_info=True)
finally:
# Clean up
logger.info('🧹 Cleaning up...')
try:
await browser.stop()
logger.info('✅ Browser session stopped')
except Exception as e:
logger.error(f'❌ Error stopping browser: {e}')
if __name__ == '__main__':
asyncio.run(main())
@@ -0,0 +1,282 @@
import base64
import os
from datetime import datetime, timezone
from pathlib import Path
import anyio
from bubus import BaseEvent
from pydantic import Field, field_validator
from uuid_extensions import uuid7str
MAX_STRING_LENGTH = 100000 # 100K chars ~ 25k tokens should be enough
MAX_URL_LENGTH = 100000
MAX_TASK_LENGTH = 100000
MAX_COMMENT_LENGTH = 2000
MAX_FILE_CONTENT_SIZE = 50 * 1024 * 1024 # 50MB
class UpdateAgentTaskEvent(BaseEvent):
# Required fields for identification
id: str # The task ID to update
user_id: str = Field(max_length=255) # For authorization
device_id: str | None = Field(None, max_length=255) # Device ID for auth lookup
# Optional fields that can be updated
stopped: bool | None = None
paused: bool | None = None
done_output: str | None = Field(None, max_length=MAX_STRING_LENGTH)
finished_at: datetime | None = None
agent_state: dict | None = None
user_feedback_type: str | None = Field(None, max_length=10) # UserFeedbackType enum value as string
user_comment: str | None = Field(None, max_length=MAX_COMMENT_LENGTH)
gif_url: str | None = Field(None, max_length=MAX_URL_LENGTH)
@classmethod
def from_agent(cls, agent) -> 'UpdateAgentTaskEvent':
"""Create an UpdateAgentTaskEvent from an Agent instance"""
if not hasattr(agent, '_task_start_time'):
raise ValueError('Agent must have _task_start_time attribute')
done_output = agent.history.final_result() if agent.history else None
return cls(
id=str(agent.task_id),
user_id='', # To be filled by cloud handler
device_id=agent.cloud_sync.auth_client.device_id
if hasattr(agent, 'cloud_sync') and agent.cloud_sync and agent.cloud_sync.auth_client
else None,
stopped=agent.state.stopped if hasattr(agent.state, 'stopped') else False,
paused=agent.state.paused if hasattr(agent.state, 'paused') else False,
done_output=done_output,
finished_at=datetime.now(timezone.utc) if agent.history and agent.history.is_done() else None,
agent_state=agent.state.model_dump() if hasattr(agent.state, 'model_dump') else {},
user_feedback_type=None,
user_comment=None,
gif_url=None,
# user_feedback_type and user_comment would be set by the API/frontend
# gif_url would be set after GIF generation if needed
)
class CreateAgentOutputFileEvent(BaseEvent):
# Model fields
id: str = Field(default_factory=uuid7str)
user_id: str = Field(max_length=255)
device_id: str | None = Field(None, max_length=255) # Device ID for auth lookup
task_id: str
file_name: str = Field(max_length=255)
file_content: str | None = None # Base64 encoded file content
content_type: str | None = Field(None, max_length=100) # MIME type for file uploads
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
@field_validator('file_content')
@classmethod
def validate_file_size(cls, v: str | None) -> str | None:
"""Validate base64 file content size."""
if v is None:
return v
# Remove data URL prefix if present
if ',' in v:
v = v.split(',')[1]
# Estimate decoded size (base64 is ~33% larger)
estimated_size = len(v) * 3 / 4
if estimated_size > MAX_FILE_CONTENT_SIZE:
raise ValueError(f'File content exceeds maximum size of {MAX_FILE_CONTENT_SIZE / 1024 / 1024}MB')
return v
@classmethod
async def from_agent_and_file(cls, agent, output_path: str) -> 'CreateAgentOutputFileEvent':
"""Create a CreateAgentOutputFileEvent from a file path"""
gif_path = Path(output_path)
if not gif_path.exists():
raise FileNotFoundError(f'File not found: {output_path}')
gif_size = os.path.getsize(gif_path)
# Read GIF content for base64 encoding if needed
gif_content = None
if gif_size < 50 * 1024 * 1024: # Only read if < 50MB
async with await anyio.open_file(gif_path, 'rb') as f:
gif_bytes = await f.read()
gif_content = base64.b64encode(gif_bytes).decode('utf-8')
return cls(
user_id='', # To be filled by cloud handler
device_id=agent.cloud_sync.auth_client.device_id
if hasattr(agent, 'cloud_sync') and agent.cloud_sync and agent.cloud_sync.auth_client
else None,
task_id=str(agent.task_id),
file_name=gif_path.name,
file_content=gif_content, # Base64 encoded
content_type='image/gif',
)
class CreateAgentStepEvent(BaseEvent):
# Model fields
id: str = Field(default_factory=uuid7str)
user_id: str = Field(max_length=255) # Added for authorization checks
device_id: str | None = Field(None, max_length=255) # Device ID for auth lookup
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
agent_task_id: str
step: int
evaluation_previous_goal: str = Field(max_length=MAX_STRING_LENGTH)
memory: str = Field(max_length=MAX_STRING_LENGTH)
next_goal: str = Field(max_length=MAX_STRING_LENGTH)
actions: list[dict]
screenshot_url: str | None = Field(None, max_length=MAX_FILE_CONTENT_SIZE) # ~50MB for base64 images
url: str = Field(default='', max_length=MAX_URL_LENGTH)
@field_validator('screenshot_url')
@classmethod
def validate_screenshot_size(cls, v: str | None) -> str | None:
"""Validate screenshot URL or base64 content size."""
if v is None or not v.startswith('data:'):
return v
# It's base64 data, check size
if ',' in v:
base64_part = v.split(',')[1]
estimated_size = len(base64_part) * 3 / 4
if estimated_size > MAX_FILE_CONTENT_SIZE:
raise ValueError(f'Screenshot content exceeds maximum size of {MAX_FILE_CONTENT_SIZE / 1024 / 1024}MB')
return v
@classmethod
def from_agent_step(
cls, agent, model_output, result: list, actions_data: list[dict], browser_state_summary
) -> 'CreateAgentStepEvent':
"""Create a CreateAgentStepEvent from agent step data"""
# Get first action details if available
first_action = model_output.action[0] if model_output.action else None
# Extract current state from model output
current_state = model_output.current_state if hasattr(model_output, 'current_state') else None
# Capture screenshot as base64 data URL if available
screenshot_url = None
if browser_state_summary.screenshot:
screenshot_url = f'data:image/png;base64,{browser_state_summary.screenshot}'
import logging
logger = logging.getLogger(__name__)
logger.debug(f'📸 Including screenshot in CreateAgentStepEvent, length: {len(browser_state_summary.screenshot)}')
else:
import logging
logger = logging.getLogger(__name__)
logger.debug('📸 No screenshot in browser_state_summary for CreateAgentStepEvent')
return cls(
user_id='', # To be filled by cloud handler
device_id=agent.cloud_sync.auth_client.device_id
if hasattr(agent, 'cloud_sync') and agent.cloud_sync and agent.cloud_sync.auth_client
else None,
agent_task_id=str(agent.task_id),
step=agent.state.n_steps,
evaluation_previous_goal=current_state.evaluation_previous_goal if current_state else '',
memory=current_state.memory if current_state else '',
next_goal=current_state.next_goal if current_state else '',
actions=actions_data, # List of action dicts
url=browser_state_summary.url,
screenshot_url=screenshot_url,
)
class CreateAgentTaskEvent(BaseEvent):
# Model fields
id: str = Field(default_factory=uuid7str)
user_id: str = Field(max_length=255) # Added for authorization checks
device_id: str | None = Field(None, max_length=255) # Device ID for auth lookup
agent_session_id: str
llm_model: str = Field(max_length=100) # LLMModel enum value as string
stopped: bool = False
paused: bool = False
task: str = Field(max_length=MAX_TASK_LENGTH)
done_output: str | None = Field(None, max_length=MAX_STRING_LENGTH)
scheduled_task_id: str | None = None
started_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
finished_at: datetime | None = None
agent_state: dict = Field(default_factory=dict)
user_feedback_type: str | None = Field(None, max_length=10) # UserFeedbackType enum value as string
user_comment: str | None = Field(None, max_length=MAX_COMMENT_LENGTH)
gif_url: str | None = Field(None, max_length=MAX_URL_LENGTH)
@classmethod
def from_agent(cls, agent) -> 'CreateAgentTaskEvent':
"""Create a CreateAgentTaskEvent from an Agent instance"""
return cls(
id=str(agent.task_id),
user_id='', # To be filled by cloud handler
device_id=agent.cloud_sync.auth_client.device_id
if hasattr(agent, 'cloud_sync') and agent.cloud_sync and agent.cloud_sync.auth_client
else None,
agent_session_id=str(agent.session_id),
task=agent.task,
llm_model=agent.llm.model_name,
agent_state=agent.state.model_dump() if hasattr(agent.state, 'model_dump') else {},
stopped=False,
paused=False,
done_output=None,
started_at=datetime.fromtimestamp(agent._task_start_time, tz=timezone.utc),
finished_at=None,
user_feedback_type=None,
user_comment=None,
gif_url=None,
)
class CreateAgentSessionEvent(BaseEvent):
# Model fields
id: str = Field(default_factory=uuid7str)
user_id: str = Field(max_length=255)
device_id: str | None = Field(None, max_length=255) # Device ID for auth lookup
browser_session_id: str = Field(max_length=255)
browser_session_live_url: str = Field(max_length=MAX_URL_LENGTH)
browser_session_cdp_url: str = Field(max_length=MAX_URL_LENGTH)
browser_session_stopped: bool = False
browser_session_stopped_at: datetime | None = None
is_source_api: bool | None = None
browser_state: dict = Field(default_factory=dict)
browser_session_data: dict | None = None
@classmethod
def from_agent(cls, agent) -> 'CreateAgentSessionEvent':
"""Create a CreateAgentSessionEvent from an Agent instance"""
return cls(
id=str(agent.session_id),
user_id='', # To be filled by cloud handler
device_id=agent.cloud_sync.auth_client.device_id
if hasattr(agent, 'cloud_sync') and agent.cloud_sync and agent.cloud_sync.auth_client
else None,
browser_session_id=agent.browser_session.id,
browser_session_live_url='', # To be filled by cloud handler
browser_session_cdp_url='', # To be filled by cloud handler
browser_state={
'viewport': agent.browser_profile.viewport if agent.browser_profile else {'width': 1280, 'height': 720},
'user_agent': agent.browser_profile.user_agent if agent.browser_profile else None,
'headless': agent.browser_profile.headless if agent.browser_profile else True,
'initial_url': None, # Will be updated during execution
'final_url': None, # Will be updated during execution
'total_pages_visited': 0, # Will be updated during execution
'session_duration_seconds': 0, # Will be updated during execution
},
browser_session_data={
'cookies': [],
'secrets': {},
# TODO: send secrets safely so tasks can be replayed on cloud seamlessly
# 'secrets': dict(agent.sensitive_data) if agent.sensitive_data else {},
'allowed_domains': agent.browser_profile.allowed_domains if agent.browser_profile else [],
},
)
class UpdateAgentSessionEvent(BaseEvent):
"""Event to update an existing agent session"""
# Model fields
id: str # Session ID to update
user_id: str = Field(max_length=255)
device_id: str | None = Field(None, max_length=255)
browser_session_stopped: bool | None = None
browser_session_stopped_at: datetime | None = None
end_reason: str | None = Field(None, max_length=100) # Why the session ended
@@ -0,0 +1,424 @@
from __future__ import annotations
import base64
import io
import logging
import os
import platform
from typing import TYPE_CHECKING
from browser_use.agent.views import AgentHistoryList
from browser_use.browser.views import PLACEHOLDER_4PX_SCREENSHOT
from browser_use.config import CONFIG
if TYPE_CHECKING:
from PIL import Image, ImageFont
logger = logging.getLogger(__name__)
def decode_unicode_escapes_to_utf8(text: str) -> str:
"""Handle decoding any unicode escape sequences embedded in a string (needed to render non-ASCII languages like chinese or arabic in the GIF overlay text)"""
if r'\u' not in text:
# doesn't have any escape sequences that need to be decoded
return text
try:
# Try to decode Unicode escape sequences
return text.encode('latin1').decode('unicode_escape')
except (UnicodeEncodeError, UnicodeDecodeError):
# logger.debug(f"Failed to decode unicode escape sequences while generating gif text: {text}")
return text
def create_history_gif(
task: str,
history: AgentHistoryList,
#
output_path: str = 'agent_history.gif',
duration: int = 3000,
show_goals: bool = True,
show_task: bool = True,
show_logo: bool = False,
font_size: int = 40,
title_font_size: int = 56,
goal_font_size: int = 44,
margin: int = 40,
line_spacing: float = 1.5,
) -> None:
"""Create a GIF from the agent's history with overlaid task and goal text."""
if not history.history:
logger.warning('No history to create GIF from')
return
from PIL import Image, ImageFont
images = []
# if history is empty, we can't create a gif
if not history.history:
logger.warning('No history to create GIF from')
return
# Get all screenshots from history (including None placeholders)
screenshots = history.screenshots(return_none_if_not_screenshot=True)
if not screenshots:
logger.warning('No screenshots found in history')
return
# Find the first non-placeholder screenshot
# A screenshot is considered a placeholder if:
# 1. It's the exact 4px placeholder for about:blank pages, OR
# 2. It comes from a new tab page (chrome://newtab/, about:blank, etc.)
first_real_screenshot = None
for screenshot in screenshots:
if screenshot and screenshot != PLACEHOLDER_4PX_SCREENSHOT:
first_real_screenshot = screenshot
break
if not first_real_screenshot:
logger.warning('No valid screenshots found (all are placeholders or from new tab pages)')
return
# Try to load nicer fonts
try:
# Try different font options in order of preference
# ArialUni is a font that comes with Office and can render most non-alphabet characters
font_options = [
'PingFang',
'STHeiti Medium',
'Microsoft YaHei', # 微软雅黑
'SimHei', # 黑体
'SimSun', # 宋体
'Noto Sans CJK SC', # 思源黑体
'WenQuanYi Micro Hei', # 文泉驿微米黑
'Helvetica',
'Arial',
'DejaVuSans',
'Verdana',
]
font_loaded = False
for font_name in font_options:
try:
if platform.system() == 'Windows':
# Need to specify the abs font path on Windows
font_name = os.path.join(CONFIG.WIN_FONT_DIR, font_name + '.ttf')
regular_font = ImageFont.truetype(font_name, font_size)
title_font = ImageFont.truetype(font_name, title_font_size)
goal_font = ImageFont.truetype(font_name, goal_font_size)
font_loaded = True
break
except OSError:
continue
if not font_loaded:
raise OSError('No preferred fonts found')
except OSError:
regular_font = ImageFont.load_default()
title_font = ImageFont.load_default()
goal_font = regular_font
# Load logo if requested
logo = None
if show_logo:
try:
logo = Image.open('./static/browser-use.png')
# Resize logo to be small (e.g., 40px height)
logo_height = 150
aspect_ratio = logo.width / logo.height
logo_width = int(logo_height * aspect_ratio)
logo = logo.resize((logo_width, logo_height), Image.Resampling.LANCZOS)
except Exception as e:
logger.warning(f'Could not load logo: {e}')
# Create task frame if requested
if show_task and task:
# Find the first non-placeholder screenshot for the task frame
first_real_screenshot = None
for item in history.history:
screenshot_b64 = item.state.get_screenshot()
if screenshot_b64 and screenshot_b64 != PLACEHOLDER_4PX_SCREENSHOT:
first_real_screenshot = screenshot_b64
break
if first_real_screenshot:
task_frame = _create_task_frame(
task,
first_real_screenshot,
title_font, # type: ignore
regular_font, # type: ignore
logo,
line_spacing,
)
images.append(task_frame)
else:
logger.warning('No real screenshots found for task frame, skipping task frame')
# Process each history item with its corresponding screenshot
for i, (item, screenshot) in enumerate(zip(history.history, screenshots), 1):
if not screenshot:
continue
# Skip placeholder screenshots from about:blank pages
# These are 4x4 white PNGs encoded as a specific base64 string
if screenshot == PLACEHOLDER_4PX_SCREENSHOT:
logger.debug(f'Skipping placeholder screenshot from about:blank page at step {i}')
continue
# Skip screenshots from new tab pages
from browser_use.utils import is_new_tab_page
if is_new_tab_page(item.state.url):
logger.debug(f'Skipping screenshot from new tab page ({item.state.url}) at step {i}')
continue
# Convert base64 screenshot to PIL Image
img_data = base64.b64decode(screenshot)
image = Image.open(io.BytesIO(img_data))
if show_goals and item.model_output:
image = _add_overlay_to_image(
image=image,
step_number=i,
goal_text=item.model_output.current_state.next_goal,
regular_font=regular_font, # type: ignore
title_font=title_font, # type: ignore
margin=margin,
logo=logo,
)
images.append(image)
if images:
# Save the GIF
images[0].save(
output_path,
save_all=True,
append_images=images[1:],
duration=duration,
loop=0,
optimize=False,
)
logger.info(f'Created GIF at {output_path}')
else:
logger.warning('No images found in history to create GIF')
def _create_task_frame(
task: str,
first_screenshot: str,
title_font: ImageFont.FreeTypeFont,
regular_font: ImageFont.FreeTypeFont,
logo: Image.Image | None = None,
line_spacing: float = 1.5,
) -> Image.Image:
"""Create initial frame showing the task."""
from PIL import Image, ImageDraw, ImageFont
img_data = base64.b64decode(first_screenshot)
template = Image.open(io.BytesIO(img_data))
image = Image.new('RGB', template.size, (0, 0, 0))
draw = ImageDraw.Draw(image)
# Calculate vertical center of image
center_y = image.height // 2
# Draw task text with dynamic font size based on task length
margin = 140 # Increased margin
max_width = image.width - (2 * margin)
# Dynamic font size calculation based on task length
# Start with base font size (regular + 16)
base_font_size = regular_font.size + 16
min_font_size = max(regular_font.size - 10, 16) # Don't go below 16pt
max_font_size = base_font_size # Cap at the base font size
# Calculate dynamic font size based on text length and complexity
# Longer texts get progressively smaller fonts
text_length = len(task)
if text_length > 200:
# For very long text, reduce font size logarithmically
font_size = max(base_font_size - int(10 * (text_length / 200)), min_font_size)
else:
font_size = base_font_size
# Try to create a larger font, but fall back to regular font if it fails
try:
larger_font = ImageFont.truetype(regular_font.path, font_size) # type: ignore
except (OSError, AttributeError):
# Fall back to regular font if .path is not available or font loading fails
larger_font = regular_font
# Generate wrapped text with the calculated font size
wrapped_text = _wrap_text(task, larger_font, max_width)
# Calculate line height with spacing
line_height = larger_font.size * line_spacing
# Split text into lines and draw with custom spacing
lines = wrapped_text.split('\n')
total_height = line_height * len(lines)
# Start position for first line
text_y = center_y - (total_height / 2) + 50 # Shifted down slightly
for line in lines:
# Get line width for centering
line_bbox = draw.textbbox((0, 0), line, font=larger_font)
text_x = (image.width - (line_bbox[2] - line_bbox[0])) // 2
draw.text(
(text_x, text_y),
line,
font=larger_font,
fill=(255, 255, 255),
)
text_y += line_height
# Add logo if provided (top right corner)
if logo:
logo_margin = 20
logo_x = image.width - logo.width - logo_margin
image.paste(logo, (logo_x, logo_margin), logo if logo.mode == 'RGBA' else None)
return image
def _add_overlay_to_image(
image: Image.Image,
step_number: int,
goal_text: str,
regular_font: ImageFont.FreeTypeFont,
title_font: ImageFont.FreeTypeFont,
margin: int,
logo: Image.Image | None = None,
display_step: bool = True,
text_color: tuple[int, int, int, int] = (255, 255, 255, 255),
text_box_color: tuple[int, int, int, int] = (0, 0, 0, 255),
) -> Image.Image:
"""Add step number and goal overlay to an image."""
from PIL import Image, ImageDraw
goal_text = decode_unicode_escapes_to_utf8(goal_text)
image = image.convert('RGBA')
txt_layer = Image.new('RGBA', image.size, (0, 0, 0, 0))
draw = ImageDraw.Draw(txt_layer)
if display_step:
# Add step number (bottom left)
step_text = str(step_number)
step_bbox = draw.textbbox((0, 0), step_text, font=title_font)
step_width = step_bbox[2] - step_bbox[0]
step_height = step_bbox[3] - step_bbox[1]
# Position step number in bottom left
x_step = margin + 10 # Slight additional offset from edge
y_step = image.height - margin - step_height - 10 # Slight offset from bottom
# Draw rounded rectangle background for step number
padding = 20 # Increased padding
step_bg_bbox = (
x_step - padding,
y_step - padding,
x_step + step_width + padding,
y_step + step_height + padding,
)
draw.rounded_rectangle(
step_bg_bbox,
radius=15, # Add rounded corners
fill=text_box_color,
)
# Draw step number
draw.text(
(x_step, y_step),
step_text,
font=title_font,
fill=text_color,
)
# Draw goal text (centered, bottom)
max_width = image.width - (4 * margin)
wrapped_goal = _wrap_text(goal_text, title_font, max_width)
goal_bbox = draw.multiline_textbbox((0, 0), wrapped_goal, font=title_font)
goal_width = goal_bbox[2] - goal_bbox[0]
goal_height = goal_bbox[3] - goal_bbox[1]
# Center goal text horizontally, place above step number
x_goal = (image.width - goal_width) // 2
y_goal = y_step - goal_height - padding * 4 # More space between step and goal
# Draw rounded rectangle background for goal
padding_goal = 25 # Increased padding for goal
goal_bg_bbox = (
x_goal - padding_goal, # Remove extra space for logo
y_goal - padding_goal,
x_goal + goal_width + padding_goal,
y_goal + goal_height + padding_goal,
)
draw.rounded_rectangle(
goal_bg_bbox,
radius=15, # Add rounded corners
fill=text_box_color,
)
# Draw goal text
draw.multiline_text(
(x_goal, y_goal),
wrapped_goal,
font=title_font,
fill=text_color,
align='center',
)
# Add logo if provided (top right corner)
if logo:
logo_layer = Image.new('RGBA', image.size, (0, 0, 0, 0))
logo_margin = 20
logo_x = image.width - logo.width - logo_margin
logo_layer.paste(logo, (logo_x, logo_margin), logo if logo.mode == 'RGBA' else None)
txt_layer = Image.alpha_composite(logo_layer, txt_layer)
# Composite and convert
result = Image.alpha_composite(image, txt_layer)
return result.convert('RGB')
def _wrap_text(text: str, font: ImageFont.FreeTypeFont, max_width: int) -> str:
"""
Wrap text to fit within a given width.
Args:
text: Text to wrap
font: Font to use for text
max_width: Maximum width in pixels
Returns:
Wrapped text with newlines
"""
text = decode_unicode_escapes_to_utf8(text)
words = text.split()
lines = []
current_line = []
for word in words:
current_line.append(word)
line = ' '.join(current_line)
bbox = font.getbbox(line)
if bbox[2] > max_width:
if len(current_line) == 1:
lines.append(current_line.pop())
else:
current_line.pop()
lines.append(' '.join(current_line))
current_line = [word]
if current_line:
lines.append(' '.join(current_line))
return '\n'.join(lines)
@@ -0,0 +1,422 @@
from __future__ import annotations
import logging
from typing import Literal
from browser_use.agent.message_manager.views import (
HistoryItem,
)
from browser_use.agent.prompts import AgentMessagePrompt
from browser_use.agent.views import (
ActionResult,
AgentOutput,
AgentStepInfo,
MessageManagerState,
)
from browser_use.browser.views import BrowserStateSummary
from browser_use.filesystem.file_system import FileSystem
from browser_use.llm.messages import (
BaseMessage,
ContentPartImageParam,
ContentPartTextParam,
SystemMessage,
)
from browser_use.observability import observe_debug
from browser_use.utils import match_url_with_domain_pattern, time_execution_sync
logger = logging.getLogger(__name__)
# ========== Logging Helper Functions ==========
# These functions are used ONLY for formatting debug log output.
# They do NOT affect the actual message content sent to the LLM.
# All logging functions start with _log_ for easy identification.
def _log_get_message_emoji(message: BaseMessage) -> str:
"""Get emoji for a message type - used only for logging display"""
emoji_map = {
'UserMessage': '💬',
'SystemMessage': '🧠',
'AssistantMessage': '🔨',
}
return emoji_map.get(message.__class__.__name__, '🎮')
def _log_format_message_line(message: BaseMessage, content: str, is_last_message: bool, terminal_width: int) -> list[str]:
"""Format a single message for logging display"""
try:
lines = []
# Get emoji and token info
emoji = _log_get_message_emoji(message)
# token_str = str(message.metadata.tokens).rjust(4)
# TODO: fix the token count
token_str = '??? (TODO)'
prefix = f'{emoji}[{token_str}]: '
# Calculate available width (emoji=2 visual cols + [token]: =8 chars)
content_width = terminal_width - 10
# Handle last message wrapping
if is_last_message and len(content) > content_width:
# Find a good break point
break_point = content.rfind(' ', 0, content_width)
if break_point > content_width * 0.7: # Keep at least 70% of line
first_line = content[:break_point]
rest = content[break_point + 1 :]
else:
# No good break point, just truncate
first_line = content[:content_width]
rest = content[content_width:]
lines.append(prefix + first_line)
# Second line with 10-space indent
if rest:
if len(rest) > terminal_width - 10:
rest = rest[: terminal_width - 10]
lines.append(' ' * 10 + rest)
else:
# Single line - truncate if needed
if len(content) > content_width:
content = content[:content_width]
lines.append(prefix + content)
return lines
except Exception as e:
logger.warning(f'Failed to format message line for logging: {e}')
# Return a simple fallback line
return ['❓[ ?]: [Error formatting message]']
# ========== End of Logging Helper Functions ==========
class MessageManager:
vision_detail_level: Literal['auto', 'low', 'high']
def __init__(
self,
task: str,
system_message: SystemMessage,
file_system: FileSystem,
state: MessageManagerState = MessageManagerState(),
use_thinking: bool = True,
include_attributes: list[str] | None = None,
sensitive_data: dict[str, str | dict[str, str]] | None = None,
max_history_items: int | None = None,
vision_detail_level: Literal['auto', 'low', 'high'] = 'auto',
include_tool_call_examples: bool = False,
include_recent_events: bool = False,
sample_images: list[ContentPartTextParam | ContentPartImageParam] | None = None,
):
self.task = task
self.state = state
self.system_prompt = system_message
self.file_system = file_system
self.sensitive_data_description = ''
self.use_thinking = use_thinking
self.max_history_items = max_history_items
self.vision_detail_level = vision_detail_level
self.include_tool_call_examples = include_tool_call_examples
self.include_recent_events = include_recent_events
self.sample_images = sample_images
assert max_history_items is None or max_history_items > 5, 'max_history_items must be None or greater than 5'
# Store settings as direct attributes instead of in a settings object
self.include_attributes = include_attributes or []
self.sensitive_data = sensitive_data
self.last_input_messages = []
# Only initialize messages if state is empty
if len(self.state.history.get_messages()) == 0:
self._set_message_with_type(self.system_prompt, 'system')
@property
def agent_history_description(self) -> str:
"""Build agent history description from list of items, respecting max_history_items limit"""
if self.max_history_items is None:
# Include all items
return '\n'.join(item.to_string() for item in self.state.agent_history_items)
total_items = len(self.state.agent_history_items)
# If we have fewer items than the limit, just return all items
if total_items <= self.max_history_items:
return '\n'.join(item.to_string() for item in self.state.agent_history_items)
# We have more items than the limit, so we need to omit some
omitted_count = total_items - self.max_history_items
# Show first item + omitted message + most recent (max_history_items - 1) items
# The omitted message doesn't count against the limit, only real history items do
recent_items_count = self.max_history_items - 1 # -1 for first item
items_to_include = [
self.state.agent_history_items[0].to_string(), # Keep first item (initialization)
f'<sys>[... {omitted_count} previous steps omitted...]</sys>',
]
# Add most recent items
items_to_include.extend([item.to_string() for item in self.state.agent_history_items[-recent_items_count:]])
return '\n'.join(items_to_include)
def add_new_task(self, new_task: str) -> None:
new_task = '<follow_up_user_request> ' + new_task.strip() + ' </follow_up_user_request>'
if '<initial_user_request>' not in self.task:
self.task = '<initial_user_request>' + self.task + '</initial_user_request>'
self.task += '\n' + new_task
task_update_item = HistoryItem(system_message=new_task)
self.state.agent_history_items.append(task_update_item)
def _update_agent_history_description(
self,
model_output: AgentOutput | None = None,
result: list[ActionResult] | None = None,
step_info: AgentStepInfo | None = None,
) -> None:
"""Update the agent history description"""
if result is None:
result = []
step_number = step_info.step_number if step_info else None
self.state.read_state_description = ''
action_results = ''
result_len = len(result)
read_state_idx = 0
for idx, action_result in enumerate(result):
if action_result.include_extracted_content_only_once and action_result.extracted_content:
self.state.read_state_description += (
f'<read_state_{read_state_idx}>\n{action_result.extracted_content}\n</read_state_{read_state_idx}>\n'
)
read_state_idx += 1
logger.debug(f'Added extracted_content to read_state_description: {action_result.extracted_content}')
if action_result.long_term_memory:
action_results += f'{action_result.long_term_memory}\n'
logger.debug(f'Added long_term_memory to action_results: {action_result.long_term_memory}')
elif action_result.extracted_content and not action_result.include_extracted_content_only_once:
action_results += f'{action_result.extracted_content}\n'
logger.debug(f'Added extracted_content to action_results: {action_result.extracted_content}')
if action_result.error:
if len(action_result.error) > 200:
error_text = action_result.error[:100] + '......' + action_result.error[-100:]
else:
error_text = action_result.error
action_results += f'{error_text}\n'
logger.debug(f'Added error to action_results: {error_text}')
self.state.read_state_description = self.state.read_state_description.strip('\n')
if action_results:
action_results = f'Result:\n{action_results}'
action_results = action_results.strip('\n') if action_results else None
# Build the history item
if model_output is None:
# Add history item for initial actions (step 0) or errors (step > 0)
if step_number is not None:
if step_number == 0 and action_results:
# Step 0 with initial action results
history_item = HistoryItem(step_number=step_number, action_results=action_results)
self.state.agent_history_items.append(history_item)
elif step_number > 0:
# Error case for steps > 0
history_item = HistoryItem(step_number=step_number, error='Agent failed to output in the right format.')
self.state.agent_history_items.append(history_item)
else:
history_item = HistoryItem(
step_number=step_number,
evaluation_previous_goal=model_output.current_state.evaluation_previous_goal,
memory=model_output.current_state.memory,
next_goal=model_output.current_state.next_goal,
action_results=action_results,
)
self.state.agent_history_items.append(history_item)
def _get_sensitive_data_description(self, current_page_url) -> str:
sensitive_data = self.sensitive_data
if not sensitive_data:
return ''
# Collect placeholders for sensitive data
placeholders: set[str] = set()
for key, value in sensitive_data.items():
if isinstance(value, dict):
# New format: {domain: {key: value}}
if current_page_url and match_url_with_domain_pattern(current_page_url, key, True):
placeholders.update(value.keys())
else:
# Old format: {key: value}
placeholders.add(key)
if placeholders:
placeholder_list = sorted(list(placeholders))
info = f'Here are placeholders for sensitive data:\n{placeholder_list}\n'
info += 'To use them, write <secret>the placeholder name</secret>'
return info
return ''
@observe_debug(ignore_input=True, ignore_output=True, name='create_state_messages')
@time_execution_sync('--create_state_messages')
def create_state_messages(
self,
browser_state_summary: BrowserStateSummary,
model_output: AgentOutput | None = None,
result: list[ActionResult] | None = None,
step_info: AgentStepInfo | None = None,
use_vision=True,
page_filtered_actions: str | None = None,
sensitive_data=None,
available_file_paths: list[str] | None = None, # Always pass current available_file_paths
) -> None:
"""Create single state message with all content"""
# Clear contextual messages from previous steps to prevent accumulation
self.state.history.context_messages.clear()
# First, update the agent history items with the latest step results
self._update_agent_history_description(model_output, result, step_info)
# Use the passed sensitive_data parameter, falling back to instance variable
effective_sensitive_data = sensitive_data if sensitive_data is not None else self.sensitive_data
if effective_sensitive_data is not None:
# Update instance variable to keep it in sync
self.sensitive_data = effective_sensitive_data
self.sensitive_data_description = self._get_sensitive_data_description(browser_state_summary.url)
# Use only the current screenshot
screenshots = []
if browser_state_summary.screenshot:
screenshots.append(browser_state_summary.screenshot)
# Create single state message with all content
assert browser_state_summary
state_message = AgentMessagePrompt(
browser_state_summary=browser_state_summary,
file_system=self.file_system,
agent_history_description=self.agent_history_description,
read_state_description=self.state.read_state_description,
task=self.task,
include_attributes=self.include_attributes,
step_info=step_info,
page_filtered_actions=page_filtered_actions,
sensitive_data=self.sensitive_data_description,
available_file_paths=available_file_paths,
screenshots=screenshots,
vision_detail_level=self.vision_detail_level,
include_recent_events=self.include_recent_events,
sample_images=self.sample_images,
).get_user_message(use_vision)
# Set the state message with caching enabled
self._set_message_with_type(state_message, 'state')
def _log_history_lines(self) -> str:
"""Generate a formatted log string of message history for debugging / printing to terminal"""
# TODO: fix logging
# try:
# total_input_tokens = 0
# message_lines = []
# terminal_width = shutil.get_terminal_size((80, 20)).columns
# for i, m in enumerate(self.state.history.messages):
# try:
# total_input_tokens += m.metadata.tokens
# is_last_message = i == len(self.state.history.messages) - 1
# # Extract content for logging
# content = _log_extract_message_content(m.message, is_last_message, m.metadata)
# # Format the message line(s)
# lines = _log_format_message_line(m, content, is_last_message, terminal_width)
# message_lines.extend(lines)
# except Exception as e:
# logger.warning(f'Failed to format message {i} for logging: {e}')
# # Add a fallback line for this message
# message_lines.append('❓[ ?]: [Error formatting this message]')
# # Build final log message
# return (
# f'📜 LLM Message history ({len(self.state.history.messages)} messages, {total_input_tokens} tokens):\n'
# + '\n'.join(message_lines)
# )
# except Exception as e:
# logger.warning(f'Failed to generate history log: {e}')
# # Return a minimal fallback message
# return f'📜 LLM Message history (error generating log: {e})'
return ''
@time_execution_sync('--get_messages')
def get_messages(self) -> list[BaseMessage]:
"""Get current message list, potentially trimmed to max tokens"""
# Log message history for debugging
logger.debug(self._log_history_lines())
self.last_input_messages = self.state.history.get_messages()
return self.last_input_messages
def _set_message_with_type(self, message: BaseMessage, message_type: Literal['system', 'state']) -> None:
"""Replace a specific state message slot with a new message"""
# Don't filter system and state messages - they should contain placeholder tags or normal conversation
if message_type == 'system':
self.state.history.system_message = message
elif message_type == 'state':
self.state.history.state_message = message
else:
raise ValueError(f'Invalid state message type: {message_type}')
def _add_context_message(self, message: BaseMessage) -> None:
"""Add a contextual message specific to this step (e.g., validation errors, retry instructions, timeout warnings)"""
# Don't filter context messages - they should contain normal conversation or error messages
self.state.history.context_messages.append(message)
@time_execution_sync('--filter_sensitive_data')
def _filter_sensitive_data(self, message: BaseMessage) -> BaseMessage:
"""Filter out sensitive data from the message"""
def replace_sensitive(value: str) -> str:
if not self.sensitive_data:
return value
# Collect all sensitive values, immediately converting old format to new format
sensitive_values: dict[str, str] = {}
# Process all sensitive data entries
for key_or_domain, content in self.sensitive_data.items():
if isinstance(content, dict):
# Already in new format: {domain: {key: value}}
for key, val in content.items():
if val: # Skip empty values
sensitive_values[key] = val
elif content: # Old format: {key: value} - convert to new format internally
# We treat this as if it was {'http*://*': {key_or_domain: content}}
sensitive_values[key_or_domain] = content
# If there are no valid sensitive data entries, just return the original value
if not sensitive_values:
logger.warning('No valid entries found in sensitive_data dictionary')
return value
# Replace all valid sensitive data values with their placeholder tags
for key, val in sensitive_values.items():
value = value.replace(val, f'<secret>{key}</secret>')
return value
if isinstance(message.content, str):
message.content = replace_sensitive(message.content)
elif isinstance(message.content, list):
for i, item in enumerate(message.content):
if isinstance(item, ContentPartTextParam):
item.text = replace_sensitive(item.text)
message.content[i] = item
return message
@@ -0,0 +1,52 @@
from __future__ import annotations
import json
import logging
from pathlib import Path
from typing import Any
import anyio
from browser_use.llm.messages import BaseMessage
logger = logging.getLogger(__name__)
async def save_conversation(
input_messages: list[BaseMessage],
response: Any,
target: str | Path,
encoding: str | None = None,
) -> None:
"""Save conversation history to file asynchronously."""
target_path = Path(target)
# create folders if not exists
if target_path.parent:
await anyio.Path(target_path.parent).mkdir(parents=True, exist_ok=True)
await anyio.Path(target_path).write_text(
await _format_conversation(input_messages, response),
encoding=encoding or 'utf-8',
)
async def _format_conversation(messages: list[BaseMessage], response: Any) -> str:
"""Format the conversation including messages and response."""
lines = []
# Format messages
for message in messages:
lines.append(f' {message.role} ')
lines.append(message.text)
lines.append('') # Empty line after each message
# Format response
lines.append(' RESPONSE')
lines.append(json.dumps(json.loads(response.model_dump_json(exclude_unset=True)), indent=2))
return '\n'.join(lines)
# Note: _write_messages_to_file and _write_response_to_file have been merged into _format_conversation
# This is more efficient for async operations and reduces file I/O
@@ -0,0 +1,98 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from pydantic import BaseModel, ConfigDict, Field
from browser_use.llm.messages import (
BaseMessage,
)
if TYPE_CHECKING:
pass
class HistoryItem(BaseModel):
"""Represents a single agent history item with its data and string representation"""
step_number: int | None = None
evaluation_previous_goal: str | None = None
memory: str | None = None
next_goal: str | None = None
action_results: str | None = None
error: str | None = None
system_message: str | None = None
model_config = ConfigDict(arbitrary_types_allowed=True)
def model_post_init(self, __context) -> None:
"""Validate that error and system_message are not both provided"""
if self.error is not None and self.system_message is not None:
raise ValueError('Cannot have both error and system_message at the same time')
def to_string(self) -> str:
"""Get string representation of the history item"""
step_str = 'step' if self.step_number is not None else 'step_unknown'
if self.error:
return f"""<{step_str}>
{self.error}
</{step_str}>"""
elif self.system_message:
return self.system_message
else:
content_parts = []
# Only include evaluation_previous_goal if it's not None/empty
if self.evaluation_previous_goal:
content_parts.append(f'{self.evaluation_previous_goal}')
# Always include memory
if self.memory:
content_parts.append(f'{self.memory}')
# Only include next_goal if it's not None/empty
if self.next_goal:
content_parts.append(f'{self.next_goal}')
if self.action_results:
content_parts.append(self.action_results)
content = '\n'.join(content_parts)
return f"""<{step_str}>
{content}
</{step_str}>"""
class MessageHistory(BaseModel):
"""History of messages"""
system_message: BaseMessage | None = None
state_message: BaseMessage | None = None
context_messages: list[BaseMessage] = Field(default_factory=list)
model_config = ConfigDict(arbitrary_types_allowed=True)
def get_messages(self) -> list[BaseMessage]:
"""Get all messages in the correct order: system -> state -> contextual"""
messages = []
if self.system_message:
messages.append(self.system_message)
if self.state_message:
messages.append(self.state_message)
messages.extend(self.context_messages)
return messages
class MessageManagerState(BaseModel):
"""Holds the state for MessageManager"""
history: MessageHistory = Field(default_factory=MessageHistory)
tool_id: int = 1
agent_history_items: list[HistoryItem] = Field(
default_factory=lambda: [HistoryItem(step_number=0, system_message='Agent initialized')]
)
read_state_description: str = ''
model_config = ConfigDict(arbitrary_types_allowed=True)
@@ -0,0 +1,378 @@
import importlib.resources
from datetime import datetime
from typing import TYPE_CHECKING, Literal, Optional
from browser_use.dom.views import NodeType, SimplifiedNode
from browser_use.llm.messages import ContentPartImageParam, ContentPartTextParam, ImageURL, SystemMessage, UserMessage
from browser_use.observability import observe_debug
from browser_use.utils import is_new_tab_page
if TYPE_CHECKING:
from browser_use.agent.views import AgentStepInfo
from browser_use.browser.views import BrowserStateSummary
from browser_use.filesystem.file_system import FileSystem
class SystemPrompt:
def __init__(
self,
action_description: str,
max_actions_per_step: int = 10,
override_system_message: str | None = None,
extend_system_message: str | None = None,
use_thinking: bool = True,
flash_mode: bool = False,
):
self.default_action_description = action_description
self.max_actions_per_step = max_actions_per_step
self.use_thinking = use_thinking
self.flash_mode = flash_mode
prompt = ''
if override_system_message:
prompt = override_system_message
else:
self._load_prompt_template()
prompt = self.prompt_template.format(max_actions=self.max_actions_per_step)
if extend_system_message:
prompt += f'\n{extend_system_message}'
self.system_message = SystemMessage(content=prompt, cache=True)
def _load_prompt_template(self) -> None:
"""Load the prompt template from the markdown file."""
try:
# Choose the appropriate template based on flash_mode and use_thinking settings
if self.flash_mode:
template_filename = 'system_prompt_flash.md'
elif self.use_thinking:
template_filename = 'system_prompt.md'
else:
template_filename = 'system_prompt_no_thinking.md'
# This works both in development and when installed as a package
with importlib.resources.files('browser_use.agent').joinpath(template_filename).open('r', encoding='utf-8') as f:
self.prompt_template = f.read()
except Exception as e:
raise RuntimeError(f'Failed to load system prompt template: {e}')
def get_system_message(self) -> SystemMessage:
"""
Get the system prompt for the agent.
Returns:
SystemMessage: Formatted system prompt
"""
return self.system_message
# Functions:
# {self.default_action_description}
# Example:
# {self.example_response()}
# Your AVAILABLE ACTIONS:
# {self.default_action_description}
class AgentMessagePrompt:
vision_detail_level: Literal['auto', 'low', 'high']
def __init__(
self,
browser_state_summary: 'BrowserStateSummary',
file_system: 'FileSystem',
agent_history_description: str | None = None,
read_state_description: str | None = None,
task: str | None = None,
include_attributes: list[str] | None = None,
step_info: Optional['AgentStepInfo'] = None,
page_filtered_actions: str | None = None,
max_clickable_elements_length: int = 40000,
sensitive_data: str | None = None,
available_file_paths: list[str] | None = None,
screenshots: list[str] | None = None,
vision_detail_level: Literal['auto', 'low', 'high'] = 'auto',
include_recent_events: bool = False,
sample_images: list[ContentPartTextParam | ContentPartImageParam] | None = None,
):
self.browser_state: 'BrowserStateSummary' = browser_state_summary
self.file_system: 'FileSystem | None' = file_system
self.agent_history_description: str | None = agent_history_description
self.read_state_description: str | None = read_state_description
self.task: str | None = task
self.include_attributes = include_attributes
self.step_info = step_info
self.page_filtered_actions: str | None = page_filtered_actions
self.max_clickable_elements_length: int = max_clickable_elements_length
self.sensitive_data: str | None = sensitive_data
self.available_file_paths: list[str] | None = available_file_paths
self.screenshots = screenshots or []
self.vision_detail_level = vision_detail_level
self.include_recent_events = include_recent_events
self.sample_images = sample_images or []
assert self.browser_state
def _extract_page_statistics(self) -> dict[str, int]:
"""Extract high-level page statistics from DOM tree for LLM context"""
stats = {
'links': 0,
'iframes': 0,
'shadow_open': 0,
'shadow_closed': 0,
'scroll_containers': 0,
'images': 0,
'interactive_elements': 0,
'total_elements': 0,
}
if not self.browser_state.dom_state or not self.browser_state.dom_state._root:
return stats
def traverse_node(node: SimplifiedNode) -> None:
"""Recursively traverse simplified DOM tree to count elements"""
if not node or not node.original_node:
return
original = node.original_node
stats['total_elements'] += 1
# Count by node type and tag
if original.node_type == NodeType.ELEMENT_NODE:
tag = original.tag_name.lower() if original.tag_name else ''
if tag == 'a':
stats['links'] += 1
elif tag in ('iframe', 'frame'):
stats['iframes'] += 1
elif tag == 'img':
stats['images'] += 1
# Check if scrollable
if original.is_actually_scrollable:
stats['scroll_containers'] += 1
# Check if interactive
if node.interactive_index is not None:
stats['interactive_elements'] += 1
# Check if this element hosts shadow DOM
if node.is_shadow_host:
# Check if any shadow children are closed
has_closed_shadow = any(
child.original_node.node_type == NodeType.DOCUMENT_FRAGMENT_NODE
and child.original_node.shadow_root_type
and child.original_node.shadow_root_type.lower() == 'closed'
for child in node.children
)
if has_closed_shadow:
stats['shadow_closed'] += 1
else:
stats['shadow_open'] += 1
elif original.node_type == NodeType.DOCUMENT_FRAGMENT_NODE:
# Shadow DOM fragment - these are the actual shadow roots
# But don't double-count since we count them at the host level above
pass
# Traverse children
for child in node.children:
traverse_node(child)
traverse_node(self.browser_state.dom_state._root)
return stats
@observe_debug(ignore_input=True, ignore_output=True, name='_get_browser_state_description')
def _get_browser_state_description(self) -> str:
# Extract page statistics first
page_stats = self._extract_page_statistics()
# Format statistics for LLM
stats_text = '<page_stats>'
if page_stats['total_elements'] < 10:
stats_text += 'Page appears empty (SPA not loaded?) - '
stats_text += f'{page_stats["links"]} links, {page_stats["interactive_elements"]} interactive, '
stats_text += f'{page_stats["iframes"]} iframes, {page_stats["scroll_containers"]} scroll containers'
if page_stats['shadow_open'] > 0 or page_stats['shadow_closed'] > 0:
stats_text += f', {page_stats["shadow_open"]} shadow(open), {page_stats["shadow_closed"]} shadow(closed)'
if page_stats['images'] > 0:
stats_text += f', {page_stats["images"]} images'
stats_text += f', {page_stats["total_elements"]} total elements'
stats_text += '</page_stats>\n\n'
elements_text = self.browser_state.dom_state.llm_representation(include_attributes=self.include_attributes)
if len(elements_text) > self.max_clickable_elements_length:
elements_text = elements_text[: self.max_clickable_elements_length]
truncated_text = f' (truncated to {self.max_clickable_elements_length} characters)'
else:
truncated_text = ''
has_content_above = False
has_content_below = False
# Enhanced page information for the model
page_info_text = ''
if self.browser_state.page_info:
pi = self.browser_state.page_info
# Compute page statistics dynamically
pages_above = pi.pixels_above / pi.viewport_height if pi.viewport_height > 0 else 0
pages_below = pi.pixels_below / pi.viewport_height if pi.viewport_height > 0 else 0
has_content_above = pages_above > 0
has_content_below = pages_below > 0
total_pages = pi.page_height / pi.viewport_height if pi.viewport_height > 0 else 0
current_page_position = pi.scroll_y / max(pi.page_height - pi.viewport_height, 1)
page_info_text = '<page_info>'
page_info_text += f'{pages_above:.1f} pages above, '
page_info_text += f'{pages_below:.1f} pages below, '
page_info_text += f'{total_pages:.1f} total pages'
page_info_text += '</page_info>\n'
# , at {current_page_position:.0%} of page
if elements_text != '':
if has_content_above:
if self.browser_state.page_info:
pi = self.browser_state.page_info
pages_above = pi.pixels_above / pi.viewport_height if pi.viewport_height > 0 else 0
elements_text = f'... {pages_above:.1f} pages above - scroll to see more or extract structured data if you are looking for specific information ...\n{elements_text}'
else:
elements_text = f'[Start of page]\n{elements_text}'
if has_content_below:
if self.browser_state.page_info:
pi = self.browser_state.page_info
pages_below = pi.pixels_below / pi.viewport_height if pi.viewport_height > 0 else 0
elements_text = f'{elements_text}\n... {pages_below:.1f} pages below - scroll to see more or extract structured data if you are looking for specific information ...'
else:
elements_text = f'{elements_text}\n[End of page]'
else:
elements_text = 'empty page'
tabs_text = ''
current_tab_candidates = []
# Find tabs that match both URL and title to identify current tab more reliably
for tab in self.browser_state.tabs:
if tab.url == self.browser_state.url and tab.title == self.browser_state.title:
current_tab_candidates.append(tab.target_id)
# If we have exactly one match, mark it as current
# Otherwise, don't mark any tab as current to avoid confusion
current_target_id = current_tab_candidates[0] if len(current_tab_candidates) == 1 else None
for tab in self.browser_state.tabs:
tabs_text += f'Tab {tab.target_id[-4:]}: {tab.url} - {tab.title[:30]}\n'
current_tab_text = f'Current tab: {current_target_id[-4:]}' if current_target_id is not None else ''
# Check if current page is a PDF viewer and add appropriate message
pdf_message = ''
if self.browser_state.is_pdf_viewer:
pdf_message = 'PDF viewer cannot be rendered. In this page, DO NOT use the extract_structured_data action as PDF content cannot be rendered. Use the read_file action on the downloaded PDF in available_file_paths to read the full content.\n\n'
# Add recent events if available and requested
recent_events_text = ''
if self.include_recent_events and self.browser_state.recent_events:
recent_events_text = f'Recent browser events: {self.browser_state.recent_events}\n'
browser_state = f"""{stats_text}{current_tab_text}
Available tabs:
{tabs_text}
{page_info_text}
{recent_events_text}{pdf_message}Elements you can interact with inside the viewport{truncated_text}:
{elements_text}
"""
return browser_state
def _get_agent_state_description(self) -> str:
if self.step_info:
step_info_description = f'Step {self.step_info.step_number + 1}. Maximum steps: {self.step_info.max_steps}\n'
else:
step_info_description = ''
time_str = datetime.now().strftime('%Y-%m-%d')
step_info_description += f'Current date: {time_str}'
_todo_contents = self.file_system.get_todo_contents() if self.file_system else ''
if not len(_todo_contents):
_todo_contents = '[Current todo.md is empty, fill it with your plan when applicable]'
agent_state = f"""
<user_request>
{self.task}
</user_request>
<file_system>
{self.file_system.describe() if self.file_system else 'No file system available'}
</file_system>
<todo_contents>
{_todo_contents}
</todo_contents>
"""
if self.sensitive_data:
agent_state += f'<sensitive_data>\n{self.sensitive_data}\n</sensitive_data>\n'
agent_state += f'<step_info>\n{step_info_description}\n</step_info>\n'
if self.available_file_paths:
available_file_paths_text = '\n'.join(self.available_file_paths)
agent_state += f'<available_file_paths>\n{available_file_paths_text}\nUse absolute full paths when referencing these files.\n</available_file_paths>\n'
return agent_state
@observe_debug(ignore_input=True, ignore_output=True, name='get_user_message')
def get_user_message(self, use_vision: bool = True) -> UserMessage:
"""Get complete state as a single cached message"""
# Don't pass screenshot to model if page is a new tab page, step is 0, and there's only one tab
if (
is_new_tab_page(self.browser_state.url)
and self.step_info is not None
and self.step_info.step_number == 0
and len(self.browser_state.tabs) == 1
):
use_vision = False
# Build complete state description
state_description = (
'<agent_history>\n'
+ (self.agent_history_description.strip('\n') if self.agent_history_description else '')
+ '\n</agent_history>\n\n'
)
state_description += '<agent_state>\n' + self._get_agent_state_description().strip('\n') + '\n</agent_state>\n'
state_description += '<browser_state>\n' + self._get_browser_state_description().strip('\n') + '\n</browser_state>\n'
# Only add read_state if it has content
read_state_description = self.read_state_description.strip('\n').strip() if self.read_state_description else ''
if read_state_description:
state_description += '<read_state>\n' + read_state_description + '\n</read_state>\n'
if self.page_filtered_actions:
state_description += '<page_specific_actions>\n'
state_description += self.page_filtered_actions + '\n'
state_description += '</page_specific_actions>\n'
if use_vision is True and self.screenshots:
# Start with text description
content_parts: list[ContentPartTextParam | ContentPartImageParam] = [ContentPartTextParam(text=state_description)]
# Add sample images
content_parts.extend(self.sample_images)
# Add screenshots with labels
for i, screenshot in enumerate(self.screenshots):
if i == len(self.screenshots) - 1:
label = 'Current screenshot:'
else:
# Use simple, accurate labeling since we don't have actual step timing info
label = 'Previous screenshot:'
# Add label as text content
content_parts.append(ContentPartTextParam(text=label))
# Add the screenshot
content_parts.append(
ContentPartImageParam(
image_url=ImageURL(
url=f'data:image/png;base64,{screenshot}',
media_type='image/png',
detail=self.vision_detail_level,
),
)
)
return UserMessage(content=content_parts, cache=True)
return UserMessage(content=state_description, cache=True)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,216 @@
You are an AI agent designed to operate in an iterative loop to automate browser tasks. Your ultimate goal is accomplishing the task provided in <user_request>.
<intro>
You excel at following tasks:
1. Navigating complex websites and extracting precise information
2. Automating form submissions and interactive web actions
3. Gathering and saving information
4. Using your filesystem effectively to decide what to keep in your context
5. Operate effectively in an agent loop
6. Efficiently performing diverse web tasks
</intro>
<language_settings>
- Default working language: **English**
- Always respond in the same language as the user request
</language_settings>
<input>
At every step, your input will consist of:
1. <agent_history>: A chronological event stream including your previous actions and their results.
2. <agent_state>: Current <user_request>, summary of <file_system>, <todo_contents>, and <step_info>.
3. <browser_state>: Current URL, open tabs, interactive elements indexed for actions, and visible page content.
4. <browser_vision>: Screenshot of the browser with bounding boxes around interactive elements.
5. <read_state> This will be displayed only if your previous action was extract_structured_data or read_file. This data is only shown in the current step.
</input>
<agent_history>
Agent history will be given as a list of step information as follows:
<step_{{step_number}}>:
Evaluation of Previous Step: Assessment of last action
Memory: Your memory of this step
Next Goal: Your goal for this step
Action Results: Your actions and their results
</step_{{step_number}}>
and system messages wrapped in <sys> tag.
</agent_history>
<user_request>
USER REQUEST: This is your ultimate objective and always remains visible.
- This has the highest priority. Make the user happy.
- If the user request is very specific - then carefully follow each step and dont skip or hallucinate steps.
- If the task is open ended you can plan yourself how to get it done.
</user_request>
<browser_state>
1. Browser State will be given as:
Current URL: URL of the page you are currently viewing.
Open Tabs: Open tabs with their indexes.
Interactive Elements: All interactive elements will be provided in format as [index]<type>text</type> where
- index: Numeric identifier for interaction
- type: HTML element type (button, input, etc.)
- text: Element description
Examples:
[33]<div>User form</div>
\t*[35]<button aria-label='Submit form'>Submit</button>
Note that:
- Only elements with numeric indexes in [] are interactive
- (stacked) indentation (with \t) is important and means that the element is a (html) child of the element above (with a lower index)
- Elements tagged with a star `*[` are the new interactive elements that appeared on the website since the last step - if url has not changed. Your previous actions caused that change. Think if you need to interact with them, e.g. after input_text you might need to select the right option from the list.
- Pure text elements without [] are not interactive.
</browser_state>
<browser_vision>
You will be provided with a screenshot of the current page with bounding boxes around interactive elements. This is your GROUND TRUTH: reason about the image in your thinking to evaluate your progress.
If an interactive index inside your browser_state does not have text information, then the interactive index is written at the top center of it's element in the screenshot.
</browser_vision>
<browser_rules>
Strictly follow these rules while using the browser and navigating the web:
- Only interact with elements that have a numeric [index] assigned.
- Only use indexes that are explicitly provided.
- If research is needed, open a **new tab** instead of reusing the current one.
- If the page changes after, for example, an input text action, analyse if you need to interact with new elements, e.g. selecting the right option from the list.
- By default, only elements in the visible viewport are listed. Use scrolling tools if you suspect relevant content is offscreen which you need to interact with. Scroll ONLY if there are more pixels below or above the page.
- You can scroll by a specific number of pages using the num_pages parameter (e.g., 0.5 for half page, 2.0 for two pages).
- If a captcha appears, attempt solving it if possible. If not, use fallback strategies (e.g., alternative site, backtrack).
- If expected elements are missing, try refreshing, scrolling, or navigating back.
- If the page is not fully loaded, use the wait action.
- You can call extract_structured_data on specific pages to gather structured semantic information from the entire page, including parts not currently visible.
- Call extract_structured_data only if the information you are looking for is not visible in your <browser_state> otherwise always just use the needed text from the <browser_state>.
- Calling the extract_structured_data tool is expensive! DO NOT query the same page with the same extract_structured_data query multiple times. Make sure that you are on the page with relevant information based on the screenshot before calling this tool.
- If you fill an input field and your action sequence is interrupted, most often something changed e.g. suggestions popped up under the field.
- If the action sequence was interrupted in previous step due to page changes, make sure to complete any remaining actions that were not executed. For example, if you tried to input text and click a search button but the click was not executed because the page changed, you should retry the click action in your next step.
- If the <user_request> includes specific page information such as product type, rating, price, location, etc., try to apply filters to be more efficient.
- The <user_request> is the ultimate goal. If the user specifies explicit steps, they have always the highest priority.
- If you input_text into a field, you might need to press enter, click the search button, or select from dropdown for completion.
- Don't login into a page if you don't have to. Don't login if you don't have the credentials.
- There are 2 types of tasks always first think which type of request you are dealing with:
1. Very specific step by step instructions:
- Follow them as very precise and don't skip steps. Try to complete everything as requested.
2. Open ended tasks. Plan yourself, be creative in achieving them.
- If you get stuck e.g. with logins or captcha in open-ended tasks you can re-evaluate the task and try alternative ways, e.g. sometimes accidentally login pops up, even though there some part of the page is accessible or you get some information via web search.
- If you reach a PDF viewer, the file is automatically downloaded and you can see its path in <available_file_paths>. You can either read the file or scroll in the page to see more.
</browser_rules>
<file_system>
- You have access to a persistent file system which you can use to track progress, store results, and manage long tasks.
- Your file system is initialized with a `todo.md`: Use this to keep a checklist for known subtasks. Use `replace_file_str` tool to update markers in `todo.md` as first action whenever you complete an item. This file should guide your step-by-step execution when you have a long running task.
- If you are writing a `csv` file, make sure to use double quotes if cell elements contain commas.
- If the file is too large, you are only given a preview of your file. Use `read_file` to see the full content if necessary.
- If exists, <available_file_paths> includes files you have downloaded or uploaded by the user. You can only read or upload these files but you don't have write access.
- If the task is really long, initialize a `results.md` file to accumulate your results.
- DO NOT use the file system if the task is less than 10 steps!
</file_system>
<task_completion_rules>
You must call the `done` action in one of two cases:
- When you have fully completed the USER REQUEST.
- When you reach the final allowed step (`max_steps`), even if the task is incomplete.
- If it is ABSOLUTELY IMPOSSIBLE to continue.
The `done` action is your opportunity to terminate and share your findings with the user.
- Set `success` to `true` only if the full USER REQUEST has been completed with no missing components.
- If any part of the request is missing, incomplete, or uncertain, set `success` to `false`.
- You can use the `text` field of the `done` action to communicate your findings and `files_to_display` to send file attachments to the user, e.g. `["results.md"]`.
- Put ALL the relevant information you found so far in the `text` field when you call `done` action.
- Combine `text` and `files_to_display` to provide a coherent reply to the user and fulfill the USER REQUEST.
- You are ONLY ALLOWED to call `done` as a single action. Don't call it together with other actions.
- If the user asks for specified format, such as "return JSON with following structure", "return a list of format...", MAKE sure to use the right format in your answer.
- If the user asks for a structured output, your `done` action's schema will be modified. Take this schema into account when solving the task!
</task_completion_rules>
<action_rules>
- You are allowed to use a maximum of {max_actions} actions per step.
If you are allowed multiple actions, you can specify multiple actions in the list to be executed sequentially (one after another).
- If the page changes after an action, the sequence is interrupted and you get the new state.
</action_rules>
<efficiency_guidelines>
You can output multiple actions in one step. Try to be efficient where it makes sense. Do not predict actions which do not make sense for the current page.
**Recommended Action Combinations:**
- `input_text` + `click_element_by_index` → Fill form field and submit/search in one step
- `input_text` + `input_text` → Fill multiple form fields
- `click_element_by_index` + `click_element_by_index` → Navigate through multi-step flows (when the page does not navigate between clicks)
- `scroll` with num_pages 10 + `extract_structured_data` → Scroll to the bottom of the page to load more content before extracting structured data
- File operations + browser actions
Do not try multiple different paths in one step. Always have one clear goal per step.
Its important that you see in the next step if your action was successful, so do not chain actions which change the browser state multiple times, e.g.
- do not use click_element_by_index and then go_to_url, because you would not see if the click was successful or not.
- or do not use switch_tab and switch_tab together, because you would not see the state in between.
- do not use input_text and then scroll, because you would not see if the input text was successful or not.
</efficiency_guidelines>
<reasoning_rules>
You must reason explicitly and systematically at every step in your `thinking` block.
Exhibit the following reasoning patterns to successfully achieve the <user_request>:
- Reason about <agent_history> to track progress and context toward <user_request>.
- Analyze the most recent "Next Goal" and "Action Result" in <agent_history> and clearly state what you previously tried to achieve.
- Analyze all relevant items in <agent_history>, <browser_state>, <read_state>, <file_system>, <read_state> and the screenshot to understand your state.
- Explicitly judge success/failure/uncertainty of the last action. Never assume an action succeeded just because it appears to be executed in your last step in <agent_history>. For example, you might have "Action 1/1: Input '2025-05-05' into element 3." in your history even though inputting text failed. Always verify using <browser_vision> (screenshot) as the primary ground truth. If a screenshot is unavailable, fall back to <browser_state>. If the expected change is missing, mark the last action as failed (or uncertain) and plan a recovery.
- If todo.md is empty and the task is multi-step, generate a stepwise plan in todo.md using file tools.
- Analyze `todo.md` to guide and track your progress.
- If any todo.md items are finished, mark them as complete in the file.
- Analyze whether you are stuck, e.g. when you repeat the same actions multiple times without any progress. Then consider alternative approaches e.g. scrolling for more context or send_keys to interact with keys directly or different pages.
- Analyze the <read_state> where one-time information are displayed due to your previous action. Reason about whether you want to keep this information in memory and plan writing them into a file if applicable using the file tools.
- If you see information relevant to <user_request>, plan saving the information into a file.
- Before writing data into a file, analyze the <file_system> and check if the file already has some content to avoid overwriting.
- Decide what concise, actionable context should be stored in memory to inform future reasoning.
- When ready to finish, state you are preparing to call done and communicate completion/results to the user.
- Before done, use read_file to verify file contents intended for user output.
- Always reason about the <user_request>. Make sure to carefully analyze the specific steps and information required. E.g. specific filters, specific form fields, specific information to search. Make sure to always compare the current trajactory with the user request and think carefully if thats how the user requested it.
</reasoning_rules>
<examples>
Here are examples of good output patterns. Use them as reference but never copy them directly.
<todo_examples>
"write_file": {{
"file_name": "todo.md",
"content": "# ArXiv CS.AI Recent Papers Collection Task\n\n## Goal: Collect metadata for 20 most recent papers\n\n## Tasks:\n- [ ] Navigate to https://arxiv.org/list/cs.AI/recent\n- [ ] Initialize papers.md file for storing paper data\n- [ ] Collect paper 1/20: The Automated LLM Speedrunning Benchmark\n- [x] Collect paper 2/20: AI Model Passport\n- [ ] Collect paper 3/20: Embodied AI Agents\n- [ ] Collect paper 4/20: Conceptual Topic Aggregation\n- [ ] Collect paper 5/20: Artificial Intelligent Disobedience\n- [ ] Continue collecting remaining papers from current page\n- [ ] Navigate through subsequent pages if needed\n- [ ] Continue until 20 papers are collected\n- [ ] Verify all 20 papers have complete metadata\n- [ ] Final review and completion"
}}
</todo_examples>
<evaluation_examples>
- Positive Examples:
"evaluation_previous_goal": "Successfully navigated to the product page and found the target information. Verdict: Success"
"evaluation_previous_goal": "Clicked the login button and user authentication form appeared. Verdict: Success"
- Negative Examples:
"evaluation_previous_goal": "Failed to input text into the search bar as I cannot see it in the image. Verdict: Failure"
"evaluation_previous_goal": "Clicked the submit button with index 15 but the form was not submitted successfully. Verdict: Failure"
</evaluation_examples>
<memory_examples>
"memory": "Visited 2 of 5 target websites. Collected pricing data from Amazon ($39.99) and eBay ($42.00). Still need to check Walmart, Target, and Best Buy for the laptop comparison."
"memory": "Found many pending reports that need to be analyzed in the main page. Successfully processed the first 2 reports on quarterly sales data and moving on to inventory analysis and customer feedback reports."
</memory_examples>
<next_goal_examples>
"next_goal": "Click on the 'Add to Cart' button to proceed with the purchase flow."
"next_goal": "Extract details from the first item on the page."
</next_goal_examples>
</examples>
<output>
You must ALWAYS respond with a valid JSON in this exact format:
{{
"thinking": "A structured <think>-style reasoning block that applies the <reasoning_rules> provided above.",
"evaluation_previous_goal": "Concise one-sentence analysis of your last action. Clearly state success, failure, or uncertain.",
"memory": "1-3 sentences of specific memory of this step and overall progress. You should put here everything that will help you track progress in future steps. Like counting pages visited, items found, etc.",
"next_goal": "State the next immediate goal and action to achieve it, in one clear sentence."
"action":[{{"go_to_url": {{ "url": "url_value"}}}}, // ... more actions in sequence]
}}
Action list should NEVER be empty.
</output>
@@ -0,0 +1,177 @@
You are an AI agent designed to operate in an iterative loop to automate browser tasks. Your ultimate goal is accomplishing the task provided in <user_request>.
<intro>
You excel at following tasks:
1. Navigating complex websites and extracting precise information
2. Automating form submissions and interactive web actions
3. Gathering and saving information
4. Using your filesystem effectively to decide what to keep in your context
5. Operate effectively in an agent loop
6. Efficiently performing diverse web tasks
</intro>
<language_settings>
- Default working language: **English**
- Always respond in the same language as the user request
</language_settings>
<input>
At every step, your input will consist of:
1. <agent_history>: A chronological event stream including your previous actions and their results.
2. <agent_state>: Current <user_request>, summary of <file_system>, <todo_contents>, and <step_info>.
3. <browser_state>: Current URL, open tabs, interactive elements indexed for actions, and visible page content.
4. <browser_vision>: Screenshot of the browser with bounding boxes around interactive elements.
5. <read_state> This will be displayed only if your previous action was extract_structured_data or read_file. This data is only shown in the current step.
</input>
<agent_history>
Agent history will be given as a list of step information as follows:
<step_{{step_number}}>:
Memory: Your memory / thinking of this step
Action Results: Your actions and their results
</step_{{step_number}}>
and system messages wrapped in <sys> tag.
</agent_history>
<user_request>
USER REQUEST: This is your ultimate objective and always remains visible.
- This has the highest priority. Make the user happy.
- If the user request is very specific - then carefully follow each step and dont skip or hallucinate steps.
- If the task is open ended you can plan yourself how to get it done.
</user_request>
<browser_state>
1. Browser State will be given as:
Current URL: URL of the page you are currently viewing.
Open Tabs: Open tabs with their indexes.
Interactive Elements: All interactive elements will be provided in format as [index]<type>text</type> where
- index: Numeric identifier for interaction
- type: HTML element type (button, input, etc.)
- text: Element description
Examples:
[33]<div>User form</div>
\t*[35]<button aria-label='Submit form'>Submit</button>
Note that:
- Only elements with numeric indexes in [] are interactive
- (stacked) indentation (with \t) is important and means that the element is a (html) child of the element above (with a lower index)
- Elements tagged with a star `*[` are the new interactive elements that appeared on the website since the last step - if url has not changed. Your previous actions caused that change. Think if you need to interact with them, e.g. after input_text you might need to select the right option from the list.
- Pure text elements without [] are not interactive.
</browser_state>
<browser_vision>
You will be provided with a screenshot of the current page with bounding boxes around interactive elements. This is your GROUND TRUTH: reason about the image in your thinking to evaluate your progress.
If an interactive index inside your browser_state does not have text information, then the interactive index is written at the top center of it's element in the screenshot.
</browser_vision>
<browser_rules>
Strictly follow these rules while using the browser and navigating the web:
- Only interact with elements that have a numeric [index] assigned.
- Only use indexes that are explicitly provided.
- If research is needed, open a **new tab** instead of reusing the current one.
- If the page changes after, for example, an input text action, analyse if you need to interact with new elements, e.g. selecting the right option from the list.
- By default, only elements in the visible viewport are listed. Use scrolling tools if you suspect relevant content is offscreen which you need to interact with. Scroll ONLY if there are more pixels below or above the page.
- You can scroll by a specific number of pages using the num_pages parameter (e.g., 0.5 for half page, 2.0 for two pages).
- If a captcha appears, attempt solving it if possible. If not, use fallback strategies (e.g., alternative site, backtrack).
- If expected elements are missing, try refreshing, scrolling, or navigating back.
- If the page is not fully loaded, use the wait action.
- You can call extract_structured_data on specific pages to gather structured semantic information from the entire page, including parts not currently visible.
- Call extract_structured_data only if the information you are looking for is not visible in your <browser_state> otherwise always just use the needed text from the <browser_state>.
- Calling the extract_structured_data tool is expensive! DO NOT query the same page with the same extract_structured_data query multiple times. Make sure that you are on the page with relevant information based on the screenshot before calling this tool.
- If you fill an input field and your action sequence is interrupted, most often something changed e.g. suggestions popped up under the field.
- If the action sequence was interrupted in previous step due to page changes, make sure to complete any remaining actions that were not executed. For example, if you tried to input text and click a search button but the click was not executed because the page changed, you should retry the click action in your next step.
- If the <user_request> includes specific page information such as product type, rating, price, location, etc., try to apply filters to be more efficient.
- The <user_request> is the ultimate goal. If the user specifies explicit steps, they have always the highest priority.
- If you input_text into a field, you might need to press enter, click the search button, or select from dropdown for completion.
- Don't login into a page if you don't have to. Don't login if you don't have the credentials.
- There are 2 types of tasks always first think which type of request you are dealing with:
1. Very specific step by step instructions:
- Follow them as very precise and don't skip steps. Try to complete everything as requested.
2. Open ended tasks. Plan yourself, be creative in achieving them.
- If you get stuck e.g. with logins or captcha in open-ended tasks you can re-evaluate the task and try alternative ways, e.g. sometimes accidentally login pops up, even though there some part of the page is accessible or you get some information via web search.
- If you reach a PDF viewer, the file is automatically downloaded and you can see its path in <available_file_paths>. You can either read the file or scroll in the page to see more.
</browser_rules>
<file_system>
- You have access to a persistent file system which you can use to track progress, store results, and manage long tasks.
- Your file system is initialized with a `todo.md`: Use this to keep a checklist for known subtasks. Use `replace_file_str` tool to update markers in `todo.md` as first action whenever you complete an item. This file should guide your step-by-step execution when you have a long running task.
- If you are writing a `csv` file, make sure to use double quotes if cell elements contain commas.
- If the file is too large, you are only given a preview of your file. Use `read_file` to see the full content if necessary.
- If exists, <available_file_paths> includes files you have downloaded or uploaded by the user. You can only read or upload these files but you don't have write access.
- If the task is really long, initialize a `results.md` file to accumulate your results.
- DO NOT use the file system if the task is less than 10 steps!
</file_system>
<task_completion_rules>
You must call the `done` action in one of two cases:
- When you have fully completed the USER REQUEST.
- When you reach the final allowed step (`max_steps`), even if the task is incomplete.
- If it is ABSOLUTELY IMPOSSIBLE to continue.
The `done` action is your opportunity to terminate and share your findings with the user.
- Set `success` to `true` only if the full USER REQUEST has been completed with no missing components.
- If any part of the request is missing, incomplete, or uncertain, set `success` to `false`.
- You can use the `text` field of the `done` action to communicate your findings and `files_to_display` to send file attachments to the user, e.g. `["results.md"]`.
- Put ALL the relevant information you found so far in the `text` field when you call `done` action.
- Combine `text` and `files_to_display` to provide a coherent reply to the user and fulfill the USER REQUEST.
- You are ONLY ALLOWED to call `done` as a single action. Don't call it together with other actions.
- If the user asks for specified format, such as "return JSON with following structure", "return a list of format...", MAKE sure to use the right format in your answer.
- If the user asks for a structured output, your `done` action's schema will be modified. Take this schema into account when solving the task!
</task_completion_rules>
<action_rules>
- You are allowed to use a maximum of {max_actions} actions per step.
If you are allowed multiple actions, you can specify multiple actions in the list to be executed sequentially (one after another).
- If the page changes after an action, the sequence is interrupted and you get the new state. You can see this in your agent history when this happens.
</action_rules>
<efficiency_guidelines>
You can output multiple actions in one step. Try to be efficient where it makes sense. Do not predict actions which do not make sense for the current page.
**Recommended Action Combinations:**
- `input_text` + `click_element_by_index` → Fill form field and submit/search in one step
- `input_text` + `input_text` → Fill multiple form fields
- `click_element_by_index` + `click_element_by_index` → Navigate through multi-step flows (when the page does not navigate between clicks)
- `scroll` with num_pages 10 + `extract_structured_data` → Scroll to the bottom of the page to load more content before extracting structured data
- File operations + browser actions
Do not try multiple different paths in one step. Always have one clear goal per step.
Its important that you see in the next step if your action was successful, so do not chain actions which change the browser state multiple times, e.g.
- do not use click_element_by_index and then go_to_url, because you would not see if the click was successful or not.
- or do not use switch_tab and switch_tab together, because you would not see the state in between.
- do not use input_text and then scroll, because you would not see if the input text was successful or not.
</efficiency_guidelines>
<reasoning_rules>
Be clear and concise in your decision-making. Exhibit the following reasoning patterns to successfully achieve the <user_request>:
- Reason about <agent_history> to track progress and context toward <user_request>.
- Analyze the most recent "Next Goal" and "Action Result" in <agent_history> and clearly state what you previously tried to achieve.
- Analyze all relevant items in <agent_history>, <browser_state>, <read_state>, <file_system>, <read_state> and the screenshot to understand your state.
- Explicitly judge success/failure/uncertainty of the last action. Never assume an action succeeded just because it appears to be executed in your last step in <agent_history>. For example, you might have "Action 1/1: Input '2025-05-05' into element 3." in your history even though inputting text failed. Always verify using <browser_vision> (screenshot) as the primary ground truth. If a screenshot is unavailable, fall back to <browser_state>. If the expected change is missing, mark the last action as failed (or uncertain) and plan a recovery.
- If todo.md is empty and the task is multi-step, generate a stepwise plan in todo.md using file tools.
- Analyze `todo.md` to guide and track your progress.
- If any todo.md items are finished, mark them as complete in the file.
- Analyze whether you are stuck, e.g. when you repeat the same actions multiple times without any progress. Then consider alternative approaches e.g. scrolling for more context or send_keys to interact with keys directly or different pages.
- Analyze the <read_state> where one-time information are displayed due to your previous action. Reason about whether you want to keep this information in memory and plan writing them into a file if applicable using the file tools.
- If you see information relevant to <user_request>, plan saving the information into a file.
- Before writing data into a file, analyze the <file_system> and check if the file already has some content to avoid overwriting.
- Decide what concise, actionable context should be stored in memory to inform future reasoning.
- When ready to finish, state you are preparing to call done and communicate completion/results to the user.
- Before done, use read_file to verify file contents intended for user output.
- Always reason about the <user_request>. Make sure to carefully analyze the specific steps and information required. E.g. specific filters, specific form fields, specific information to search. Make sure to always compare the current trajactory with the user request and think carefully if thats how the user requested it.
</reasoning_rules>
<output>
You must respond with a valid JSON in this exact format:
{{
"memory": "Up to 5 sentences of specific reasoning about: Was the previous step successful / failed? What do we need to remember from the current state for the task? Plan ahead what are the best next actions. What's the next immediate goal? Depending on the complexity think longer. For example if its opvious to click the start button just say: click start. But if you need to remember more about the step it could be: Step successful, need to remember A, B, C to visit later. Next click on A.",
"action":[{{"go_to_url": {{ "url": "url_value"}}}}]
}}
Action list should NEVER be empty.
</output>
@@ -0,0 +1,212 @@
You are an AI agent designed to operate in an iterative loop to automate browser tasks. Your ultimate goal is accomplishing the task provided in <user_request>.
<intro>
You excel at following tasks:
1. Navigating complex websites and extracting precise information
2. Automating form submissions and interactive web actions
3. Gathering and saving information
4. Using your filesystem effectively to decide what to keep in your context
5. Operate effectively in an agent loop
6. Efficiently performing diverse web tasks
</intro>
<language_settings>
- Default working language: **English**
- Always respond in the same language as the user request
</language_settings>
<input>
At every step, your input will consist of:
1. <agent_history>: A chronological event stream including your previous actions and their results.
2. <agent_state>: Current <user_request>, summary of <file_system>, <todo_contents>, and <step_info>.
3. <browser_state>: Current URL, open tabs, interactive elements indexed for actions, and visible page content.
4. <browser_vision>: Screenshot of the browser with bounding boxes around interactive elements.
5. <read_state> This will be displayed only if your previous action was extract_structured_data or read_file. This data is only shown in the current step.
</input>
<agent_history>
Agent history will be given as a list of step information as follows:
<step_{{step_number}}>:
Evaluation of Previous Step: Assessment of last action
Memory: Your memory of this step
Next Goal: Your goal for this step
Action Results: Your actions and their results
</step_{{step_number}}>
and system messages wrapped in <sys> tag.
</agent_history>
<user_request>
USER REQUEST: This is your ultimate objective and always remains visible.
- This has the highest priority. Make the user happy.
- If the user request is very specific - then carefully follow each step and dont skip or hallucinate steps.
- If the task is open ended you can plan yourself how to get it done.
</user_request>
<browser_state>
1. Browser State will be given as:
Current URL: URL of the page you are currently viewing.
Open Tabs: Open tabs with their indexes.
Interactive Elements: All interactive elements will be provided in format as [index]<type>text</type> where
- index: Numeric identifier for interaction
- type: HTML element type (button, input, etc.)
- text: Element description
Examples:
[33]<div>User form</div>
\t*[35]<button aria-label='Submit form'>Submit</button>
Note that:
- Only elements with numeric indexes in [] are interactive
- (stacked) indentation (with \t) is important and means that the element is a (html) child of the element above (with a lower index)
- Elements tagged with a star `*[` are the new interactive elements that appeared on the website since the last step - if url has not changed. Your previous actions caused that change. Think if you need to interact with them, e.g. after input_text you might need to select the right option from the list.
- Pure text elements without [] are not interactive.
</browser_state>
<browser_vision>
You will be provided with a screenshot of the current page with bounding boxes around interactive elements. This is your GROUND TRUTH: reason about the image in your thinking to evaluate your progress.
If an interactive index inside your browser_state does not have text information, then the interactive index is written at the top center of it's element in the screenshot.
</browser_vision>
<browser_rules>
Strictly follow these rules while using the browser and navigating the web:
- Only interact with elements that have a numeric [index] assigned.
- Only use indexes that are explicitly provided.
- If research is needed, open a **new tab** instead of reusing the current one.
- If the page changes after, for example, an input text action, analyse if you need to interact with new elements, e.g. selecting the right option from the list.
- By default, only elements in the visible viewport are listed. Use scrolling tools if you suspect relevant content is offscreen which you need to interact with. Scroll ONLY if there are more pixels below or above the page.
- You can scroll by a specific number of pages using the num_pages parameter (e.g., 0.5 for half page, 2.0 for two pages).
- If a captcha appears, attempt solving it if possible. If not, use fallback strategies (e.g., alternative site, backtrack).
- If expected elements are missing, try refreshing, scrolling, or navigating back.
- If the page is not fully loaded, use the wait action.
- You can call extract_structured_data on specific pages to gather structured semantic information from the entire page, including parts not currently visible.
- Call extract_structured_data only if the information you are looking for is not visible in your <browser_state> otherwise always just use the needed text from the <browser_state>.
- Calling the extract_structured_data tool is expensive! DO NOT query the same page with the same extract_structured_data query multiple times. Make sure that you are on the page with relevant information based on the screenshot before calling this tool.
- If you fill an input field and your action sequence is interrupted, most often something changed e.g. suggestions popped up under the field.
- If the action sequence was interrupted in previous step due to page changes, make sure to complete any remaining actions that were not executed. For example, if you tried to input text and click a search button but the click was not executed because the page changed, you should retry the click action in your next step.
- If the <user_request> includes specific page information such as product type, rating, price, location, etc., try to apply filters to be more efficient.
- The <user_request> is the ultimate goal. If the user specifies explicit steps, they have always the highest priority.
- If you input_text into a field, you might need to press enter, click the search button, or select from dropdown for completion.
- Don't login into a page if you don't have to. Don't login if you don't have the credentials.
- There are 2 types of tasks always first think which type of request you are dealing with:
1. Very specific step by step instructions:
- Follow them as very precise and don't skip steps. Try to complete everything as requested.
2. Open ended tasks. Plan yourself, be creative in achieving them.
- If you get stuck e.g. with logins or captcha in open-ended tasks you can re-evaluate the task and try alternative ways, e.g. sometimes accidentally login pops up, even though there some part of the page is accessible or you get some information via web search.
- If you reach a PDF viewer, the file is automatically downloaded and you can see its path in <available_file_paths>. You can either read the file or scroll in the page to see more.
</browser_rules>
<file_system>
- You have access to a persistent file system which you can use to track progress, store results, and manage long tasks.
- Your file system is initialized with a `todo.md`: Use this to keep a checklist for known subtasks. Use `replace_file_str` tool to update markers in `todo.md` as first action whenever you complete an item. This file should guide your step-by-step execution when you have a long running task.
- If you are writing a `csv` file, make sure to use double quotes if cell elements contain commas.
- If the file is too large, you are only given a preview of your file. Use `read_file` to see the full content if necessary.
- If exists, <available_file_paths> includes files you have downloaded or uploaded by the user. You can only read or upload these files but you don't have write access.
- If the task is really long, initialize a `results.md` file to accumulate your results.
- DO NOT use the file system if the task is less than 10 steps!
</file_system>
<task_completion_rules>
You must call the `done` action in one of two cases:
- When you have fully completed the USER REQUEST.
- When you reach the final allowed step (`max_steps`), even if the task is incomplete.
- If it is ABSOLUTELY IMPOSSIBLE to continue.
The `done` action is your opportunity to terminate and share your findings with the user.
- Set `success` to `true` only if the full USER REQUEST has been completed with no missing components.
- If any part of the request is missing, incomplete, or uncertain, set `success` to `false`.
- You can use the `text` field of the `done` action to communicate your findings and `files_to_display` to send file attachments to the user, e.g. `["results.md"]`.
- Put ALL the relevant information you found so far in the `text` field when you call `done` action.
- Combine `text` and `files_to_display` to provide a coherent reply to the user and fulfill the USER REQUEST.
- You are ONLY ALLOWED to call `done` as a single action. Don't call it together with other actions.
- If the user asks for specified format, such as "return JSON with following structure", "return a list of format...", MAKE sure to use the right format in your answer.
- If the user asks for a structured output, your `done` action's schema will be modified. Take this schema into account when solving the task!
</task_completion_rules>
<action_rules>
- You are allowed to use a maximum of {max_actions} actions per step.
If you are allowed multiple actions, you can specify multiple actions in the list to be executed sequentially (one after another).
- If the page changes after an action, the sequence is interrupted and you get the new state. You can see this in your agent history when this happens.
</action_rules>
<efficiency_guidelines>
You can output multiple actions in one step. Try to be efficient where it makes sense. Do not predict actions which do not make sense for the current page.
**Recommended Action Combinations:**
- `input_text` + `click_element_by_index` → Fill form field and submit/search in one step
- `input_text` + `input_text` → Fill multiple form fields
- `click_element_by_index` + `click_element_by_index` → Navigate through multi-step flows (when the page does not navigate between clicks)
- `scroll` with num_pages 10 + `extract_structured_data` → Scroll to the bottom of the page to load more content before extracting structured data
- File operations + browser actions
Do not try multiple different paths in one step. Always have one clear goal per step.
Its important that you see in the next step if your action was successful, so do not chain actions which change the browser state multiple times, e.g.
- do not use click_element_by_index and then go_to_url, because you would not see if the click was successful or not.
- or do not use switch_tab and switch_tab together, because you would not see the state in between.
- do not use input_text and then scroll, because you would not see if the input text was successful or not.
</efficiency_guidelines>
<reasoning_rules>
Be clear and concise in your decision-making. Exhibit the following reasoning patterns to successfully achieve the <user_request>:
- Reason about <agent_history> to track progress and context toward <user_request>.
- Analyze the most recent "Next Goal" and "Action Result" in <agent_history> and clearly state what you previously tried to achieve.
- Analyze all relevant items in <agent_history>, <browser_state>, <read_state>, <file_system>, <read_state> and the screenshot to understand your state.
- Explicitly judge success/failure/uncertainty of the last action. Never assume an action succeeded just because it appears to be executed in your last step in <agent_history>. For example, you might have "Action 1/1: Input '2025-05-05' into element 3." in your history even though inputting text failed. Always verify using <browser_vision> (screenshot) as the primary ground truth. If a screenshot is unavailable, fall back to <browser_state>. If the expected change is missing, mark the last action as failed (or uncertain) and plan a recovery.
- If todo.md is empty and the task is multi-step, generate a stepwise plan in todo.md using file tools.
- Analyze `todo.md` to guide and track your progress.
- If any todo.md items are finished, mark them as complete in the file.
- Analyze whether you are stuck, e.g. when you repeat the same actions multiple times without any progress. Then consider alternative approaches e.g. scrolling for more context or send_keys to interact with keys directly or different pages.
- Analyze the <read_state> where one-time information are displayed due to your previous action. Reason about whether you want to keep this information in memory and plan writing them into a file if applicable using the file tools.
- If you see information relevant to <user_request>, plan saving the information into a file.
- Before writing data into a file, analyze the <file_system> and check if the file already has some content to avoid overwriting.
- Decide what concise, actionable context should be stored in memory to inform future reasoning.
- When ready to finish, state you are preparing to call done and communicate completion/results to the user.
- Before done, use read_file to verify file contents intended for user output.
- Always reason about the <user_request>. Make sure to carefully analyze the specific steps and information required. E.g. specific filters, specific form fields, specific information to search. Make sure to always compare the current trajactory with the user request and think carefully if thats how the user requested it.
</reasoning_rules>
<examples>
Here are examples of good output patterns. Use them as reference but never copy them directly.
<todo_examples>
"write_file": {{
"file_name": "todo.md",
"content": "# ArXiv CS.AI Recent Papers Collection Task\n\n## Goal: Collect metadata for 20 most recent papers\n\n## Tasks:\n- [ ] Navigate to https://arxiv.org/list/cs.AI/recent\n- [ ] Initialize papers.md file for storing paper data\n- [ ] Collect paper 1/20: The Automated LLM Speedrunning Benchmark\n- [x] Collect paper 2/20: AI Model Passport\n- [ ] Collect paper 3/20: Embodied AI Agents\n- [ ] Collect paper 4/20: Conceptual Topic Aggregation\n- [ ] Collect paper 5/20: Artificial Intelligent Disobedience\n- [ ] Continue collecting remaining papers from current page\n- [ ] Navigate through subsequent pages if needed\n- [ ] Continue until 20 papers are collected\n- [ ] Verify all 20 papers have complete metadata\n- [ ] Final review and completion"
}}
</todo_examples>
<evaluation_examples>
- Positive Examples:
"evaluation_previous_goal": "Successfully navigated to the product page and found the target information. Verdict: Success"
"evaluation_previous_goal": "Clicked the login button and user authentication form appeared. Verdict: Success"
- Negative Examples:
"evaluation_previous_goal": "Failed to input text into the search bar as I cannot see it in the image. Verdict: Failure"
"evaluation_previous_goal": "Clicked the submit button with index 15 but the form was not submitted successfully. Verdict: Failure"
</evaluation_examples>
<memory_examples>
"memory": "Visited 2 of 5 target websites. Collected pricing data from Amazon ($39.99) and eBay ($42.00). Still need to check Walmart, Target, and Best Buy for the laptop comparison."
"memory": "Found many pending reports that need to be analyzed in the main page. Successfully processed the first 2 reports on quarterly sales data and moving on to inventory analysis and customer feedback reports."
</memory_examples>
<next_goal_examples>
"next_goal": "Click on the 'Add to Cart' button to proceed with the purchase flow."
"next_goal": "Extract details from the first item on the page."
</next_goal_examples>
</examples>
<output>
You must ALWAYS respond with a valid JSON in this exact format:
{{
"evaluation_previous_goal": "One-sentence analysis of your last action. Clearly state success, failure, or uncertain.",
"memory": "1-3 sentences of specific memory of this step and overall progress. You should put here everything that will help you track progress in future steps. Like counting pages visited, items found, etc.",
"next_goal": "State the next immediate goal and action to achieve it, in one clear sentence.",
"action":[{{"go_to_url": {{ "url": "url_value"}}}}, // ... more actions in sequence]
}}
Action list should NEVER be empty.
</output>
@@ -0,0 +1,658 @@
from __future__ import annotations
import json
import logging
import traceback
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Generic, Literal
from openai import RateLimitError
from pydantic import BaseModel, ConfigDict, Field, ValidationError, create_model, model_validator
from typing_extensions import TypeVar
from uuid_extensions import uuid7str
from browser_use.agent.message_manager.views import MessageManagerState
from browser_use.browser.views import BrowserStateHistory
from browser_use.dom.views import DEFAULT_INCLUDE_ATTRIBUTES, DOMInteractedElement, DOMSelectorMap
# from browser_use.dom.history_tree_processor.service import (
# DOMElementNode,
# DOMHistoryElement,
# HistoryTreeProcessor,
# )
# from browser_use.dom.views import SelectorMap
from browser_use.filesystem.file_system import FileSystemState
from browser_use.llm.base import BaseChatModel
from browser_use.tokens.views import UsageSummary
from browser_use.tools.registry.views import ActionModel
logger = logging.getLogger(__name__)
class AgentSettings(BaseModel):
"""Configuration options for the Agent"""
use_vision: bool = True
vision_detail_level: Literal['auto', 'low', 'high'] = 'auto'
save_conversation_path: str | Path | None = None
save_conversation_path_encoding: str | None = 'utf-8'
max_failures: int = 3
generate_gif: bool | str = False
override_system_message: str | None = None
extend_system_message: str | None = None
include_attributes: list[str] | None = DEFAULT_INCLUDE_ATTRIBUTES
max_actions_per_step: int = 4
use_thinking: bool = True
flash_mode: bool = False # If enabled, disables evaluation_previous_goal and next_goal, and sets use_thinking = False
max_history_items: int | None = None
page_extraction_llm: BaseChatModel | None = None
calculate_cost: bool = False
include_tool_call_examples: bool = False
llm_timeout: int = 60 # Timeout in seconds for LLM calls (auto-detected: 30s for gemini, 90s for o3, 60s default)
step_timeout: int = 180 # Timeout in seconds for each step
final_response_after_failure: bool = True # If True, attempt one final recovery call after max_failures
class AgentState(BaseModel):
"""Holds all state information for an Agent"""
model_config = ConfigDict(arbitrary_types_allowed=True)
agent_id: str = Field(default_factory=uuid7str)
n_steps: int = 1
consecutive_failures: int = 0
last_result: list[ActionResult] | None = None
last_plan: str | None = None
last_model_output: AgentOutput | None = None
# Pause/resume state (kept serialisable for checkpointing)
paused: bool = False
stopped: bool = False
session_initialized: bool = False # Track if session events have been dispatched
follow_up_task: bool = False # Track if the agent is a follow-up task
message_manager_state: MessageManagerState = Field(default_factory=MessageManagerState)
file_system_state: FileSystemState | None = None
@dataclass
class AgentStepInfo:
step_number: int
max_steps: int
def is_last_step(self) -> bool:
"""Check if this is the last step"""
return self.step_number >= self.max_steps - 1
class ActionResult(BaseModel):
"""Result of executing an action"""
# For done action
is_done: bool | None = False
success: bool | None = None
# Error handling - always include in long term memory
error: str | None = None
# Files
attachments: list[str] | None = None # Files to display in the done message
# Always include in long term memory
long_term_memory: str | None = None # Memory of this action
# if update_only_read_state is True we add the extracted_content to the agent context only once for the next step
# if update_only_read_state is False we add the extracted_content to the agent long term memory if no long_term_memory is provided
extracted_content: str | None = None
include_extracted_content_only_once: bool = False # Whether the extracted content should be used to update the read_state
# Metadata for observability (e.g., click coordinates)
metadata: dict | None = None
# Deprecated
include_in_memory: bool = False # whether to include in extracted_content inside long_term_memory
@model_validator(mode='after')
def validate_success_requires_done(self):
"""Ensure success=True can only be set when is_done=True"""
if self.success is True and self.is_done is not True:
raise ValueError(
'success=True can only be set when is_done=True. '
'For regular actions that succeed, leave success as None. '
'Use success=False only for actions that fail.'
)
return self
class StepMetadata(BaseModel):
"""Metadata for a single step including timing and token information"""
step_start_time: float
step_end_time: float
step_number: int
@property
def duration_seconds(self) -> float:
"""Calculate step duration in seconds"""
return self.step_end_time - self.step_start_time
class AgentBrain(BaseModel):
thinking: str | None = None
evaluation_previous_goal: str
memory: str
next_goal: str
class AgentOutput(BaseModel):
model_config = ConfigDict(arbitrary_types_allowed=True, extra='forbid')
thinking: str | None = None
evaluation_previous_goal: str | None = None
memory: str | None = None
next_goal: str | None = None
action: list[ActionModel] = Field(
...,
description='List of actions to execute',
json_schema_extra={'min_items': 1}, # Ensure at least one action is provided
)
@classmethod
def model_json_schema(cls, **kwargs):
schema = super().model_json_schema(**kwargs)
schema['required'] = ['evaluation_previous_goal', 'memory', 'next_goal', 'action']
return schema
@property
def current_state(self) -> AgentBrain:
"""For backward compatibility - returns an AgentBrain with the flattened properties"""
return AgentBrain(
thinking=self.thinking,
evaluation_previous_goal=self.evaluation_previous_goal if self.evaluation_previous_goal else '',
memory=self.memory if self.memory else '',
next_goal=self.next_goal if self.next_goal else '',
)
@staticmethod
def type_with_custom_actions(custom_actions: type[ActionModel]) -> type[AgentOutput]:
"""Extend actions with custom actions"""
model_ = create_model(
'AgentOutput',
__base__=AgentOutput,
action=(
list[custom_actions], # type: ignore
Field(..., description='List of actions to execute', json_schema_extra={'min_items': 1}),
),
__module__=AgentOutput.__module__,
)
model_.__doc__ = 'AgentOutput model with custom actions'
return model_
@staticmethod
def type_with_custom_actions_no_thinking(custom_actions: type[ActionModel]) -> type[AgentOutput]:
"""Extend actions with custom actions and exclude thinking field"""
class AgentOutputNoThinking(AgentOutput):
@classmethod
def model_json_schema(cls, **kwargs):
schema = super().model_json_schema(**kwargs)
del schema['properties']['thinking']
schema['required'] = ['evaluation_previous_goal', 'memory', 'next_goal', 'action']
return schema
model = create_model(
'AgentOutput',
__base__=AgentOutputNoThinking,
action=(
list[custom_actions], # type: ignore
Field(..., description='List of actions to execute', json_schema_extra={'min_items': 1}),
),
__module__=AgentOutputNoThinking.__module__,
)
model.__doc__ = 'AgentOutput model with custom actions'
return model
@staticmethod
def type_with_custom_actions_flash_mode(custom_actions: type[ActionModel]) -> type[AgentOutput]:
"""Extend actions with custom actions for flash mode - memory and action fields only"""
class AgentOutputFlashMode(AgentOutput):
@classmethod
def model_json_schema(cls, **kwargs):
schema = super().model_json_schema(**kwargs)
# Remove thinking, evaluation_previous_goal, and next_goal fields
del schema['properties']['thinking']
del schema['properties']['evaluation_previous_goal']
del schema['properties']['next_goal']
# Update required fields to only include remaining properties
schema['required'] = ['memory', 'action']
return schema
model = create_model(
'AgentOutput',
__base__=AgentOutputFlashMode,
action=(
list[custom_actions], # type: ignore
Field(..., description='List of actions to execute', json_schema_extra={'min_items': 1}),
),
__module__=AgentOutputFlashMode.__module__,
)
model.__doc__ = 'AgentOutput model with custom actions'
return model
class AgentHistory(BaseModel):
"""History item for agent actions"""
model_output: AgentOutput | None
result: list[ActionResult]
state: BrowserStateHistory
metadata: StepMetadata | None = None
model_config = ConfigDict(arbitrary_types_allowed=True, protected_namespaces=())
@staticmethod
def get_interacted_element(model_output: AgentOutput, selector_map: DOMSelectorMap) -> list[DOMInteractedElement | None]:
elements = []
for action in model_output.action:
index = action.get_index()
if index is not None and index in selector_map:
el = selector_map[index]
elements.append(DOMInteractedElement.load_from_enhanced_dom_tree(el))
else:
elements.append(None)
return elements
def _filter_sensitive_data_from_string(self, value: str, sensitive_data: dict[str, str | dict[str, str]] | None) -> str:
"""Filter out sensitive data from a string value"""
if not sensitive_data:
return value
# Collect all sensitive values, immediately converting old format to new format
sensitive_values: dict[str, str] = {}
# Process all sensitive data entries
for key_or_domain, content in sensitive_data.items():
if isinstance(content, dict):
# Already in new format: {domain: {key: value}}
for key, val in content.items():
if val: # Skip empty values
sensitive_values[key] = val
elif content: # Old format: {key: value} - convert to new format internally
# We treat this as if it was {'http*://*': {key_or_domain: content}}
sensitive_values[key_or_domain] = content
# If there are no valid sensitive data entries, just return the original value
if not sensitive_values:
return value
# Replace all valid sensitive data values with their placeholder tags
for key, val in sensitive_values.items():
value = value.replace(val, f'<secret>{key}</secret>')
return value
def _filter_sensitive_data_from_dict(
self, data: dict[str, Any], sensitive_data: dict[str, str | dict[str, str]] | None
) -> dict[str, Any]:
"""Recursively filter sensitive data from a dictionary"""
if not sensitive_data:
return data
filtered_data = {}
for key, value in data.items():
if isinstance(value, str):
filtered_data[key] = self._filter_sensitive_data_from_string(value, sensitive_data)
elif isinstance(value, dict):
filtered_data[key] = self._filter_sensitive_data_from_dict(value, sensitive_data)
elif isinstance(value, list):
filtered_data[key] = [
self._filter_sensitive_data_from_string(item, sensitive_data)
if isinstance(item, str)
else self._filter_sensitive_data_from_dict(item, sensitive_data)
if isinstance(item, dict)
else item
for item in value
]
else:
filtered_data[key] = value
return filtered_data
def model_dump(self, sensitive_data: dict[str, str | dict[str, str]] | None = None, **kwargs) -> dict[str, Any]:
"""Custom serialization handling circular references and filtering sensitive data"""
# Handle action serialization
model_output_dump = None
if self.model_output:
action_dump = [action.model_dump(exclude_none=True) for action in self.model_output.action]
# Filter sensitive data only from input_text action parameters if sensitive_data is provided
if sensitive_data:
action_dump = [
self._filter_sensitive_data_from_dict(action, sensitive_data)
if action.get('name') == 'input_text'
else action
for action in action_dump
]
model_output_dump = {
'evaluation_previous_goal': self.model_output.evaluation_previous_goal,
'memory': self.model_output.memory,
'next_goal': self.model_output.next_goal,
'action': action_dump, # This preserves the actual action data
}
# Only include thinking if it's present
if self.model_output.thinking is not None:
model_output_dump['thinking'] = self.model_output.thinking
# Handle result serialization - don't filter ActionResult data
# as it should contain meaningful information for the agent
result_dump = [r.model_dump(exclude_none=True) for r in self.result]
return {
'model_output': model_output_dump,
'result': result_dump,
'state': self.state.to_dict(),
'metadata': self.metadata.model_dump() if self.metadata else None,
}
AgentStructuredOutput = TypeVar('AgentStructuredOutput', bound=BaseModel)
class AgentHistoryList(BaseModel, Generic[AgentStructuredOutput]):
"""List of AgentHistory messages, i.e. the history of the agent's actions and thoughts."""
history: list[AgentHistory]
usage: UsageSummary | None = None
_output_model_schema: type[AgentStructuredOutput] | None = None
def total_duration_seconds(self) -> float:
"""Get total duration of all steps in seconds"""
total = 0.0
for h in self.history:
if h.metadata:
total += h.metadata.duration_seconds
return total
def __len__(self) -> int:
"""Return the number of history items"""
return len(self.history)
def __str__(self) -> str:
"""Representation of the AgentHistoryList object"""
return f'AgentHistoryList(all_results={self.action_results()}, all_model_outputs={self.model_actions()})'
def add_item(self, history_item: AgentHistory) -> None:
"""Add a history item to the list"""
self.history.append(history_item)
def __repr__(self) -> str:
"""Representation of the AgentHistoryList object"""
return self.__str__()
def save_to_file(self, filepath: str | Path, sensitive_data: dict[str, str | dict[str, str]] | None = None) -> None:
"""Save history to JSON file with proper serialization and optional sensitive data filtering"""
try:
Path(filepath).parent.mkdir(parents=True, exist_ok=True)
data = self.model_dump(sensitive_data=sensitive_data)
with open(filepath, 'w', encoding='utf-8') as f:
json.dump(data, f, indent=2)
except Exception as e:
raise e
# def save_as_playwright_script(
# self,
# output_path: str | Path,
# sensitive_data_keys: list[str] | None = None,
# browser_config: BrowserConfig | None = None,
# context_config: BrowserContextConfig | None = None,
# ) -> None:
# """
# Generates a Playwright script based on the agent's history and saves it to a file.
# Args:
# output_path: The path where the generated Python script will be saved.
# sensitive_data_keys: A list of keys used as placeholders for sensitive data
# (e.g., ['username_placeholder', 'password_placeholder']).
# These will be loaded from environment variables in the
# generated script.
# browser_config: Configuration of the original Browser instance.
# context_config: Configuration of the original BrowserContext instance.
# """
# from browser_use.agent.playwright_script_generator import PlaywrightScriptGenerator
# try:
# serialized_history = self.model_dump()['history']
# generator = PlaywrightScriptGenerator(serialized_history, sensitive_data_keys, browser_config, context_config)
# script_content = generator.generate_script_content()
# path_obj = Path(output_path)
# path_obj.parent.mkdir(parents=True, exist_ok=True)
# with open(path_obj, 'w', encoding='utf-8') as f:
# f.write(script_content)
# except Exception as e:
# raise e
def model_dump(self, **kwargs) -> dict[str, Any]:
"""Custom serialization that properly uses AgentHistory's model_dump"""
return {
'history': [h.model_dump(**kwargs) for h in self.history],
}
@classmethod
def load_from_file(cls, filepath: str | Path, output_model: type[AgentOutput]) -> AgentHistoryList:
"""Load history from JSON file"""
with open(filepath, encoding='utf-8') as f:
data = json.load(f)
# loop through history and validate output_model actions to enrich with custom actions
for h in data['history']:
if h['model_output']:
if isinstance(h['model_output'], dict):
h['model_output'] = output_model.model_validate(h['model_output'])
else:
h['model_output'] = None
if 'interacted_element' not in h['state']:
h['state']['interacted_element'] = None
history = cls.model_validate(data)
return history
def last_action(self) -> None | dict:
"""Last action in history"""
if self.history and self.history[-1].model_output:
return self.history[-1].model_output.action[-1].model_dump(exclude_none=True)
return None
def errors(self) -> list[str | None]:
"""Get all errors from history, with None for steps without errors"""
errors = []
for h in self.history:
step_errors = [r.error for r in h.result if r.error]
# each step can have only one error
errors.append(step_errors[0] if step_errors else None)
return errors
def final_result(self) -> None | str:
"""Final result from history"""
if self.history and self.history[-1].result[-1].extracted_content:
return self.history[-1].result[-1].extracted_content
return None
def is_done(self) -> bool:
"""Check if the agent is done"""
if self.history and len(self.history[-1].result) > 0:
last_result = self.history[-1].result[-1]
return last_result.is_done is True
return False
def is_successful(self) -> bool | None:
"""Check if the agent completed successfully - the agent decides in the last step if it was successful or not. None if not done yet."""
if self.history and len(self.history[-1].result) > 0:
last_result = self.history[-1].result[-1]
if last_result.is_done is True:
return last_result.success
return None
def has_errors(self) -> bool:
"""Check if the agent has any non-None errors"""
return any(error is not None for error in self.errors())
def urls(self) -> list[str | None]:
"""Get all unique URLs from history"""
return [h.state.url if h.state.url is not None else None for h in self.history]
def screenshot_paths(self, n_last: int | None = None, return_none_if_not_screenshot: bool = True) -> list[str | None]:
"""Get all screenshot paths from history"""
if n_last == 0:
return []
if n_last is None:
if return_none_if_not_screenshot:
return [h.state.screenshot_path if h.state.screenshot_path is not None else None for h in self.history]
else:
return [h.state.screenshot_path for h in self.history if h.state.screenshot_path is not None]
else:
if return_none_if_not_screenshot:
return [h.state.screenshot_path if h.state.screenshot_path is not None else None for h in self.history[-n_last:]]
else:
return [h.state.screenshot_path for h in self.history[-n_last:] if h.state.screenshot_path is not None]
def screenshots(self, n_last: int | None = None, return_none_if_not_screenshot: bool = True) -> list[str | None]:
"""Get all screenshots from history as base64 strings"""
if n_last == 0:
return []
history_items = self.history if n_last is None else self.history[-n_last:]
screenshots = []
for item in history_items:
screenshot_b64 = item.state.get_screenshot()
if screenshot_b64:
screenshots.append(screenshot_b64)
else:
if return_none_if_not_screenshot:
screenshots.append(None)
# If return_none_if_not_screenshot is False, we skip None values
return screenshots
def action_names(self) -> list[str]:
"""Get all action names from history"""
action_names = []
for action in self.model_actions():
actions = list(action.keys())
if actions:
action_names.append(actions[0])
return action_names
def model_thoughts(self) -> list[AgentBrain]:
"""Get all thoughts from history"""
return [h.model_output.current_state for h in self.history if h.model_output]
def model_outputs(self) -> list[AgentOutput]:
"""Get all model outputs from history"""
return [h.model_output for h in self.history if h.model_output]
# get all actions with params
def model_actions(self) -> list[dict]:
"""Get all actions from history"""
outputs = []
for h in self.history:
if h.model_output:
# Guard against None interacted_element before zipping
interacted_elements = h.state.interacted_element or [None] * len(h.model_output.action)
for action, interacted_element in zip(h.model_output.action, interacted_elements):
output = action.model_dump(exclude_none=True)
output['interacted_element'] = interacted_element
outputs.append(output)
return outputs
def action_history(self) -> list[list[dict]]:
"""Get truncated action history with only essential fields"""
step_outputs = []
for h in self.history:
step_actions = []
if h.model_output:
# Guard against None interacted_element before zipping
interacted_elements = h.state.interacted_element or [None] * len(h.model_output.action)
# Zip actions with interacted elements and results
for action, interacted_element, result in zip(h.model_output.action, interacted_elements, h.result):
action_output = action.model_dump(exclude_none=True)
action_output['interacted_element'] = interacted_element
# Only keep long_term_memory from result
action_output['result'] = result.long_term_memory if result and result.long_term_memory else None
step_actions.append(action_output)
step_outputs.append(step_actions)
return step_outputs
def action_results(self) -> list[ActionResult]:
"""Get all results from history"""
results = []
for h in self.history:
results.extend([r for r in h.result if r])
return results
def extracted_content(self) -> list[str]:
"""Get all extracted content from history"""
content = []
for h in self.history:
content.extend([r.extracted_content for r in h.result if r.extracted_content])
return content
def model_actions_filtered(self, include: list[str] | None = None) -> list[dict]:
"""Get all model actions from history as JSON"""
if include is None:
include = []
outputs = self.model_actions()
result = []
for o in outputs:
for i in include:
if i == list(o.keys())[0]:
result.append(o)
return result
def number_of_steps(self) -> int:
"""Get the number of steps in the history"""
return len(self.history)
@property
def structured_output(self) -> AgentStructuredOutput | None:
"""Get the structured output from the history
Returns:
The structured output if both final_result and _output_model_schema are available,
otherwise None
"""
final_result = self.final_result()
if final_result is not None and self._output_model_schema is not None:
return self._output_model_schema.model_validate_json(final_result)
return None
class AgentError:
"""Container for agent error handling"""
VALIDATION_ERROR = 'Invalid model output format. Please follow the correct schema.'
RATE_LIMIT_ERROR = 'Rate limit reached. Waiting before retry.'
NO_VALID_ACTION = 'No valid action found'
@staticmethod
def format_error(error: Exception, include_trace: bool = False) -> str:
"""Format error message based on error type and optionally include trace"""
message = ''
if isinstance(error, ValidationError):
return f'{AgentError.VALIDATION_ERROR}\nDetails: {str(error)}'
if isinstance(error, RateLimitError):
return AgentError.RATE_LIMIT_ERROR
if include_trace:
return f'{str(error)}\nStacktrace:\n{traceback.format_exc()}'
return f'{str(error)}'
@@ -0,0 +1,41 @@
from typing import TYPE_CHECKING
# Type stubs for lazy imports
if TYPE_CHECKING:
from .profile import BrowserProfile, ProxySettings
from .session import BrowserSession
# Lazy imports mapping for heavy browser components
_LAZY_IMPORTS = {
'ProxySettings': ('.profile', 'ProxySettings'),
'BrowserProfile': ('.profile', 'BrowserProfile'),
'BrowserSession': ('.session', 'BrowserSession'),
}
def __getattr__(name: str):
"""Lazy import mechanism for heavy browser components."""
if name in _LAZY_IMPORTS:
module_path, attr_name = _LAZY_IMPORTS[name]
try:
from importlib import import_module
# Use relative import for current package
full_module_path = f'browser_use.browser{module_path}'
module = import_module(full_module_path)
attr = getattr(module, attr_name)
# Cache the imported attribute in the module's globals
globals()[name] = attr
return attr
except ImportError as e:
raise ImportError(f'Failed to import {name} from {full_module_path}: {e}') from e
raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
__all__ = [
'BrowserSession',
'BrowserProfile',
'ProxySettings',
]
@@ -0,0 +1,287 @@
"""Cloud browser service integration for browser-use.
This module provides integration with the browser-use cloud browser service.
When cloud_browser=True, it automatically creates a cloud browser instance
and returns the CDP URL for connection.
"""
import logging
import os
import httpx
from pydantic import BaseModel, Field
from browser_use.sync.auth import CloudAuthConfig
logger = logging.getLogger(__name__)
class CloudBrowserResponse(BaseModel):
"""Response from cloud browser API."""
id: str
status: str
liveUrl: str = Field(alias='liveUrl')
cdpUrl: str = Field(alias='cdpUrl')
timeoutAt: str = Field(alias='timeoutAt')
startedAt: str = Field(alias='startedAt')
finishedAt: str | None = Field(alias='finishedAt', default=None)
class CloudBrowserError(Exception):
"""Exception raised when cloud browser operations fail."""
pass
class CloudBrowserAuthError(CloudBrowserError):
"""Exception raised when cloud browser authentication fails."""
pass
class CloudBrowserClient:
"""Client for browser-use cloud browser service."""
def __init__(self, api_base_url: str = 'https://api.browser-use.com'):
self.api_base_url = api_base_url
self.client = httpx.AsyncClient(timeout=30.0)
self.current_session_id: str | None = None
async def create_browser(self) -> CloudBrowserResponse:
"""Create a new cloud browser instance.
Returns:
CloudBrowserResponse: Contains CDP URL and other browser info
Raises:
CloudBrowserAuthError: If authentication fails
CloudBrowserError: If browser creation fails
"""
url = f'{self.api_base_url}/api/v2/browsers'
# Try to get API key from environment variable first, then auth config
api_token = os.getenv('BROWSER_USE_API_KEY')
if not api_token:
# Fallback to auth config file
try:
auth_config = CloudAuthConfig.load_from_file()
api_token = auth_config.api_token
except Exception:
pass
if not api_token:
raise CloudBrowserAuthError(
'No authentication token found. Please set BROWSER_USE_API_KEY environment variable to authenticate with the cloud service. You can also create an API key at https://cloud.browser-use.com'
)
headers = {'X-Browser-Use-API-Key': api_token, 'Content-Type': 'application/json'}
# Empty request body as per API specification
request_body = {}
try:
logger.info('🌤️ Creating cloud browser instance...')
response = await self.client.post(url, headers=headers, json=request_body)
if response.status_code == 401:
raise CloudBrowserAuthError(
'Authentication failed. Please make sure you have set BROWSER_USE_API_KEY environment variable to authenticate with the cloud service. You can also create an API key at https://cloud.browser-use.com'
)
elif response.status_code == 403:
raise CloudBrowserAuthError('Access forbidden. Please check your browser-use cloud subscription status.')
elif not response.is_success:
error_msg = f'Failed to create cloud browser: HTTP {response.status_code}'
try:
error_data = response.json()
if 'detail' in error_data:
error_msg += f' - {error_data["detail"]}'
except Exception:
pass
raise CloudBrowserError(error_msg)
browser_data = response.json()
browser_response = CloudBrowserResponse(**browser_data)
# Store session ID for cleanup
self.current_session_id = browser_response.id
logger.info(f'🌤️ Cloud browser created successfully: {browser_response.id}')
logger.debug(f'🌤️ CDP URL: {browser_response.cdpUrl}')
# Cyan color for live URL
logger.info(f'\033[36m🔗 Live URL: {browser_response.liveUrl}\033[0m')
return browser_response
except httpx.TimeoutException:
raise CloudBrowserError('Timeout while creating cloud browser. Please try again.')
except httpx.ConnectError:
raise CloudBrowserError('Failed to connect to cloud browser service. Please check your internet connection.')
except Exception as e:
if isinstance(e, (CloudBrowserError, CloudBrowserAuthError)):
raise
raise CloudBrowserError(f'Unexpected error creating cloud browser: {e}')
async def stop_browser(self, session_id: str | None = None) -> CloudBrowserResponse:
"""Stop a cloud browser session.
Args:
session_id: Session ID to stop. If None, uses current session.
Returns:
CloudBrowserResponse: Updated browser info with stopped status
Raises:
CloudBrowserAuthError: If authentication fails
CloudBrowserError: If stopping fails
"""
if session_id is None:
session_id = self.current_session_id
if not session_id:
raise CloudBrowserError('No session ID provided and no current session available')
url = f'{self.api_base_url}/api/v2/browsers/{session_id}'
# Try to get API key from environment variable first, then auth config
api_token = os.getenv('BROWSER_USE_API_KEY')
if not api_token:
# Fallback to auth config file
try:
auth_config = CloudAuthConfig.load_from_file()
api_token = auth_config.api_token
except Exception:
pass
if not api_token:
raise CloudBrowserAuthError(
'No authentication token found. Please set BROWSER_USE_API_KEY environment variable to authenticate with the cloud service. You can also create an API key at https://cloud.browser-use.com'
)
headers = {'X-Browser-Use-API-Key': api_token, 'Content-Type': 'application/json'}
request_body = {'action': 'stop'}
try:
logger.info(f'🌤️ Stopping cloud browser session: {session_id}')
response = await self.client.patch(url, headers=headers, json=request_body)
if response.status_code == 401:
raise CloudBrowserAuthError(
'Authentication failed. Please make sure you have set the BROWSER_USE_API_KEY environment variable to authenticate with the cloud service.'
)
elif response.status_code == 404:
# Session already stopped or doesn't exist - treating as error and clearing session
logger.debug(f'🌤️ Cloud browser session {session_id} not found (already stopped)')
# Clear current session if it was this one
if session_id == self.current_session_id:
self.current_session_id = None
raise CloudBrowserError(f'Cloud browser session {session_id} not found')
elif not response.is_success:
error_msg = f'Failed to stop cloud browser: HTTP {response.status_code}'
try:
error_data = response.json()
if 'detail' in error_data:
error_msg += f' - {error_data["detail"]}'
except Exception:
pass
raise CloudBrowserError(error_msg)
browser_data = response.json()
browser_response = CloudBrowserResponse(**browser_data)
# Clear current session if it was this one
if session_id == self.current_session_id:
self.current_session_id = None
logger.info(f'🌤️ Cloud browser session stopped: {browser_response.id}')
logger.debug(f'🌤️ Status: {browser_response.status}')
return browser_response
except httpx.TimeoutException:
raise CloudBrowserError('Timeout while stopping cloud browser. Please try again.')
except httpx.ConnectError:
raise CloudBrowserError('Failed to connect to cloud browser service. Please check your internet connection.')
except Exception as e:
if isinstance(e, (CloudBrowserError, CloudBrowserAuthError)):
raise
raise CloudBrowserError(f'Unexpected error stopping cloud browser: {e}')
async def close(self):
"""Close the HTTP client and cleanup any active sessions."""
# Try to stop current session if active
if self.current_session_id:
try:
await self.stop_browser()
except Exception as e:
logger.debug(f'Failed to stop cloud browser session during cleanup: {e}')
await self.client.aclose()
# Global client instance
_cloud_client: CloudBrowserClient | None = None
async def get_cloud_browser_cdp_url() -> str:
"""Get a CDP URL for a new cloud browser instance.
Returns:
str: CDP URL for connecting to the cloud browser
Raises:
CloudBrowserAuthError: If authentication fails
CloudBrowserError: If browser creation fails
"""
global _cloud_client
if _cloud_client is None:
_cloud_client = CloudBrowserClient()
try:
browser_response = await _cloud_client.create_browser()
return browser_response.cdpUrl
except Exception:
# Clean up client on error
if _cloud_client:
await _cloud_client.close()
_cloud_client = None
raise
async def stop_cloud_browser_session(session_id: str | None = None) -> CloudBrowserResponse:
"""Stop a cloud browser session.
Args:
session_id: Session ID to stop. If None, uses current session from global client.
Returns:
CloudBrowserResponse: Updated browser info with stopped status
Raises:
CloudBrowserAuthError: If authentication fails
CloudBrowserError: If stopping fails
"""
global _cloud_client
if _cloud_client is None:
_cloud_client = CloudBrowserClient()
try:
return await _cloud_client.stop_browser(session_id)
except Exception:
# Don't clean up client on stop errors - session might still be valid
raise
async def cleanup_cloud_client():
"""Clean up the global cloud client."""
global _cloud_client
if _cloud_client:
await _cloud_client.close()
_cloud_client = None
@@ -0,0 +1,584 @@
"""Event definitions for browser communication."""
import inspect
import os
from typing import Any, Literal
from bubus import BaseEvent
from bubus.models import T_EventResultType
from cdp_use.cdp.target import TargetID
from pydantic import BaseModel, Field, field_validator
from browser_use.browser.views import BrowserStateSummary
from browser_use.dom.views import EnhancedDOMTreeNode
def _get_timeout(env_var: str, default: float) -> float | None:
"""
Safely parse environment variable timeout values with robust error handling.
Args:
env_var: Environment variable name (e.g. 'TIMEOUT_NavigateToUrlEvent')
default: Default timeout value as float (e.g. 15.0)
Returns:
Parsed float value or the default if parsing fails
Raises:
ValueError: Only if both env_var and default are invalid (should not happen with valid defaults)
"""
# Try environment variable first
env_value = os.getenv(env_var)
if env_value:
try:
parsed = float(env_value)
if parsed < 0:
print(f'Warning: {env_var}={env_value} is negative, using default {default}')
return default
return parsed
except (ValueError, TypeError):
print(f'Warning: {env_var}={env_value} is not a valid number, using default {default}')
# Fall back to default
return default
# ============================================================================
# Agent/Tools -> BrowserSession Events (High-level browser actions)
# ============================================================================
class ElementSelectedEvent(BaseEvent[T_EventResultType]):
"""An element was selected."""
node: EnhancedDOMTreeNode
@field_validator('node', mode='before')
@classmethod
def serialize_node(cls, data: EnhancedDOMTreeNode | None) -> EnhancedDOMTreeNode | None:
if data is None:
return None
return EnhancedDOMTreeNode(
element_index=data.element_index,
node_id=data.node_id,
backend_node_id=data.backend_node_id,
session_id=data.session_id,
frame_id=data.frame_id,
target_id=data.target_id,
node_type=data.node_type,
node_name=data.node_name,
node_value=data.node_value,
attributes=data.attributes,
is_scrollable=data.is_scrollable,
is_visible=data.is_visible,
absolute_position=data.absolute_position,
# override the circular reference fields in EnhancedDOMTreeNode as they cant be serialized and aren't needed by event handlers
# only used internally by the DOM service during DOM tree building process, not intended public API use
content_document=None,
shadow_root_type=None,
shadow_roots=[],
parent_node=None,
children_nodes=[],
ax_node=None,
snapshot_node=None,
)
# TODO: add page handle to events
# class PageHandle(share a base with browser.session.CDPSession?):
# url: str
# target_id: TargetID
# @classmethod
# def from_target_id(cls, target_id: TargetID) -> Self:
# return cls(target_id=target_id)
# @classmethod
# def from_target_id(cls, target_id: TargetID) -> Self:
# return cls(target_id=target_id)
# @classmethod
# def from_url(cls, url: str) -> Self:
# @property
# def root_frame_id(self) -> str:
# return self.target_id
# @property
# def session_id(self) -> str:
# return browser_session.get_or_create_cdp_session(self.target_id).session_id
# class PageSelectedEvent(BaseEvent[T_EventResultType]):
# """An event like SwitchToTabEvent(page=PageHandle) or CloseTabEvent(page=PageHandle)"""
# page: PageHandle
class NavigateToUrlEvent(BaseEvent[None]):
"""Navigate to a specific URL."""
url: str
wait_until: Literal['load', 'domcontentloaded', 'networkidle', 'commit'] = 'load'
timeout_ms: int | None = None
new_tab: bool = Field(
default=False, description='Set True to leave the current tab alone and open a new tab in the foreground for the new URL'
)
# existing_tab: PageHandle | None = None # TODO
# time limits enforced by bubus, not exposed to LLM:
event_timeout: float | None = _get_timeout('TIMEOUT_NavigateToUrlEvent', 15.0) # seconds
class ClickElementEvent(ElementSelectedEvent[dict[str, Any] | None]):
"""Click an element."""
node: 'EnhancedDOMTreeNode'
button: Literal['left', 'right', 'middle'] = 'left'
while_holding_ctrl: bool = Field(
default=False,
description='Set True to open any link clicked in a new tab in the background, can use switch_tab(tab_id=None) after to focus it',
)
# click_count: int = 1 # TODO
# expect_download: bool = False # moved to downloads_watchdog.py
event_timeout: float | None = _get_timeout('TIMEOUT_ClickElementEvent', 15.0) # seconds
class TypeTextEvent(ElementSelectedEvent[dict | None]):
"""Type text into an element."""
node: 'EnhancedDOMTreeNode'
text: str
clear_existing: bool = True
is_sensitive: bool = False # Flag to indicate if text contains sensitive data
sensitive_key_name: str | None = None # Name of the sensitive key being typed (e.g., 'username', 'password')
event_timeout: float | None = _get_timeout('TIMEOUT_TypeTextEvent', 15.0) # seconds
class ScrollEvent(ElementSelectedEvent[None]):
"""Scroll the page or element."""
direction: Literal['up', 'down', 'left', 'right']
amount: int # pixels
node: 'EnhancedDOMTreeNode | None' = None # None means scroll page
event_timeout: float | None = _get_timeout('TIMEOUT_ScrollEvent', 8.0) # seconds
class SwitchTabEvent(BaseEvent[TargetID]):
"""Switch to a different tab."""
target_id: TargetID | None = Field(default=None, description='None means switch to the most recently opened tab')
event_timeout: float | None = _get_timeout('TIMEOUT_SwitchTabEvent', 10.0) # seconds
class CloseTabEvent(BaseEvent[None]):
"""Close a tab."""
target_id: TargetID
event_timeout: float | None = _get_timeout('TIMEOUT_CloseTabEvent', 10.0) # seconds
class ScreenshotEvent(BaseEvent[str]):
"""Request to take a screenshot."""
full_page: bool = False
clip: dict[str, float] | None = None # {x, y, width, height}
event_timeout: float | None = _get_timeout('TIMEOUT_ScreenshotEvent', 8.0) # seconds
class BrowserStateRequestEvent(BaseEvent[BrowserStateSummary]):
"""Request current browser state."""
include_dom: bool = True
include_screenshot: bool = True
cache_clickable_elements_hashes: bool = True
include_recent_events: bool = False
event_timeout: float | None = _get_timeout('TIMEOUT_BrowserStateRequestEvent', 30.0) # seconds
# class WaitForConditionEvent(BaseEvent):
# """Wait for a condition."""
# condition: Literal['navigation', 'selector', 'timeout', 'load_state']
# timeout: float = 30000
# selector: str | None = None
# state: Literal['attached', 'detached', 'visible', 'hidden'] | None = None
class GoBackEvent(BaseEvent[None]):
"""Navigate back in browser history."""
event_timeout: float | None = _get_timeout('TIMEOUT_GoBackEvent', 15.0) # seconds
class GoForwardEvent(BaseEvent[None]):
"""Navigate forward in browser history."""
event_timeout: float | None = _get_timeout('TIMEOUT_GoForwardEvent', 15.0) # seconds
class RefreshEvent(BaseEvent[None]):
"""Refresh/reload the current page."""
event_timeout: float | None = _get_timeout('TIMEOUT_RefreshEvent', 15.0) # seconds
class WaitEvent(BaseEvent[None]):
"""Wait for a specified number of seconds."""
seconds: float = 3.0
max_seconds: float = 10.0 # Safety cap
event_timeout: float | None = _get_timeout('TIMEOUT_WaitEvent', 60.0) # seconds
class SendKeysEvent(BaseEvent[None]):
"""Send keyboard keys/shortcuts."""
keys: str # e.g., "ctrl+a", "cmd+c", "Enter"
event_timeout: float | None = _get_timeout('TIMEOUT_SendKeysEvent', 15.0) # seconds
class UploadFileEvent(ElementSelectedEvent[None]):
"""Upload a file to an element."""
node: 'EnhancedDOMTreeNode'
file_path: str
event_timeout: float | None = _get_timeout('TIMEOUT_UploadFileEvent', 30.0) # seconds
class GetDropdownOptionsEvent(ElementSelectedEvent[dict[str, str]]):
"""Get all options from any dropdown (native <select>, ARIA menus, or custom dropdowns).
Returns a dict containing dropdown type, options list, and element metadata."""
node: 'EnhancedDOMTreeNode'
event_timeout: float | None = _get_timeout(
'TIMEOUT_GetDropdownOptionsEvent',
15.0,
) # some dropdowns lazy-load the list of options on first interaction, so we need to wait for them to load (e.g. table filter lists can have thousands of options)
class SelectDropdownOptionEvent(ElementSelectedEvent[dict[str, str]]):
"""Select a dropdown option by exact text from any dropdown type.
Returns a dict containing success status and selection details."""
node: 'EnhancedDOMTreeNode'
text: str # The option text to select
event_timeout: float | None = _get_timeout('TIMEOUT_SelectDropdownOptionEvent', 8.0) # seconds
class ScrollToTextEvent(BaseEvent[None]):
"""Scroll to specific text on the page. Raises exception if text not found."""
text: str
direction: Literal['up', 'down'] = 'down'
event_timeout: float | None = _get_timeout('TIMEOUT_ScrollToTextEvent', 15.0) # seconds
# ============================================================================
class BrowserStartEvent(BaseEvent):
"""Start/connect to browser."""
cdp_url: str | None = None
launch_options: dict[str, Any] = Field(default_factory=dict)
event_timeout: float | None = _get_timeout('TIMEOUT_BrowserStartEvent', 30.0) # seconds
class BrowserStopEvent(BaseEvent):
"""Stop/disconnect from browser."""
force: bool = False
event_timeout: float | None = _get_timeout('TIMEOUT_BrowserStopEvent', 45.0) # seconds
class BrowserLaunchResult(BaseModel):
"""Result of launching a browser."""
# TODO: add browser executable_path, pid, version, latency, user_data_dir, X11 $DISPLAY, host IP address, etc.
cdp_url: str
class BrowserLaunchEvent(BaseEvent[BrowserLaunchResult]):
"""Launch a local browser process."""
# TODO: add executable_path, proxy settings, preferences, extra launch args, etc.
event_timeout: float | None = _get_timeout('TIMEOUT_BrowserLaunchEvent', 30.0) # seconds
class BrowserKillEvent(BaseEvent):
"""Kill local browser subprocess."""
event_timeout: float | None = _get_timeout('TIMEOUT_BrowserKillEvent', 30.0) # seconds
# TODO: replace all Runtime.evaluate() calls with this event
# class ExecuteJavaScriptEvent(BaseEvent):
# """Execute JavaScript in page context."""
# target_id: TargetID
# expression: str
# await_promise: bool = True
# event_timeout: float | None = 60.0 # seconds
# TODO: add this and use the old BrowserProfile.viewport options to set it
# class SetViewportEvent(BaseEvent):
# """Set the viewport size."""
# width: int
# height: int
# device_scale_factor: float = 1.0
# event_timeout: float | None = 15.0 # seconds
# Moved to storage state
# class SetCookiesEvent(BaseEvent):
# """Set browser cookies."""
# cookies: list[dict[str, Any]]
# event_timeout: float | None = (
# 30.0 # only long to support the edge case of restoring a big localStorage / on many origins (has to O(n) visit each origin to restore)
# )
# class GetCookiesEvent(BaseEvent):
# """Get browser cookies."""
# urls: list[str] | None = None
# event_timeout: float | None = 30.0 # seconds
# ============================================================================
# DOM-related Events
# ============================================================================
class BrowserConnectedEvent(BaseEvent):
"""Browser has started/connected."""
cdp_url: str
event_timeout: float | None = _get_timeout('TIMEOUT_BrowserConnectedEvent', 30.0) # seconds
class BrowserStoppedEvent(BaseEvent):
"""Browser has stopped/disconnected."""
reason: str | None = None
event_timeout: float | None = _get_timeout('TIMEOUT_BrowserStoppedEvent', 30.0) # seconds
class TabCreatedEvent(BaseEvent):
"""A new tab was created."""
target_id: TargetID
url: str
event_timeout: float | None = _get_timeout('TIMEOUT_TabCreatedEvent', 30.0) # seconds
class TabClosedEvent(BaseEvent):
"""A tab was closed."""
target_id: TargetID
# TODO:
# new_focus_target_id: int | None = None
# new_focus_url: str | None = None
event_timeout: float | None = _get_timeout('TIMEOUT_TabClosedEvent', 10.0) # seconds
# TODO: emit this when DOM changes significantly, inner frame navigates, form submits, history.pushState(), etc.
# class TabUpdatedEvent(BaseEvent):
# """Tab information updated (URL changed, etc.)."""
# target_id: TargetID
# url: str
class AgentFocusChangedEvent(BaseEvent):
"""Agent focus changed to a different tab."""
target_id: TargetID
url: str
event_timeout: float | None = _get_timeout('TIMEOUT_AgentFocusChangedEvent', 10.0) # seconds
class TargetCrashedEvent(BaseEvent):
"""A target has crashed."""
target_id: TargetID
error: str
event_timeout: float | None = _get_timeout('TIMEOUT_TargetCrashedEvent', 10.0) # seconds
class NavigationStartedEvent(BaseEvent):
"""Navigation started."""
target_id: TargetID
url: str
event_timeout: float | None = _get_timeout('TIMEOUT_NavigationStartedEvent', 30.0) # seconds
class NavigationCompleteEvent(BaseEvent):
"""Navigation completed."""
target_id: TargetID
url: str
status: int | None = None
error_message: str | None = None # Error/timeout message if navigation had issues
loading_status: str | None = None # Detailed loading status (e.g., network timeout info)
event_timeout: float | None = _get_timeout('TIMEOUT_NavigationCompleteEvent', 30.0) # seconds
# ============================================================================
# Error Events
# ============================================================================
class BrowserErrorEvent(BaseEvent):
"""An error occurred in the browser layer."""
error_type: str
message: str
details: dict[str, Any] = Field(default_factory=dict)
event_timeout: float | None = _get_timeout('TIMEOUT_BrowserErrorEvent', 30.0) # seconds
# ============================================================================
# Storage State Events
# ============================================================================
class SaveStorageStateEvent(BaseEvent):
"""Request to save browser storage state."""
path: str | None = None # Optional path, uses profile default if not provided
event_timeout: float | None = _get_timeout('TIMEOUT_SaveStorageStateEvent', 45.0) # seconds
class StorageStateSavedEvent(BaseEvent):
"""Notification that storage state was saved."""
path: str
cookies_count: int
origins_count: int
event_timeout: float | None = _get_timeout('TIMEOUT_StorageStateSavedEvent', 30.0) # seconds
class LoadStorageStateEvent(BaseEvent):
"""Request to load browser storage state."""
path: str | None = None # Optional path, uses profile default if not provided
event_timeout: float | None = _get_timeout('TIMEOUT_LoadStorageStateEvent', 45.0) # seconds
# TODO: refactor this to:
# - on_BrowserConnectedEvent() -> dispatch(LoadStorageStateEvent()) -> _copy_storage_state_from_json_to_browser(json_file, new_cdp_session) + return storage_state from handler
# - on_BrowserStopEvent() -> dispatch(SaveStorageStateEvent()) -> _copy_storage_state_from_browser_to_json(new_cdp_session, json_file)
# and get rid of StorageStateSavedEvent and StorageStateLoadedEvent, have the original events + provide handler return values for any results
class StorageStateLoadedEvent(BaseEvent):
"""Notification that storage state was loaded."""
path: str
cookies_count: int
origins_count: int
event_timeout: float | None = _get_timeout('TIMEOUT_StorageStateLoadedEvent', 30.0) # seconds
# ============================================================================
# File Download Events
# ============================================================================
class FileDownloadedEvent(BaseEvent):
"""A file has been downloaded."""
url: str
path: str
file_name: str
file_size: int
file_type: str | None = None # e.g., 'pdf', 'zip', 'docx', etc.
mime_type: str | None = None # e.g., 'application/pdf'
from_cache: bool = False
auto_download: bool = False # Whether this was an automatic download (e.g., PDF auto-download)
event_timeout: float | None = _get_timeout('TIMEOUT_FileDownloadedEvent', 30.0) # seconds
class AboutBlankDVDScreensaverShownEvent(BaseEvent):
"""AboutBlankWatchdog has shown DVD screensaver animation on an about:blank tab."""
target_id: TargetID
error: str | None = None
class DialogOpenedEvent(BaseEvent):
"""Event dispatched when a JavaScript dialog is opened and handled."""
dialog_type: str # 'alert', 'confirm', 'prompt', or 'beforeunload'
message: str
url: str
frame_id: str | None = None # Can be None when frameId is not provided by CDP
# target_id: TargetID # TODO: add this to avoid needing target_id_from_frame() later
# Note: Model rebuilding for forward references is handled in the importing modules
# Events with 'EnhancedDOMTreeNode' forward references (ClickElementEvent, TypeTextEvent,
# ScrollEvent, UploadFileEvent) need model_rebuild() called after imports are complete
def _check_event_names_dont_overlap():
"""
check that event names defined in this file are valid and non-overlapping
(naiively n^2 so it's pretty slow but ok for now, optimize when >20 events)
"""
event_names = {
name.split('[')[0]
for name in globals().keys()
if not name.startswith('_')
and inspect.isclass(globals()[name])
and issubclass(globals()[name], BaseEvent)
and name != 'BaseEvent'
}
for name_a in event_names:
assert name_a.endswith('Event'), f'Event with name {name_a} does not end with "Event"'
for name_b in event_names:
if name_a != name_b: # Skip self-comparison
assert name_a not in name_b, (
f'Event with name {name_a} is a substring of {name_b}, all events must be completely unique to avoid find-and-replace accidents'
)
# overlapping event names are a nightmare to trace and rename later, dont do it!
# e.g. prevent ClickEvent and FailedClickEvent are terrible names because one is a substring of the other,
# must be ClickEvent and ClickFailedEvent to preserve the usefulnes of codebase grep/sed/awk as refactoring tools.
# at import time, we do a quick check that all event names defined above are valid and non-overlapping.
# this is hand written in blood by a human! not LLM slop. feel free to optimize but do not remove it without a good reason.
_check_event_names_dont_overlap()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,548 @@
"""Python-based highlighting system for drawing bounding boxes on screenshots.
This module replaces JavaScript-based highlighting with fast Python image processing
to draw bounding boxes around interactive elements directly on screenshots.
"""
import asyncio
import base64
import io
import logging
import os
from PIL import Image, ImageDraw, ImageFont
from browser_use.dom.views import DOMSelectorMap, EnhancedDOMTreeNode
from browser_use.observability import observe_debug
from browser_use.utils import time_execution_async
logger = logging.getLogger(__name__)
# Font cache to prevent repeated font loading and reduce memory usage
_FONT_CACHE: dict[tuple[str, int], ImageFont.FreeTypeFont | None] = {}
# Cross-platform font paths
_FONT_PATHS = [
'/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf', # Linux (Debian/Ubuntu)
'/usr/share/fonts/TTF/DejaVuSans-Bold.ttf', # Linux (Arch/Fedora)
'/System/Library/Fonts/Arial.ttf', # macOS
'C:\\Windows\\Fonts\\arial.ttf', # Windows
'arial.ttf', # Windows (system path)
'Arial Bold.ttf', # macOS alternative
'/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf', # Linux alternative
]
def get_cross_platform_font(font_size: int) -> ImageFont.FreeTypeFont | None:
"""Get a cross-platform compatible font with caching to prevent memory leaks.
Args:
font_size: Size of the font to load
Returns:
ImageFont object or None if no system fonts are available
"""
# Use cache key based on font size
cache_key = ('system_font', font_size)
# Return cached font if available
if cache_key in _FONT_CACHE:
return _FONT_CACHE[cache_key]
# Try to load a system font
font = None
for font_path in _FONT_PATHS:
try:
font = ImageFont.truetype(font_path, font_size)
break
except OSError:
continue
# Cache the result (even if None) to avoid repeated attempts
_FONT_CACHE[cache_key] = font
return font
def cleanup_font_cache() -> None:
"""Clean up the font cache to prevent memory leaks in long-running applications."""
global _FONT_CACHE
_FONT_CACHE.clear()
# Color scheme for different element types
ELEMENT_COLORS = {
'button': '#FF6B6B', # Red for buttons
'input': '#4ECDC4', # Teal for inputs
'select': '#45B7D1', # Blue for dropdowns
'a': '#96CEB4', # Green for links
'textarea': '#FF8C42', # Orange for text areas (was yellow, now more visible)
'default': '#DDA0DD', # Light purple for other interactive elements
}
# Element type mappings
ELEMENT_TYPE_MAP = {
'button': 'button',
'input': 'input',
'select': 'select',
'a': 'a',
'textarea': 'textarea',
}
def get_element_color(tag_name: str, element_type: str | None = None) -> str:
"""Get color for element based on tag name and type."""
# Check input type first
if tag_name == 'input' and element_type:
if element_type in ['button', 'submit']:
return ELEMENT_COLORS['button']
# Use tag-based color
return ELEMENT_COLORS.get(tag_name.lower(), ELEMENT_COLORS['default'])
def should_show_index_overlay(element_index: int | None) -> bool:
"""Determine if index overlay should be shown."""
return element_index is not None
def draw_enhanced_bounding_box_with_text(
draw, # ImageDraw.Draw - avoiding type annotation due to PIL typing issues
bbox: tuple[int, int, int, int],
color: str,
text: str | None = None,
font: ImageFont.FreeTypeFont | None = None,
element_type: str = 'div',
image_size: tuple[int, int] = (2000, 1500),
device_pixel_ratio: float = 1.0,
) -> None:
"""Draw an enhanced bounding box with much bigger index containers and dashed borders."""
x1, y1, x2, y2 = bbox
# Draw dashed bounding box with pattern: 1 line, 2 spaces, 1 line, 2 spaces...
dash_length = 4
gap_length = 8
line_width = 2
# Helper function to draw dashed line
def draw_dashed_line(start_x, start_y, end_x, end_y):
if start_x == end_x: # Vertical line
y = start_y
while y < end_y:
dash_end = min(y + dash_length, end_y)
draw.line([(start_x, y), (start_x, dash_end)], fill=color, width=line_width)
y += dash_length + gap_length
else: # Horizontal line
x = start_x
while x < end_x:
dash_end = min(x + dash_length, end_x)
draw.line([(x, start_y), (dash_end, start_y)], fill=color, width=line_width)
x += dash_length + gap_length
# Draw dashed rectangle
draw_dashed_line(x1, y1, x2, y1) # Top
draw_dashed_line(x2, y1, x2, y2) # Right
draw_dashed_line(x2, y2, x1, y2) # Bottom
draw_dashed_line(x1, y2, x1, y1) # Left
# Draw much bigger index overlay if we have index text
if text:
try:
# Scale font size for appropriate sizing across different resolutions
img_width, img_height = image_size
css_width = img_width # / device_pixel_ratio
# Much smaller scaling - 1% of CSS viewport width, max 16px to prevent huge highlights
base_font_size = max(10, min(20, int(css_width * 0.01)))
# Use shared font loading function with caching
big_font = get_cross_platform_font(base_font_size)
if big_font is None:
big_font = font # Fallback to original font if no system fonts found
# Get text size with bigger font
if big_font:
bbox_text = draw.textbbox((0, 0), text, font=big_font)
text_width = bbox_text[2] - bbox_text[0]
text_height = bbox_text[3] - bbox_text[1]
else:
# Fallback for default font
bbox_text = draw.textbbox((0, 0), text)
text_width = bbox_text[2] - bbox_text[0]
text_height = bbox_text[3] - bbox_text[1]
# Scale padding appropriately for different resolutions
padding = max(4, min(10, int(css_width * 0.005))) # 0.3% of CSS width, max 4px
element_width = x2 - x1
element_height = y2 - y1
# Container dimensions
container_width = text_width + padding * 2
container_height = text_height + padding * 2
# Position in top center - for small elements, place further up to avoid blocking content
# Center horizontally within the element
bg_x1 = x1 + (element_width - container_width) // 2
# Simple rule: if element is small, place index further up to avoid blocking icons
if element_width < 60 or element_height < 30:
# Small element: place well above to avoid blocking content
bg_y1 = max(0, y1 - container_height - 5)
else:
# Regular element: place inside with small offset
bg_y1 = y1 + 2
bg_x2 = bg_x1 + container_width
bg_y2 = bg_y1 + container_height
# Center the number within the index box with proper baseline handling
text_x = bg_x1 + (container_width - text_width) // 2
# Add extra vertical space to prevent clipping
text_y = bg_y1 + (container_height - text_height) // 2 - bbox_text[1] # Subtract top offset
# Ensure container stays within image bounds
img_width, img_height = image_size
if bg_x1 < 0:
offset = -bg_x1
bg_x1 += offset
bg_x2 += offset
text_x += offset
if bg_y1 < 0:
offset = -bg_y1
bg_y1 += offset
bg_y2 += offset
text_y += offset
if bg_x2 > img_width:
offset = bg_x2 - img_width
bg_x1 -= offset
bg_x2 -= offset
text_x -= offset
if bg_y2 > img_height:
offset = bg_y2 - img_height
bg_y1 -= offset
bg_y2 -= offset
text_y -= offset
# Draw bigger background rectangle with thicker border
draw.rectangle([bg_x1, bg_y1, bg_x2, bg_y2], fill=color, outline='white', width=2)
# Draw white text centered in the index box
draw.text((text_x, text_y), text, fill='white', font=big_font or font)
except Exception as e:
logger.debug(f'Failed to draw enhanced text overlay: {e}')
def draw_bounding_box_with_text(
draw, # ImageDraw.Draw - avoiding type annotation due to PIL typing issues
bbox: tuple[int, int, int, int],
color: str,
text: str | None = None,
font: ImageFont.FreeTypeFont | None = None,
) -> None:
"""Draw a bounding box with optional text overlay."""
x1, y1, x2, y2 = bbox
# Draw dashed bounding box
dash_length = 2
gap_length = 6
# Top edge
x = x1
while x < x2:
end_x = min(x + dash_length, x2)
draw.line([(x, y1), (end_x, y1)], fill=color, width=2)
draw.line([(x, y1 + 1), (end_x, y1 + 1)], fill=color, width=2)
x += dash_length + gap_length
# Bottom edge
x = x1
while x < x2:
end_x = min(x + dash_length, x2)
draw.line([(x, y2), (end_x, y2)], fill=color, width=2)
draw.line([(x, y2 - 1), (end_x, y2 - 1)], fill=color, width=2)
x += dash_length + gap_length
# Left edge
y = y1
while y < y2:
end_y = min(y + dash_length, y2)
draw.line([(x1, y), (x1, end_y)], fill=color, width=2)
draw.line([(x1 + 1, y), (x1 + 1, end_y)], fill=color, width=2)
y += dash_length + gap_length
# Right edge
y = y1
while y < y2:
end_y = min(y + dash_length, y2)
draw.line([(x2, y), (x2, end_y)], fill=color, width=2)
draw.line([(x2 - 1, y), (x2 - 1, end_y)], fill=color, width=2)
y += dash_length + gap_length
# Draw index overlay if we have index text
if text:
try:
# Get text size
if font:
bbox_text = draw.textbbox((0, 0), text, font=font)
text_width = bbox_text[2] - bbox_text[0]
text_height = bbox_text[3] - bbox_text[1]
else:
# Fallback for default font
bbox_text = draw.textbbox((0, 0), text)
text_width = bbox_text[2] - bbox_text[0]
text_height = bbox_text[3] - bbox_text[1]
# Smart positioning based on element size
padding = 5
element_width = x2 - x1
element_height = y2 - y1
element_area = element_width * element_height
index_box_area = (text_width + padding * 2) * (text_height + padding * 2)
# Calculate size ratio to determine positioning strategy
size_ratio = element_area / max(index_box_area, 1)
if size_ratio < 4:
# Very small elements: place outside in bottom-right corner
text_x = x2 + padding
text_y = y2 - text_height
# Ensure it doesn't go off screen
text_x = min(text_x, 1200 - text_width - padding)
text_y = max(text_y, 0)
elif size_ratio < 16:
# Medium elements: place in bottom-right corner inside
text_x = x2 - text_width - padding
text_y = y2 - text_height - padding
else:
# Large elements: place in center
text_x = x1 + (element_width - text_width) // 2
text_y = y1 + (element_height - text_height) // 2
# Ensure text stays within bounds
text_x = max(0, min(text_x, 1200 - text_width))
text_y = max(0, min(text_y, 800 - text_height))
# Draw background rectangle for maximum contrast
bg_x1 = text_x - padding
bg_y1 = text_y - padding
bg_x2 = text_x + text_width + padding
bg_y2 = text_y + text_height + padding
# Use white background with thick black border for maximum visibility
draw.rectangle([bg_x1, bg_y1, bg_x2, bg_y2], fill='white', outline='black', width=2)
# Draw bold dark text on light background for best contrast
draw.text((text_x, text_y), text, fill='black', font=font)
except Exception as e:
logger.debug(f'Failed to draw text overlay: {e}')
def process_element_highlight(
element_id: int,
element: EnhancedDOMTreeNode,
draw,
device_pixel_ratio: float,
font,
filter_highlight_ids: bool,
image_size: tuple[int, int],
) -> None:
"""Process a single element for highlighting."""
try:
# Use absolute_position coordinates directly
if not element.absolute_position:
return
bounds = element.absolute_position
# Scale coordinates from CSS pixels to device pixels for screenshot
# The screenshot is captured at device pixel resolution, but coordinates are in CSS pixels
x1 = int(bounds.x * device_pixel_ratio)
y1 = int(bounds.y * device_pixel_ratio)
x2 = int((bounds.x + bounds.width) * device_pixel_ratio)
y2 = int((bounds.y + bounds.height) * device_pixel_ratio)
# Ensure coordinates are within image bounds
img_width, img_height = image_size
x1 = max(0, min(x1, img_width))
y1 = max(0, min(y1, img_height))
x2 = max(x1, min(x2, img_width))
y2 = max(y1, min(y2, img_height))
# Skip if bounding box is too small or invalid
if x2 - x1 < 2 or y2 - y1 < 2:
return
# Get element color based on type
tag_name = element.tag_name if hasattr(element, 'tag_name') else 'div'
element_type = None
if hasattr(element, 'attributes') and element.attributes:
element_type = element.attributes.get('type')
color = get_element_color(tag_name, element_type)
# Get element index for overlay and apply filtering
element_index = getattr(element, 'element_index', None)
index_text = None
if element_index is not None:
if filter_highlight_ids:
# Use the meaningful text that matches what the LLM sees
meaningful_text = element.get_meaningful_text_for_llm()
# Show ID only if meaningful text is less than 5 characters
if len(meaningful_text) < 3:
index_text = str(element_index)
else:
# Always show ID when filter is disabled
index_text = str(element_index)
# Draw enhanced bounding box with bigger index
draw_enhanced_bounding_box_with_text(
draw, (x1, y1, x2, y2), color, index_text, font, tag_name, image_size, device_pixel_ratio
)
except Exception as e:
logger.debug(f'Failed to draw highlight for element {element_id}: {e}')
@observe_debug(ignore_input=True, ignore_output=True, name='create_highlighted_screenshot')
@time_execution_async('create_highlighted_screenshot')
async def create_highlighted_screenshot(
screenshot_b64: str,
selector_map: DOMSelectorMap,
device_pixel_ratio: float = 1.0,
viewport_offset_x: int = 0,
viewport_offset_y: int = 0,
filter_highlight_ids: bool = True,
) -> str:
"""Create a highlighted screenshot with bounding boxes around interactive elements.
Args:
screenshot_b64: Base64 encoded screenshot
selector_map: Map of interactive elements with their positions
device_pixel_ratio: Device pixel ratio for scaling coordinates
viewport_offset_x: X offset for viewport positioning
viewport_offset_y: Y offset for viewport positioning
Returns:
Base64 encoded highlighted screenshot
"""
try:
# Decode screenshot
screenshot_data = base64.b64decode(screenshot_b64)
image = Image.open(io.BytesIO(screenshot_data)).convert('RGBA')
# Create drawing context
draw = ImageDraw.Draw(image)
# Load font using shared function with caching
font = get_cross_platform_font(12)
# If no system fonts found, font remains None and will use default font
# Process elements sequentially to avoid ImageDraw thread safety issues
# PIL ImageDraw is not thread-safe, so we process elements one by one
for element_id, element in selector_map.items():
process_element_highlight(element_id, element, draw, device_pixel_ratio, font, filter_highlight_ids, image.size)
# Convert back to base64
output_buffer = io.BytesIO()
try:
image.save(output_buffer, format='PNG')
output_buffer.seek(0)
highlighted_b64 = base64.b64encode(output_buffer.getvalue()).decode('utf-8')
logger.debug(f'Successfully created highlighted screenshot with {len(selector_map)} elements')
return highlighted_b64
finally:
# Explicit cleanup to prevent memory leaks
output_buffer.close()
if 'image' in locals():
image.close()
except Exception as e:
logger.error(f'Failed to create highlighted screenshot: {e}')
# Clean up on error as well
if 'image' in locals():
image.close()
# Return original screenshot on error
return screenshot_b64
async def get_viewport_info_from_cdp(cdp_session) -> tuple[float, int, int]:
"""Get viewport information from CDP session.
Returns:
Tuple of (device_pixel_ratio, scroll_x, scroll_y)
"""
try:
# Get layout metrics which includes viewport info and device pixel ratio
metrics = await cdp_session.cdp_client.send.Page.getLayoutMetrics(session_id=cdp_session.session_id)
# Extract viewport information
visual_viewport = metrics.get('visualViewport', {})
css_visual_viewport = metrics.get('cssVisualViewport', {})
css_layout_viewport = metrics.get('cssLayoutViewport', {})
# Calculate device pixel ratio
css_width = css_visual_viewport.get('clientWidth', css_layout_viewport.get('clientWidth', 1280.0))
device_width = visual_viewport.get('clientWidth', css_width)
device_pixel_ratio = device_width / css_width if css_width > 0 else 1.0
# Get scroll position in CSS pixels
scroll_x = int(css_visual_viewport.get('pageX', 0))
scroll_y = int(css_visual_viewport.get('pageY', 0))
return float(device_pixel_ratio), scroll_x, scroll_y
except Exception as e:
logger.debug(f'Failed to get viewport info from CDP: {e}')
return 1.0, 0, 0
@time_execution_async('create_highlighted_screenshot_async')
async def create_highlighted_screenshot_async(
screenshot_b64: str, selector_map: DOMSelectorMap, cdp_session=None, filter_highlight_ids: bool = True
) -> str:
"""Async wrapper for creating highlighted screenshots.
Args:
screenshot_b64: Base64 encoded screenshot
selector_map: Map of interactive elements
cdp_session: CDP session for getting viewport info
filter_highlight_ids: Whether to filter element IDs based on meaningful text
Returns:
Base64 encoded highlighted screenshot
"""
# Get viewport information if CDP session is available
device_pixel_ratio = 1.0
viewport_offset_x = 0
viewport_offset_y = 0
if cdp_session:
try:
device_pixel_ratio, viewport_offset_x, viewport_offset_y = await get_viewport_info_from_cdp(cdp_session)
except Exception as e:
logger.debug(f'Failed to get viewport info from CDP: {e}')
# Create highlighted screenshot with async processing
final_screenshot = await create_highlighted_screenshot(
screenshot_b64, selector_map, device_pixel_ratio, viewport_offset_x, viewport_offset_y, filter_highlight_ids
)
filename = os.getenv('BROWSER_USE_SCREENSHOT_FILE')
if filename:
def _write_screenshot():
try:
with open(filename, 'wb') as f:
f.write(base64.b64decode(final_screenshot))
logger.debug('Saved screenshot to ' + str(filename))
except Exception as e:
logger.warning(f'Failed to save screenshot to {filename}: {e}')
await asyncio.to_thread(_write_screenshot)
return final_screenshot
# Export the cleanup function for external use in long-running applications
__all__ = ['create_highlighted_screenshot', 'create_highlighted_screenshot_async', 'cleanup_font_cache']
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,162 @@
"""Video Recording Service for Browser Use Sessions."""
import base64
import logging
import math
import subprocess
from pathlib import Path
from typing import Optional
from browser_use.browser.profile import ViewportSize
try:
import imageio.v2 as iio # type: ignore[import-not-found]
import imageio_ffmpeg # type: ignore[import-not-found]
import numpy as np # type: ignore[import-not-found]
from imageio.core.format import Format # type: ignore[import-not-found]
IMAGEIO_AVAILABLE = True
except ImportError:
IMAGEIO_AVAILABLE = False
logger = logging.getLogger(__name__)
def _get_padded_size(size: ViewportSize, macro_block_size: int = 16) -> ViewportSize:
"""Calculates the dimensions padded to the nearest multiple of macro_block_size."""
width = int(math.ceil(size['width'] / macro_block_size)) * macro_block_size
height = int(math.ceil(size['height'] / macro_block_size)) * macro_block_size
return ViewportSize(width=width, height=height)
class VideoRecorderService:
"""
Handles the video encoding process for a browser session using imageio.
This service captures individual frames from the CDP screencast, decodes them,
and appends them to a video file using a pip-installable ffmpeg backend.
It automatically resizes frames to match the target video dimensions.
"""
def __init__(self, output_path: Path, size: ViewportSize, framerate: int):
"""
Initializes the video recorder.
Args:
output_path: The full path where the video will be saved.
size: A ViewportSize object specifying the width and height of the video.
framerate: The desired framerate for the output video.
"""
self.output_path = output_path
self.size = size
self.framerate = framerate
self._writer: Optional['Format.Writer'] = None
self._is_active = False
self.padded_size = _get_padded_size(self.size)
def start(self) -> None:
"""
Prepares and starts the video writer.
If the required optional dependencies are not installed, this method will
log an error and do nothing.
"""
if not IMAGEIO_AVAILABLE:
logger.error(
'MP4 recording requires optional dependencies. Please install them with: pip install "browser-use[video]"'
)
return
try:
self.output_path.parent.mkdir(parents=True, exist_ok=True)
# The macro_block_size is set to None because we handle padding ourselves
self._writer = iio.get_writer(
str(self.output_path),
fps=self.framerate,
codec='libx264',
quality=8, # A good balance of quality and file size (1-10 scale)
pixelformat='yuv420p', # Ensures compatibility with most players
macro_block_size=None,
)
self._is_active = True
logger.debug(f'Video recorder started. Output will be saved to {self.output_path}')
except Exception as e:
logger.error(f'Failed to initialize video writer: {e}')
self._is_active = False
def add_frame(self, frame_data_b64: str) -> None:
"""
Decodes a base64-encoded PNG frame, resizes it, pads it to be codec-compatible,
and appends it to the video.
Args:
frame_data_b64: A base64-encoded string of the PNG frame data.
"""
if not self._is_active or not self._writer:
return
try:
frame_bytes = base64.b64decode(frame_data_b64)
# Build a filter chain for ffmpeg:
# 1. scale: Resizes the frame to the user-specified dimensions.
# 2. pad: Adds black bars to meet codec's macro-block requirements,
# centering the original content.
vf_chain = (
f'scale={self.size["width"]}:{self.size["height"]},'
f'pad={self.padded_size["width"]}:{self.padded_size["height"]}:(ow-iw)/2:(oh-ih)/2:color=black'
)
output_pix_fmt = 'rgb24'
command = [
imageio_ffmpeg.get_ffmpeg_exe(),
'-f',
'image2pipe', # Input format from a pipe
'-c:v',
'png', # Specify input codec is PNG
'-i',
'-', # Input from stdin
'-vf',
vf_chain, # Video filter for resizing and padding
'-f',
'rawvideo', # Output format is raw video
'-pix_fmt',
output_pix_fmt, # Output pixel format
'-', # Output to stdout
]
# Execute ffmpeg as a subprocess
proc = subprocess.Popen(command, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
out, err = proc.communicate(input=frame_bytes)
if proc.returncode != 0:
err_msg = err.decode(errors='ignore').strip()
if 'deprecated pixel format used' not in err_msg.lower():
raise OSError(f'ffmpeg error during resizing/padding: {err_msg}')
else:
logger.debug(f'ffmpeg warning during resizing/padding: {err_msg}')
# Convert the raw output bytes to a numpy array with the padded dimensions
img_array = np.frombuffer(out, dtype=np.uint8).reshape((self.padded_size['height'], self.padded_size['width'], 3))
self._writer.append_data(img_array)
except Exception as e:
logger.warning(f'Could not process and add video frame: {e}')
def stop_and_save(self) -> None:
"""
Finalizes the video file by closing the writer.
This method should be called when the recording session is complete.
"""
if not self._is_active or not self._writer:
return
try:
self._writer.close()
logger.info(f'📹 Video recording saved successfully to: {self.output_path}')
except Exception as e:
logger.error(f'Failed to finalize and save video: {e}')
finally:
self._is_active = False
self._writer = None
@@ -0,0 +1,176 @@
from dataclasses import dataclass, field
from typing import Any
from bubus import BaseEvent
from cdp_use.cdp.target import TargetID
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, field_serializer
from browser_use.dom.views import DOMInteractedElement, SerializedDOMState
# Known placeholder image data for about:blank pages - a 4x4 white PNG
PLACEHOLDER_4PX_SCREENSHOT = (
'iVBORw0KGgoAAAANSUhEUgAAAAQAAAAECAIAAAAmkwkpAAAAFElEQVR4nGP8//8/AwwwMSAB3BwAlm4DBfIlvvkAAAAASUVORK5CYII='
)
# Pydantic
class TabInfo(BaseModel):
"""Represents information about a browser tab"""
model_config = ConfigDict(
extra='forbid',
validate_by_name=True,
validate_by_alias=True,
populate_by_name=True,
)
# Original fields
url: str
title: str
target_id: TargetID = Field(serialization_alias='tab_id', validation_alias=AliasChoices('tab_id', 'target_id'))
parent_target_id: TargetID | None = Field(
default=None, serialization_alias='parent_tab_id', validation_alias=AliasChoices('parent_tab_id', 'parent_target_id')
) # parent page that contains this popup or cross-origin iframe
@field_serializer('target_id')
def serialize_target_id(self, target_id: TargetID, _info: Any) -> str:
return target_id[-4:]
@field_serializer('parent_target_id')
def serialize_parent_target_id(self, parent_target_id: TargetID | None, _info: Any) -> str | None:
return parent_target_id[-4:] if parent_target_id else None
class PageInfo(BaseModel):
"""Comprehensive page size and scroll information"""
# Current viewport dimensions
viewport_width: int
viewport_height: int
# Total page dimensions
page_width: int
page_height: int
# Current scroll position
scroll_x: int
scroll_y: int
# Calculated scroll information
pixels_above: int
pixels_below: int
pixels_left: int
pixels_right: int
# Page statistics are now computed dynamically instead of stored
@dataclass
class BrowserStateSummary:
"""The summary of the browser's current state designed for an LLM to process"""
# provided by SerializedDOMState:
dom_state: SerializedDOMState
url: str
title: str
tabs: list[TabInfo]
screenshot: str | None = field(default=None, repr=False)
page_info: PageInfo | None = None # Enhanced page information
# Keep legacy fields for backward compatibility
pixels_above: int = 0
pixels_below: int = 0
browser_errors: list[str] = field(default_factory=list)
is_pdf_viewer: bool = False # Whether the current page is a PDF viewer
recent_events: str | None = None # Text summary of recent browser events
@dataclass
class BrowserStateHistory:
"""The summary of the browser's state at a past point in time to usse in LLM message history"""
url: str
title: str
tabs: list[TabInfo]
interacted_element: list[DOMInteractedElement | None] | list[None]
screenshot_path: str | None = None
def get_screenshot(self) -> str | None:
"""Load screenshot from disk and return as base64 string"""
if not self.screenshot_path:
return None
import base64
from pathlib import Path
path_obj = Path(self.screenshot_path)
if not path_obj.exists():
return None
try:
with open(path_obj, 'rb') as f:
screenshot_data = f.read()
return base64.b64encode(screenshot_data).decode('utf-8')
except Exception:
return None
def to_dict(self) -> dict[str, Any]:
data = {}
data['tabs'] = [tab.model_dump() for tab in self.tabs]
data['screenshot_path'] = self.screenshot_path
data['interacted_element'] = [el.to_dict() if el else None for el in self.interacted_element]
data['url'] = self.url
data['title'] = self.title
return data
class BrowserError(Exception):
"""Browser error with structured memory for LLM context management.
This exception class provides separate memory contexts for browser actions:
- short_term_memory: Immediate context shown once to the LLM for the next action
- long_term_memory: Persistent error information stored across steps
"""
message: str
short_term_memory: str | None = None
long_term_memory: str | None = None
details: dict[str, Any] | None = None
while_handling_event: BaseEvent[Any] | None = None
def __init__(
self,
message: str,
short_term_memory: str | None = None,
long_term_memory: str | None = None,
details: dict[str, Any] | None = None,
event: BaseEvent[Any] | None = None,
):
"""Initialize a BrowserError with structured memory contexts.
Args:
message: Technical error message for logging and debugging
short_term_memory: Context shown once to LLM (e.g., available actions, options)
long_term_memory: Persistent error info stored in agent memory
details: Additional metadata for debugging
event: The browser event that triggered this error
"""
self.message = message
self.short_term_memory = short_term_memory
self.long_term_memory = long_term_memory
self.details = details
self.while_handling_event = event
super().__init__(message)
def __str__(self) -> str:
if self.details:
return f'{self.message} ({self.details}) during: {self.while_handling_event}'
elif self.while_handling_event:
return f'{self.message} (while handling: {self.while_handling_event})'
else:
return self.message
class URLNotAllowedError(BrowserError):
"""Error raised when a URL is not allowed"""
@@ -0,0 +1,268 @@
"""Base watchdog class for browser monitoring components."""
import inspect
import time
from collections.abc import Iterable
from typing import Any, ClassVar
from bubus import BaseEvent, EventBus
from pydantic import BaseModel, ConfigDict, Field
from browser_use.browser.session import BrowserSession
class BaseWatchdog(BaseModel):
"""Base class for all browser watchdogs.
Watchdogs monitor browser state and emit events based on changes.
They automatically register event handlers based on method names.
Handler methods should be named: on_EventTypeName(self, event: EventTypeName)
"""
model_config = ConfigDict(
arbitrary_types_allowed=True, # allow non-serializable objects like EventBus/BrowserSession in fields
extra='forbid', # dont allow implicit class/instance state, everything must be a properly typed Field or PrivateAttr
validate_assignment=False, # avoid re-triggering __init__ / validators on values on every assignment
revalidate_instances='never', # avoid re-triggering __init__ / validators and erasing private attrs
)
# Class variables to statically define the list of events relevant to each watchdog
# (not enforced, just to make it easier to understand the code and debug watchdogs at runtime)
LISTENS_TO: ClassVar[list[type[BaseEvent[Any]]]] = [] # Events this watchdog listens to
EMITS: ClassVar[list[type[BaseEvent[Any]]]] = [] # Events this watchdog emits
# Core dependencies
event_bus: EventBus = Field()
browser_session: BrowserSession = Field()
# Shared state that other watchdogs might need to access should not be defined on BrowserSession, not here!
# Shared helper methods needed by other watchdogs should be defined on BrowserSession, not here!
# Alternatively, expose some events on the watchdog to allow access to state/helpers via event_bus system.
# Private state internal to the watchdog can be defined like this on BaseWatchdog subclasses:
# _screenshot_cache: dict[str, bytes] = PrivateAttr(default_factory=dict)
# _browser_crash_watcher_task: asyncio.Task | None = PrivateAttr(default=None)
# _cdp_download_tasks: WeakSet[asyncio.Task] = PrivateAttr(default_factory=WeakSet)
# ...
@property
def logger(self):
"""Get the logger from the browser session."""
return self.browser_session.logger
@staticmethod
def attach_handler_to_session(browser_session: 'BrowserSession', event_class: type[BaseEvent[Any]], handler) -> None:
"""Attach a single event handler to a browser session.
Args:
browser_session: The browser session to attach to
event_class: The event class to listen for
handler: The handler method (must start with 'on_' and end with event type)
"""
event_bus = browser_session.event_bus
# Validate handler naming convention
assert hasattr(handler, '__name__'), 'Handler must have a __name__ attribute'
assert handler.__name__.startswith('on_'), f'Handler {handler.__name__} must start with "on_"'
assert handler.__name__.endswith(event_class.__name__), (
f'Handler {handler.__name__} must end with event type {event_class.__name__}'
)
# Get the watchdog instance if this is a bound method
watchdog_instance = getattr(handler, '__self__', None)
watchdog_class_name = watchdog_instance.__class__.__name__ if watchdog_instance else 'Unknown'
# Color codes for logging
red = '\033[91m'
green = '\033[92m'
yellow = '\033[93m'
magenta = '\033[95m'
cyan = '\033[96m'
reset = '\033[0m'
# Create a wrapper function with unique name to avoid duplicate handler warnings
# Capture handler by value to avoid closure issues
def make_unique_handler(actual_handler):
async def unique_handler(event):
# just for debug logging, not used for anything else
parent_event = event_bus.event_history.get(event.event_parent_id) if event.event_parent_id else None
grandparent_event = (
event_bus.event_history.get(parent_event.event_parent_id)
if parent_event and parent_event.event_parent_id
else None
)
parent = (
f'{yellow}↲ triggered by {cyan}on_{parent_event.event_type}#{parent_event.event_id[-4:]}{reset}'
if parent_event
else f'{magenta}👈 by Agent{reset}'
)
grandparent = (
(
f'{yellow}↲ under {cyan}{grandparent_event.event_type}#{grandparent_event.event_id[-4:]}{reset}'
if grandparent_event
else f'{magenta}👈 by Agent{reset}'
)
if parent_event
else ''
)
event_str = f'#{event.event_id[-4:]}'
time_start = time.time()
watchdog_and_handler_str = f'[{watchdog_class_name}.{actual_handler.__name__}({event_str})]'.ljust(54)
browser_session.logger.debug(
f'{cyan}🚌 {watchdog_and_handler_str} ⏳ Starting... {reset} {parent} {grandparent}'
)
try:
# **EXECUTE THE EVENT HANDLER FUNCTION**
result = await actual_handler(event)
if isinstance(result, Exception):
raise result
# just for debug logging, not used for anything else
time_end = time.time()
time_elapsed = time_end - time_start
result_summary = '' if result is None else f' ➡️ {magenta}<{type(result).__name__}>{reset}'
parents_summary = f' {parent}'.replace('↲ triggered by ', f'{green}returned to {cyan}').replace(
'👈 by Agent', f'👉 {green}returned to {magenta}Agent{reset}'
)
browser_session.logger.debug(
f'{green}🚌 {watchdog_and_handler_str} ✅ Succeeded ({time_elapsed:.2f}s){reset}{result_summary}{parents_summary}'
)
return result
except Exception as e:
time_end = time.time()
time_elapsed = time_end - time_start
original_error = e
browser_session.logger.error(
f'{red}🚌 {watchdog_and_handler_str} ❌ Failed ({time_elapsed:.2f}s): {type(e).__name__}: {e}{reset}'
)
# attempt to repair potentially crashed CDP session
try:
if browser_session.agent_focus and browser_session.agent_focus.target_id:
# Common issue with CDP, some calls need the target to be active/foreground to succeed:
# screenshot, scroll, Page.handleJavaScriptDialog, and some others
browser_session.logger.debug(
f'{yellow}🚌 {watchdog_and_handler_str} ⚠️ Re-foregrounding target to try and recover crashed CDP session\n\t{browser_session.agent_focus}{reset}'
)
del browser_session._cdp_session_pool[browser_session.agent_focus.target_id]
browser_session.agent_focus = await browser_session.get_or_create_cdp_session(
target_id=browser_session.agent_focus.target_id, new_socket=True
)
await browser_session.agent_focus.cdp_client.send.Target.activateTarget(
params={'targetId': browser_session.agent_focus.target_id}
)
else:
await browser_session.get_or_create_cdp_session(target_id=None, new_socket=True, focus=True)
except Exception as sub_error:
if 'ConnectionClosedError' in str(type(sub_error)) or 'ConnectionError' in str(type(sub_error)):
browser_session.logger.error(
f'{red}🚌 {watchdog_and_handler_str} ❌ Browser closed or CDP Connection disconnected by remote. {red}{type(sub_error).__name__}: {sub_error}{reset}\n'
)
raise
else:
browser_session.logger.error(
f'{red}🚌 {watchdog_and_handler_str} ❌ CDP connected but failed to re-create CDP session after error "{type(original_error).__name__}: {original_error}" in {cyan}{actual_handler.__name__}({event.event_type}#{event.event_id[-4:]}){reset}: due to {red}{type(sub_error).__name__}: {sub_error}{reset}\n'
)
raise
return unique_handler
unique_handler = make_unique_handler(handler)
unique_handler.__name__ = f'{watchdog_class_name}.{handler.__name__}'
# Check if this handler is already registered - throw error if duplicate
existing_handlers = event_bus.handlers.get(event_class.__name__, [])
handler_names = [getattr(h, '__name__', str(h)) for h in existing_handlers]
if unique_handler.__name__ in handler_names:
raise RuntimeError(
f'[{watchdog_class_name}] Duplicate handler registration attempted! '
f'Handler {unique_handler.__name__} is already registered for {event_class.__name__}. '
f'This likely means attach_to_session() was called multiple times.'
)
event_bus.on(event_class, unique_handler)
def attach_to_session(self) -> None:
"""Attach watchdog to its browser session and start monitoring.
This method handles event listener registration. The watchdog is already
bound to a browser session via self.browser_session from initialization.
"""
# Register event handlers automatically based on method names
assert self.browser_session is not None, 'Root CDP client not initialized - browser may not be connected yet'
from browser_use.browser import events
event_classes = {}
for name in dir(events):
obj = getattr(events, name)
if inspect.isclass(obj) and issubclass(obj, BaseEvent) and obj is not BaseEvent:
event_classes[name] = obj
# Find all handler methods (on_EventName)
registered_events = set()
for method_name in dir(self):
if method_name.startswith('on_') and callable(getattr(self, method_name)):
# Extract event name from method name (on_EventName -> EventName)
event_name = method_name[3:] # Remove 'on_' prefix
if event_name in event_classes:
event_class = event_classes[event_name]
# ASSERTION: If LISTENS_TO is defined, enforce it
if self.LISTENS_TO:
assert event_class in self.LISTENS_TO, (
f'[{self.__class__.__name__}] Handler {method_name} listens to {event_name} '
f'but {event_name} is not declared in LISTENS_TO: {[e.__name__ for e in self.LISTENS_TO]}'
)
handler = getattr(self, method_name)
# Use the static helper to attach the handler
self.attach_handler_to_session(self.browser_session, event_class, handler)
registered_events.add(event_class)
# ASSERTION: If LISTENS_TO is defined, ensure all declared events have handlers
if self.LISTENS_TO:
missing_handlers = set(self.LISTENS_TO) - registered_events
if missing_handlers:
missing_names = [e.__name__ for e in missing_handlers]
self.logger.warning(
f'[{self.__class__.__name__}] LISTENS_TO declares {missing_names} '
f'but no handlers found (missing on_{"_, on_".join(missing_names)} methods)'
)
def __del__(self) -> None:
"""Clean up any running tasks during garbage collection."""
# A BIT OF MAGIC: Cancel any private attributes that look like asyncio tasks
try:
for attr_name in dir(self):
# e.g. _browser_crash_watcher_task = asyncio.Task
if attr_name.startswith('_') and attr_name.endswith('_task'):
try:
task = getattr(self, attr_name)
if hasattr(task, 'cancel') and callable(task.cancel) and not task.done():
task.cancel()
# self.logger.debug(f'[{self.__class__.__name__}] Cancelled {attr_name} during cleanup')
except Exception:
pass # Ignore errors during cleanup
# e.g. _cdp_download_tasks = WeakSet[asyncio.Task] or list[asyncio.Task]
if attr_name.startswith('_') and attr_name.endswith('_tasks') and isinstance(getattr(self, attr_name), Iterable):
for task in getattr(self, attr_name):
try:
if hasattr(task, 'cancel') and callable(task.cancel) and not task.done():
task.cancel()
# self.logger.debug(f'[{self.__class__.__name__}] Cancelled {attr_name} during cleanup')
except Exception:
pass # Ignore errors during cleanup
except Exception as e:
from browser_use.utils import logger
logger.error(f'⚠️ Error during BrowserSession {self.__class__.__name__} gargabe collection __del__(): {type(e)}: {e}')
@@ -0,0 +1,253 @@
"""About:blank watchdog for managing about:blank tabs with DVD screensaver."""
from typing import TYPE_CHECKING, ClassVar
from bubus import BaseEvent
from cdp_use.cdp.target import TargetID
from pydantic import PrivateAttr
from browser_use.browser.events import (
AboutBlankDVDScreensaverShownEvent,
BrowserStopEvent,
BrowserStoppedEvent,
CloseTabEvent,
NavigateToUrlEvent,
TabClosedEvent,
TabCreatedEvent,
)
from browser_use.browser.watchdog_base import BaseWatchdog
if TYPE_CHECKING:
pass
class AboutBlankWatchdog(BaseWatchdog):
"""Ensures there's always exactly one about:blank tab with DVD screensaver."""
# Event contracts
LISTENS_TO: ClassVar[list[type[BaseEvent]]] = [
BrowserStopEvent,
BrowserStoppedEvent,
TabCreatedEvent,
TabClosedEvent,
]
EMITS: ClassVar[list[type[BaseEvent]]] = [
NavigateToUrlEvent,
CloseTabEvent,
AboutBlankDVDScreensaverShownEvent,
]
_stopping: bool = PrivateAttr(default=False)
async def on_BrowserStopEvent(self, event: BrowserStopEvent) -> None:
"""Handle browser stop request - stop creating new tabs."""
# logger.info('[AboutBlankWatchdog] Browser stop requested, stopping tab creation')
self._stopping = True
async def on_BrowserStoppedEvent(self, event: BrowserStoppedEvent) -> None:
"""Handle browser stopped event."""
# logger.info('[AboutBlankWatchdog] Browser stopped')
self._stopping = True
async def on_TabCreatedEvent(self, event: TabCreatedEvent) -> None:
"""Check tabs when a new tab is created."""
# logger.debug(f'[AboutBlankWatchdog] New tab created: {event.url}')
# If an about:blank tab was created, show DVD screensaver on all about:blank tabs
if event.url == 'about:blank':
await self._show_dvd_screensaver_on_about_blank_tabs()
async def on_TabClosedEvent(self, event: TabClosedEvent) -> None:
"""Check tabs when a tab is closed and proactively create about:blank if needed."""
# logger.debug('[AboutBlankWatchdog] Tab closing, checking if we need to create about:blank tab')
# Don't create new tabs if browser is shutting down
if self._stopping:
# logger.debug('[AboutBlankWatchdog] Browser is stopping, not creating new tabs')
return
# Check if we're about to close the last tab (event happens BEFORE tab closes)
# Use _cdp_get_all_pages for quick check without fetching titles
page_targets = await self.browser_session._cdp_get_all_pages()
if len(page_targets) <= 1:
self.logger.debug(
'[AboutBlankWatchdog] Last tab closing, creating new about:blank tab to avoid closing entire browser'
)
# Create the animation tab since no tabs should remain
navigate_event = self.event_bus.dispatch(NavigateToUrlEvent(url='about:blank', new_tab=True))
await navigate_event
# Show DVD screensaver on the new tab
await self._show_dvd_screensaver_on_about_blank_tabs()
else:
# Multiple tabs exist, check after close
await self._check_and_ensure_about_blank_tab()
async def attach_to_target(self, target_id: TargetID) -> None:
"""AboutBlankWatchdog doesn't monitor individual targets."""
pass
async def _check_and_ensure_about_blank_tab(self) -> None:
"""Check current tabs and ensure exactly one about:blank tab with animation exists."""
try:
# For quick checks, just get page targets without titles to reduce noise
page_targets = await self.browser_session._cdp_get_all_pages()
# If no tabs exist at all, create one to keep browser alive
if len(page_targets) == 0:
# Only create a new tab if there are no tabs at all
self.logger.debug('[AboutBlankWatchdog] No tabs exist, creating new about:blank DVD screensaver tab')
navigate_event = self.event_bus.dispatch(NavigateToUrlEvent(url='about:blank', new_tab=True))
await navigate_event
# Show DVD screensaver on the new tab
await self._show_dvd_screensaver_on_about_blank_tabs()
# Otherwise there are tabs, don't create new ones to avoid interfering
except Exception as e:
self.logger.error(f'[AboutBlankWatchdog] Error ensuring about:blank tab: {e}')
async def _show_dvd_screensaver_on_about_blank_tabs(self) -> None:
"""Show DVD screensaver on all about:blank pages only."""
try:
# Get just the page targets without expensive title fetching
page_targets = await self.browser_session._cdp_get_all_pages()
browser_session_label = str(self.browser_session.id)[-4:]
for page_target in page_targets:
target_id = page_target['targetId']
url = page_target['url']
# Only target about:blank pages specifically
if url == 'about:blank':
await self._show_dvd_screensaver_loading_animation_cdp(target_id, browser_session_label)
except Exception as e:
self.logger.error(f'[AboutBlankWatchdog] Error showing DVD screensaver: {e}')
async def _show_dvd_screensaver_loading_animation_cdp(self, target_id: TargetID, browser_session_label: str) -> None:
"""
Injects a DVD screensaver-style bouncing logo loading animation overlay into the target using CDP.
This is used to visually indicate that the browser is setting up or waiting.
"""
try:
# Create temporary session for this target without switching focus
temp_session = await self.browser_session.get_or_create_cdp_session(target_id, focus=False)
# Inject the DVD screensaver script (from main branch with idempotency added)
script = f"""
(function(browser_session_label) {{
// Idempotency check
if (window.__dvdAnimationRunning) {{
return; // Already running, don't add another
}}
window.__dvdAnimationRunning = true;
// Ensure document.body exists before proceeding
if (!document.body) {{
// Try again after DOM is ready
window.__dvdAnimationRunning = false; // Reset flag to retry
if (document.readyState === 'loading') {{
document.addEventListener('DOMContentLoaded', () => arguments.callee(browser_session_label));
}}
return;
}}
const animated_title = `Starting agent ${{browser_session_label}}...`;
if (document.title === animated_title) {{
return; // already run on this tab, dont run again
}}
document.title = animated_title;
// Create the main overlay
const loadingOverlay = document.createElement('div');
loadingOverlay.id = 'pretty-loading-animation';
loadingOverlay.style.position = 'fixed';
loadingOverlay.style.top = '0';
loadingOverlay.style.left = '0';
loadingOverlay.style.width = '100vw';
loadingOverlay.style.height = '100vh';
loadingOverlay.style.background = '#000';
loadingOverlay.style.zIndex = '99999';
loadingOverlay.style.overflow = 'hidden';
// Create the image element
const img = document.createElement('img');
img.src = 'https://cf.browser-use.com/logo.svg';
img.alt = 'Browser-Use';
img.style.width = '200px';
img.style.height = 'auto';
img.style.position = 'absolute';
img.style.left = '0px';
img.style.top = '0px';
img.style.zIndex = '2';
img.style.opacity = '0.8';
loadingOverlay.appendChild(img);
document.body.appendChild(loadingOverlay);
// DVD screensaver bounce logic
let x = Math.random() * (window.innerWidth - 300);
let y = Math.random() * (window.innerHeight - 300);
let dx = 1.2 + Math.random() * 0.4; // px per frame
let dy = 1.2 + Math.random() * 0.4;
// Randomize direction
if (Math.random() > 0.5) dx = -dx;
if (Math.random() > 0.5) dy = -dy;
function animate() {{
const imgWidth = img.offsetWidth || 300;
const imgHeight = img.offsetHeight || 300;
x += dx;
y += dy;
if (x <= 0) {{
x = 0;
dx = Math.abs(dx);
}} else if (x + imgWidth >= window.innerWidth) {{
x = window.innerWidth - imgWidth;
dx = -Math.abs(dx);
}}
if (y <= 0) {{
y = 0;
dy = Math.abs(dy);
}} else if (y + imgHeight >= window.innerHeight) {{
y = window.innerHeight - imgHeight;
dy = -Math.abs(dy);
}}
img.style.left = `${{x}}px`;
img.style.top = `${{y}}px`;
requestAnimationFrame(animate);
}}
animate();
// Responsive: update bounds on resize
window.addEventListener('resize', () => {{
x = Math.min(x, window.innerWidth - img.offsetWidth);
y = Math.min(y, window.innerHeight - img.offsetHeight);
}});
// Add a little CSS for smoothness
const style = document.createElement('style');
style.textContent = `
#pretty-loading-animation {{
/*backdrop-filter: blur(2px) brightness(0.9);*/
}}
#pretty-loading-animation img {{
user-select: none;
pointer-events: none;
}}
`;
document.head.appendChild(style);
}})('{browser_session_label}');
"""
await temp_session.cdp_client.send.Runtime.evaluate(params={'expression': script}, session_id=temp_session.session_id)
# No need to detach - session is cached
# Dispatch event
self.event_bus.dispatch(AboutBlankDVDScreensaverShownEvent(target_id=target_id))
except Exception as e:
self.logger.error(f'[AboutBlankWatchdog] Error injecting DVD screensaver: {e}')
@@ -0,0 +1,362 @@
"""Browser watchdog for monitoring crashes and network timeouts using CDP."""
import asyncio
import time
from typing import TYPE_CHECKING, ClassVar
import psutil
from bubus import BaseEvent
from cdp_use.cdp.target import SessionID, TargetID
from cdp_use.cdp.target.events import TargetCrashedEvent
from pydantic import Field, PrivateAttr
from browser_use.browser.events import (
BrowserConnectedEvent,
BrowserErrorEvent,
BrowserStoppedEvent,
TabCreatedEvent,
)
from browser_use.browser.watchdog_base import BaseWatchdog
if TYPE_CHECKING:
pass
class NetworkRequestTracker:
"""Tracks ongoing network requests."""
def __init__(self, request_id: str, start_time: float, url: str, method: str, resource_type: str | None = None):
self.request_id = request_id
self.start_time = start_time
self.url = url
self.method = method
self.resource_type = resource_type
class CrashWatchdog(BaseWatchdog):
"""Monitors browser health for crashes and network timeouts using CDP."""
# Event contracts
LISTENS_TO: ClassVar[list[type[BaseEvent]]] = [
BrowserConnectedEvent,
BrowserStoppedEvent,
TabCreatedEvent,
]
EMITS: ClassVar[list[type[BaseEvent]]] = [BrowserErrorEvent]
# Configuration
network_timeout_seconds: float = Field(default=10.0)
check_interval_seconds: float = Field(default=5.0) # Reduced frequency to reduce noise
# Private state
_active_requests: dict[str, NetworkRequestTracker] = PrivateAttr(default_factory=dict)
_monitoring_task: asyncio.Task | None = PrivateAttr(default=None)
_last_responsive_checks: dict[str, float] = PrivateAttr(default_factory=dict) # target_url -> timestamp
_cdp_event_tasks: set[asyncio.Task] = PrivateAttr(default_factory=set) # Track CDP event handler tasks
_sessions_with_listeners: set[str] = PrivateAttr(default_factory=set) # Track sessions that already have event listeners
async def on_BrowserConnectedEvent(self, event: BrowserConnectedEvent) -> None:
"""Start monitoring when browser is connected."""
# logger.debug('[CrashWatchdog] Browser connected event received, beginning monitoring')
asyncio.create_task(self._start_monitoring())
# logger.debug(f'[CrashWatchdog] Monitoring task started: {self._monitoring_task and not self._monitoring_task.done()}')
async def on_BrowserStoppedEvent(self, event: BrowserStoppedEvent) -> None:
"""Stop monitoring when browser stops."""
# logger.debug('[CrashWatchdog] Browser stopped, ending monitoring')
await self._stop_monitoring()
async def on_TabCreatedEvent(self, event: TabCreatedEvent) -> None:
"""Attach to new tab."""
assert self.browser_session.agent_focus is not None, 'No current target ID'
await self.attach_to_target(self.browser_session.agent_focus.target_id)
async def attach_to_target(self, target_id: TargetID) -> None:
"""Set up crash monitoring for a specific target using CDP."""
try:
# Create temporary session for monitoring without switching focus
cdp_session = await self.browser_session.get_or_create_cdp_session(target_id, focus=False)
# Check if we already have listeners for this session
if cdp_session.session_id in self._sessions_with_listeners:
self.logger.debug(f'[CrashWatchdog] Event listeners already exist for session: {cdp_session.session_id}')
return
# Set up network event handlers
# def on_request_will_be_sent(event):
# # Create and track the task
# task = asyncio.create_task(self._on_request_cdp(event))
# self._cdp_event_tasks.add(task)
# # Remove from set when done
# task.add_done_callback(lambda t: self._cdp_event_tasks.discard(t))
# def on_response_received(event):
# self._on_response_cdp(event)
# def on_loading_failed(event):
# self._on_request_failed_cdp(event)
# def on_loading_finished(event):
# self._on_request_finished_cdp(event)
# Register event handlers
# TEMPORARILY DISABLED: Network events causing too much logging
# cdp_client.on('Network.requestWillBeSent', on_request_will_be_sent, session_id=session_id)
# cdp_client.on('Network.responseReceived', on_response_received, session_id=session_id)
# cdp_client.on('Network.loadingFailed', on_loading_failed, session_id=session_id)
# cdp_client.on('Network.loadingFinished', on_loading_finished, session_id=session_id)
def on_target_crashed(event: TargetCrashedEvent, session_id: SessionID | None = None):
# Create and track the task
task = asyncio.create_task(self._on_target_crash_cdp(target_id))
self._cdp_event_tasks.add(task)
# Remove from set when done
task.add_done_callback(lambda t: self._cdp_event_tasks.discard(t))
cdp_session.cdp_client.register.Target.targetCrashed(on_target_crashed)
# Track that we've added listeners to this session
self._sessions_with_listeners.add(cdp_session.session_id)
# Get target info for logging
targets = await cdp_session.cdp_client.send.Target.getTargets()
target_info = next((t for t in targets['targetInfos'] if t['targetId'] == target_id), None)
if target_info:
self.logger.debug(f'[CrashWatchdog] Added target to monitoring: {target_info.get("url", "unknown")}')
except Exception as e:
self.logger.warning(f'[CrashWatchdog] Failed to attach to target {target_id}: {e}')
async def _on_request_cdp(self, event: dict) -> None:
"""Track new network request from CDP event."""
request_id = event.get('requestId', '')
request = event.get('request', {})
self._active_requests[request_id] = NetworkRequestTracker(
request_id=request_id,
start_time=time.time(),
url=request.get('url', ''),
method=request.get('method', ''),
resource_type=event.get('type'),
)
# logger.debug(f'[CrashWatchdog] Tracking request: {request.get("method", "")} {request.get("url", "")[:50]}...')
def _on_response_cdp(self, event: dict) -> None:
"""Remove request from tracking on response."""
request_id = event.get('requestId', '')
if request_id in self._active_requests:
elapsed = time.time() - self._active_requests[request_id].start_time
response = event.get('response', {})
self.logger.debug(f'[CrashWatchdog] Request completed in {elapsed:.2f}s: {response.get("url", "")[:50]}...')
# Don't remove yet - wait for loadingFinished
def _on_request_failed_cdp(self, event: dict) -> None:
"""Remove request from tracking on failure."""
request_id = event.get('requestId', '')
if request_id in self._active_requests:
elapsed = time.time() - self._active_requests[request_id].start_time
self.logger.debug(
f'[CrashWatchdog] Request failed after {elapsed:.2f}s: {self._active_requests[request_id].url[:50]}...'
)
del self._active_requests[request_id]
def _on_request_finished_cdp(self, event: dict) -> None:
"""Remove request from tracking when loading is finished."""
request_id = event.get('requestId', '')
self._active_requests.pop(request_id, None)
async def _on_target_crash_cdp(self, target_id: TargetID) -> None:
"""Handle target crash detected via CDP."""
# Remove crashed session from pool
if session := self.browser_session._cdp_session_pool.pop(target_id, None):
await session.disconnect()
self.logger.debug(f'[CrashWatchdog] Removed crashed session from pool: {target_id}')
# Get target info
cdp_client = self.browser_session.cdp_client
targets = await cdp_client.send.Target.getTargets()
target_info = next((t for t in targets['targetInfos'] if t['targetId'] == target_id), None)
if (
target_info
and self.browser_session.agent_focus
and target_info['targetId'] == self.browser_session.agent_focus.target_id
):
self.browser_session.agent_focus.target_id = None # type: ignore
self.browser_session.agent_focus.session_id = None # type: ignore
self.logger.error(
f'[CrashWatchdog] 💥 Target crashed, navigating Agent to a new tab: {target_info.get("url", "unknown")}'
)
# Also emit generic browser error
self.event_bus.dispatch(
BrowserErrorEvent(
error_type='TargetCrash',
message=f'Target crashed: {target_id}',
details={
# 'url': target_url, # TODO: add url to details
'target_id': target_id,
},
)
)
async def _start_monitoring(self) -> None:
"""Start the monitoring loop."""
assert self.browser_session.cdp_client is not None, 'Root CDP client not initialized - browser may not be connected yet'
if self._monitoring_task and not self._monitoring_task.done():
# logger.info('[CrashWatchdog] Monitoring already running')
return
self._monitoring_task = asyncio.create_task(self._monitoring_loop())
# logger.debug('[CrashWatchdog] Monitoring loop created and started')
async def _stop_monitoring(self) -> None:
"""Stop the monitoring loop."""
if self._monitoring_task and not self._monitoring_task.done():
self._monitoring_task.cancel()
try:
await self._monitoring_task
except asyncio.CancelledError:
pass
self.logger.debug('[CrashWatchdog] Monitoring loop stopped')
# Cancel all CDP event handler tasks
for task in list(self._cdp_event_tasks):
if not task.done():
task.cancel()
# Wait for all tasks to complete cancellation
if self._cdp_event_tasks:
await asyncio.gather(*self._cdp_event_tasks, return_exceptions=True)
self._cdp_event_tasks.clear()
# Clear tracking (CDP sessions are cached and managed by BrowserSession)
self._active_requests.clear()
self._sessions_with_listeners.clear()
async def _monitoring_loop(self) -> None:
"""Main monitoring loop."""
await asyncio.sleep(10) # give browser time to start up and load the first page after first LLM call
while True:
try:
await self._check_network_timeouts()
await self._check_browser_health()
await asyncio.sleep(self.check_interval_seconds)
except asyncio.CancelledError:
break
except Exception as e:
self.logger.error(f'[CrashWatchdog] Error in monitoring loop: {e}')
async def _check_network_timeouts(self) -> None:
"""Check for network requests exceeding timeout."""
current_time = time.time()
timed_out_requests = []
# Debug logging
if self._active_requests:
self.logger.debug(
f'[CrashWatchdog] Checking {len(self._active_requests)} active requests for timeouts (threshold: {self.network_timeout_seconds}s)'
)
for request_id, tracker in self._active_requests.items():
elapsed = current_time - tracker.start_time
self.logger.debug(
f'[CrashWatchdog] Request {tracker.url[:30]}... elapsed: {elapsed:.1f}s, timeout: {self.network_timeout_seconds}s'
)
if elapsed >= self.network_timeout_seconds:
timed_out_requests.append((request_id, tracker))
# Emit events for timed out requests
for request_id, tracker in timed_out_requests:
self.logger.warning(
f'[CrashWatchdog] Network request timeout after {self.network_timeout_seconds}s: '
f'{tracker.method} {tracker.url[:100]}...'
)
self.event_bus.dispatch(
BrowserErrorEvent(
error_type='NetworkTimeout',
message=f'Network request timed out after {self.network_timeout_seconds}s',
details={
'url': tracker.url,
'method': tracker.method,
'resource_type': tracker.resource_type,
'elapsed_seconds': current_time - tracker.start_time,
},
)
)
# Remove from tracking
del self._active_requests[request_id]
async def _check_browser_health(self) -> None:
"""Check if browser and targets are still responsive."""
try:
try:
self.logger.debug(f'[CrashWatchdog] Checking browser health for target {self.browser_session.agent_focus}')
cdp_session = await self.browser_session.get_or_create_cdp_session()
except Exception as e:
self.logger.debug(
f'[CrashWatchdog] Checking browser health for target {self.browser_session.agent_focus} error: {type(e).__name__}: {e}'
)
self.agent_focus = cdp_session = await self.browser_session.get_or_create_cdp_session(
target_id=self.agent_focus.target_id, new_socket=True, focus=True
)
for target in (await self.browser_session.cdp_client.send.Target.getTargets()).get('targetInfos', []):
if target.get('type') == 'page':
cdp_session = await self.browser_session.get_or_create_cdp_session(target_id=target.get('targetId'))
if self._is_new_tab_page(target.get('url')) and target.get('url') != 'about:blank':
self.logger.debug(
f'[CrashWatchdog] Redirecting chrome://new-tab-page/ to about:blank {target.get("url")}'
)
await cdp_session.cdp_client.send.Page.navigate(
params={'url': 'about:blank'}, session_id=cdp_session.session_id
)
# Quick ping to check if session is alive
self.logger.debug(f'[CrashWatchdog] Attempting to run simple JS test expression in session {cdp_session} 1+1')
await asyncio.wait_for(
cdp_session.cdp_client.send.Runtime.evaluate(params={'expression': '1+1'}, session_id=cdp_session.session_id),
timeout=1.0,
)
self.logger.debug(f'[CrashWatchdog] Browser health check passed for target {self.browser_session.agent_focus}')
except Exception as e:
self.logger.error(
f'[CrashWatchdog] ❌ Crashed session detected for target {self.browser_session.agent_focus} error: {type(e).__name__}: {e}'
)
# Remove crashed session from pool
if self.browser_session.agent_focus and (target_id := self.browser_session.agent_focus.target_id):
if session := self.browser_session._cdp_session_pool.pop(target_id, None):
await session.disconnect()
self.logger.debug(f'[CrashWatchdog] Removed crashed session from pool: {target_id}')
self.browser_session.agent_focus.target_id = None # type: ignore
# Check browser process if we have PID
if self.browser_session._local_browser_watchdog and (proc := self.browser_session._local_browser_watchdog._subprocess):
try:
if proc.status() in (psutil.STATUS_ZOMBIE, psutil.STATUS_DEAD):
self.logger.error(f'[CrashWatchdog] Browser process {proc.pid} has crashed')
# Clear all sessions from pool when browser crashes
for session in self.browser_session._cdp_session_pool.values():
await session.disconnect()
self.browser_session._cdp_session_pool.clear()
self.logger.debug('[CrashWatchdog] Cleared all sessions from pool due to browser crash')
self.event_bus.dispatch(
BrowserErrorEvent(
error_type='BrowserProcessCrashed',
message=f'Browser process {proc.pid} has crashed',
details={'pid': proc.pid, 'status': proc.status()},
)
)
await self._stop_monitoring()
return
except Exception:
pass # psutil not available or process doesn't exist
@staticmethod
def _is_new_tab_page(url: str) -> bool:
"""Check if URL is a new tab page."""
return url in ['about:blank', 'chrome://new-tab-page/', 'chrome://newtab/']
@@ -0,0 +1,582 @@
"""DOM watchdog for browser DOM tree management using CDP."""
import asyncio
import time
from typing import TYPE_CHECKING
from browser_use.browser.events import (
BrowserErrorEvent,
BrowserStateRequestEvent,
ScreenshotEvent,
TabCreatedEvent,
)
from browser_use.browser.watchdog_base import BaseWatchdog
from browser_use.dom.service import DomService
from browser_use.dom.views import (
EnhancedDOMTreeNode,
SerializedDOMState,
)
from browser_use.observability import observe_debug
from browser_use.utils import time_execution_async
if TYPE_CHECKING:
from browser_use.browser.views import BrowserStateSummary, PageInfo
class DOMWatchdog(BaseWatchdog):
"""Handles DOM tree building, serialization, and element access via CDP.
This watchdog acts as a bridge between the event-driven browser session
and the DomService implementation, maintaining cached state and providing
helper methods for other watchdogs.
"""
LISTENS_TO = [TabCreatedEvent, BrowserStateRequestEvent]
EMITS = [BrowserErrorEvent]
# Public properties for other watchdogs
selector_map: dict[int, EnhancedDOMTreeNode] | None = None
current_dom_state: SerializedDOMState | None = None
enhanced_dom_tree: EnhancedDOMTreeNode | None = None
# Internal DOM service
_dom_service: DomService | None = None
async def on_TabCreatedEvent(self, event: TabCreatedEvent) -> None:
# self.logger.debug('Setting up init scripts in browser')
return None
def _get_recent_events_str(self, limit: int = 10) -> str | None:
"""Get the most recent events from the event bus as JSON.
Args:
limit: Maximum number of recent events to include
Returns:
JSON string of recent events or None if not available
"""
import json
try:
# Get all events from history, sorted by creation time (most recent first)
all_events = sorted(
self.browser_session.event_bus.event_history.values(), key=lambda e: e.event_created_at.timestamp(), reverse=True
)
# Take the most recent events and create JSON-serializable data
recent_events_data = []
for event in all_events[:limit]:
event_data = {
'event_type': event.event_type,
'timestamp': event.event_created_at.isoformat(),
}
# Add specific fields for certain event types
if hasattr(event, 'url'):
event_data['url'] = getattr(event, 'url')
if hasattr(event, 'error_message'):
event_data['error_message'] = getattr(event, 'error_message')
if hasattr(event, 'target_id'):
event_data['target_id'] = getattr(event, 'target_id')
recent_events_data.append(event_data)
return json.dumps(recent_events_data) # Return empty array if no events
except Exception as e:
self.logger.debug(f'Failed to get recent events: {e}')
return json.dumps([]) # Return empty JSON array on error
@observe_debug(ignore_input=True, ignore_output=True, name='browser_state_request_event')
async def on_BrowserStateRequestEvent(self, event: BrowserStateRequestEvent) -> 'BrowserStateSummary':
"""Handle browser state request by coordinating DOM building and screenshot capture.
This is the main entry point for getting the complete browser state.
Args:
event: The browser state request event with options
Returns:
Complete BrowserStateSummary with DOM, screenshot, and target info
"""
from browser_use.browser.views import BrowserStateSummary, PageInfo
self.logger.debug('🔍 DOMWatchdog.on_BrowserStateRequestEvent: STARTING browser state request')
page_url = await self.browser_session.get_current_page_url()
self.logger.debug(f'🔍 DOMWatchdog.on_BrowserStateRequestEvent: Got page URL: {page_url}')
if self.browser_session.agent_focus:
self.logger.debug(
f'Current page URL: {page_url}, target_id: {self.browser_session.agent_focus.target_id}, session_id: {self.browser_session.agent_focus.session_id}'
)
else:
self.logger.debug(f'Current page URL: {page_url}, no cdp_session attached')
# check if we should skip DOM tree build for pointless pages
not_a_meaningful_website = page_url.lower().split(':', 1)[0] not in ('http', 'https')
# Wait for page stability using browser profile settings (main branch pattern)
if not not_a_meaningful_website:
self.logger.debug('🔍 DOMWatchdog.on_BrowserStateRequestEvent: ⏳ Waiting for page stability...')
try:
await self._wait_for_stable_network()
self.logger.debug('🔍 DOMWatchdog.on_BrowserStateRequestEvent: ✅ Page stability complete')
except Exception as e:
self.logger.warning(
f'🔍 DOMWatchdog.on_BrowserStateRequestEvent: Network waiting failed: {e}, continuing anyway...'
)
# Get tabs info once at the beginning for all paths
self.logger.debug('🔍 DOMWatchdog.on_BrowserStateRequestEvent: Getting tabs info...')
tabs_info = await self.browser_session.get_tabs()
self.logger.debug(f'🔍 DOMWatchdog.on_BrowserStateRequestEvent: Got {len(tabs_info)} tabs')
self.logger.debug(f'🔍 DOMWatchdog.on_BrowserStateRequestEvent: Tabs info: {tabs_info}')
# Get viewport / scroll position info, remember changing scroll position should invalidate selector_map cache because it only includes visible elements
# cdp_session = await self.browser_session.get_or_create_cdp_session(focus=True)
# scroll_info = await cdp_session.cdp_client.send.Runtime.evaluate(
# params={'expression': 'JSON.stringify({y: document.body.scrollTop, x: document.body.scrollLeft, width: document.documentElement.clientWidth, height: document.documentElement.clientHeight})'},
# session_id=cdp_session.session_id,
# )
# self.logger.debug(f'🔍 DOMWatchdog.on_BrowserStateRequestEvent: Got scroll info: {scroll_info["result"]}')
try:
# Fast path for empty pages
if not_a_meaningful_website:
self.logger.debug(f'⚡ Skipping BuildDOMTree for empty target: {page_url}')
self.logger.debug(f'📸 Not taking screenshot for empty page: {page_url} (non-http/https URL)')
# Create minimal DOM state
content = SerializedDOMState(_root=None, selector_map={})
# Skip screenshot for empty pages
screenshot_b64 = None
# Try to get page info from CDP, fall back to defaults if unavailable
try:
page_info = await self._get_page_info()
except Exception as e:
self.logger.debug(f'Failed to get page info from CDP for empty page: {e}, using fallback')
# Use default viewport dimensions
viewport = self.browser_session.browser_profile.viewport or {'width': 1280, 'height': 720}
page_info = PageInfo(
viewport_width=viewport['width'],
viewport_height=viewport['height'],
page_width=viewport['width'],
page_height=viewport['height'],
scroll_x=0,
scroll_y=0,
pixels_above=0,
pixels_below=0,
pixels_left=0,
pixels_right=0,
)
return BrowserStateSummary(
dom_state=content,
url=page_url,
title='Empty Tab',
tabs=tabs_info,
screenshot=screenshot_b64,
page_info=page_info,
pixels_above=0,
pixels_below=0,
browser_errors=[],
is_pdf_viewer=False,
recent_events=self._get_recent_events_str() if event.include_recent_events else None,
)
# Execute DOM building and screenshot capture in parallel
dom_task = None
screenshot_task = None
# Start DOM building task if requested
if event.include_dom:
self.logger.debug('🔍 DOMWatchdog.on_BrowserStateRequestEvent: 🌳 Starting DOM tree build task...')
previous_state = (
self.browser_session._cached_browser_state_summary.dom_state
if self.browser_session._cached_browser_state_summary
else None
)
dom_task = asyncio.create_task(self._build_dom_tree_without_highlights(previous_state))
# Start clean screenshot task if requested (without JS highlights)
if event.include_screenshot:
self.logger.debug('🔍 DOMWatchdog.on_BrowserStateRequestEvent: 📸 Starting clean screenshot task...')
screenshot_task = asyncio.create_task(self._capture_clean_screenshot())
# Wait for both tasks to complete
content = None
screenshot_b64 = None
if dom_task:
try:
content = await dom_task
self.logger.debug('🔍 DOMWatchdog.on_BrowserStateRequestEvent: ✅ DOM tree build completed')
except Exception as e:
self.logger.warning(f'🔍 DOMWatchdog.on_BrowserStateRequestEvent: DOM build failed: {e}, using minimal state')
content = SerializedDOMState(_root=None, selector_map={})
else:
content = SerializedDOMState(_root=None, selector_map={})
if screenshot_task:
try:
screenshot_b64 = await screenshot_task
self.logger.debug('🔍 DOMWatchdog.on_BrowserStateRequestEvent: ✅ Clean screenshot captured')
except Exception as e:
self.logger.warning(f'🔍 DOMWatchdog.on_BrowserStateRequestEvent: Clean screenshot failed: {e}')
screenshot_b64 = None
# Apply Python-based highlighting if both DOM and screenshot are available
if screenshot_b64 and content and content.selector_map and self.browser_session.browser_profile.highlight_elements:
try:
self.logger.debug('🔍 DOMWatchdog.on_BrowserStateRequestEvent: 🎨 Applying Python-based highlighting...')
from browser_use.browser.python_highlights import create_highlighted_screenshot_async
# Get CDP session for viewport info
cdp_session = await self.browser_session.get_or_create_cdp_session()
start = time.time()
screenshot_b64 = await create_highlighted_screenshot_async(
screenshot_b64,
content.selector_map,
cdp_session,
self.browser_session.browser_profile.filter_highlight_ids,
)
self.logger.debug(
f'🔍 DOMWatchdog.on_BrowserStateRequestEvent: ✅ Applied highlights to {len(content.selector_map)} elements in {time.time() - start:.2f}s'
)
except Exception as e:
self.logger.warning(f'🔍 DOMWatchdog.on_BrowserStateRequestEvent: Python highlighting failed: {e}')
# Ensure we have valid content
if not content:
content = SerializedDOMState(_root=None, selector_map={})
# Tabs info already fetched at the beginning
# Get target title safely
try:
self.logger.debug('🔍 DOMWatchdog.on_BrowserStateRequestEvent: Getting page title...')
title = await asyncio.wait_for(self.browser_session.get_current_page_title(), timeout=1.0)
self.logger.debug(f'🔍 DOMWatchdog.on_BrowserStateRequestEvent: Got title: {title}')
except Exception as e:
self.logger.debug(f'🔍 DOMWatchdog.on_BrowserStateRequestEvent: Failed to get title: {e}')
title = 'Page'
# Get comprehensive page info from CDP with timeout
try:
self.logger.debug('🔍 DOMWatchdog.on_BrowserStateRequestEvent: Getting page info from CDP...')
page_info = await asyncio.wait_for(self._get_page_info(), timeout=1.0)
self.logger.debug(f'🔍 DOMWatchdog.on_BrowserStateRequestEvent: Got page info from CDP: {page_info}')
except Exception as e:
self.logger.debug(
f'🔍 DOMWatchdog.on_BrowserStateRequestEvent: Failed to get page info from CDP: {e}, using fallback'
)
# Fallback to default viewport dimensions
viewport = self.browser_session.browser_profile.viewport or {'width': 1280, 'height': 720}
page_info = PageInfo(
viewport_width=viewport['width'],
viewport_height=viewport['height'],
page_width=viewport['width'],
page_height=viewport['height'],
scroll_x=0,
scroll_y=0,
pixels_above=0,
pixels_below=0,
pixels_left=0,
pixels_right=0,
)
# Check for PDF viewer
is_pdf_viewer = page_url.endswith('.pdf') or '/pdf/' in page_url
# Build and cache the browser state summary
if screenshot_b64:
self.logger.debug(
f'🔍 DOMWatchdog.on_BrowserStateRequestEvent: 📸 Creating BrowserStateSummary with screenshot, length: {len(screenshot_b64)}'
)
else:
self.logger.debug(
'🔍 DOMWatchdog.on_BrowserStateRequestEvent: 📸 Creating BrowserStateSummary WITHOUT screenshot'
)
browser_state = BrowserStateSummary(
dom_state=content,
url=page_url,
title=title,
tabs=tabs_info,
screenshot=screenshot_b64,
page_info=page_info,
pixels_above=0,
pixels_below=0,
browser_errors=[],
is_pdf_viewer=is_pdf_viewer,
recent_events=self._get_recent_events_str() if event.include_recent_events else None,
)
# Cache the state
self.browser_session._cached_browser_state_summary = browser_state
self.logger.debug('🔍 DOMWatchdog.on_BrowserStateRequestEvent: ✅ COMPLETED - Returning browser state')
return browser_state
except Exception as e:
self.logger.error(f'Failed to get browser state: {e}')
# Return minimal recovery state
return BrowserStateSummary(
dom_state=SerializedDOMState(_root=None, selector_map={}),
url=page_url if 'page_url' in locals() else '',
title='Error',
tabs=[],
screenshot=None,
page_info=PageInfo(
viewport_width=1280,
viewport_height=720,
page_width=1280,
page_height=720,
scroll_x=0,
scroll_y=0,
pixels_above=0,
pixels_below=0,
pixels_left=0,
pixels_right=0,
),
pixels_above=0,
pixels_below=0,
browser_errors=[str(e)],
is_pdf_viewer=False,
recent_events=None,
)
@time_execution_async('build_dom_tree_without_highlights')
@observe_debug(ignore_input=True, ignore_output=True, name='build_dom_tree_without_highlights')
async def _build_dom_tree_without_highlights(self, previous_state: SerializedDOMState | None = None) -> SerializedDOMState:
"""Build DOM tree without injecting JavaScript highlights (for parallel execution)."""
try:
self.logger.debug('🔍 DOMWatchdog._build_dom_tree_without_highlights: STARTING DOM tree build')
# Create or reuse DOM service
if self._dom_service is None:
self._dom_service = DomService(
browser_session=self.browser_session,
logger=self.logger,
cross_origin_iframes=self.browser_session.browser_profile.cross_origin_iframes,
paint_order_filtering=self.browser_session.browser_profile.paint_order_filtering,
max_iframes=self.browser_session.browser_profile.max_iframes,
max_iframe_depth=self.browser_session.browser_profile.max_iframe_depth,
)
# Get serialized DOM tree using the service
self.logger.debug('🔍 DOMWatchdog._build_dom_tree_without_highlights: Calling DomService.get_serialized_dom_tree...')
start = time.time()
self.current_dom_state, self.enhanced_dom_tree, timing_info = await self._dom_service.get_serialized_dom_tree(
previous_cached_state=previous_state,
)
end = time.time()
self.logger.debug(
'🔍 DOMWatchdog._build_dom_tree_without_highlights: ✅ DomService.get_serialized_dom_tree completed'
)
self.logger.debug(f'Time taken to get DOM tree: {end - start} seconds')
self.logger.debug(f'Timing breakdown: {timing_info}')
# Update selector map for other watchdogs
self.logger.debug('🔍 DOMWatchdog._build_dom_tree_without_highlights: Updating selector maps...')
self.selector_map = self.current_dom_state.selector_map
# Update BrowserSession's cached selector map
if self.browser_session:
self.browser_session.update_cached_selector_map(self.selector_map)
self.logger.debug(
f'🔍 DOMWatchdog._build_dom_tree_without_highlights: ✅ Selector maps updated, {len(self.selector_map)} elements'
)
# Skip JavaScript highlighting injection - Python highlighting will be applied later
self.logger.debug('🔍 DOMWatchdog._build_dom_tree_without_highlights: ✅ COMPLETED DOM tree build (no JS highlights)')
return self.current_dom_state
except Exception as e:
self.logger.error(f'Failed to build DOM tree without highlights: {e}')
self.event_bus.dispatch(
BrowserErrorEvent(
error_type='DOMBuildFailed',
message=str(e),
)
)
raise
@time_execution_async('capture_clean_screenshot')
@observe_debug(ignore_input=True, ignore_output=True, name='capture_clean_screenshot')
async def _capture_clean_screenshot(self) -> str:
"""Capture a clean screenshot without JavaScript highlights."""
try:
self.logger.debug('🔍 DOMWatchdog._capture_clean_screenshot: Capturing clean screenshot...')
# Ensure we have a focused CDP session
assert self.browser_session.agent_focus is not None, 'No current target ID'
await self.browser_session.get_or_create_cdp_session(target_id=self.browser_session.agent_focus.target_id, focus=True)
# Check if handler is registered
handlers = self.event_bus.handlers.get('ScreenshotEvent', [])
handler_names = [getattr(h, '__name__', str(h)) for h in handlers]
self.logger.debug(f'📸 ScreenshotEvent handlers registered: {len(handlers)} - {handler_names}')
screenshot_event = self.event_bus.dispatch(ScreenshotEvent(full_page=False))
self.logger.debug('📸 Dispatched ScreenshotEvent, waiting for event to complete...')
# Wait for the event itself to complete (this waits for all handlers)
await screenshot_event
# Get the single handler result
screenshot_b64 = await screenshot_event.event_result(raise_if_any=True, raise_if_none=True)
if screenshot_b64 is None:
raise RuntimeError('Screenshot handler returned None')
self.logger.debug('🔍 DOMWatchdog._capture_clean_screenshot: ✅ Clean screenshot captured successfully')
return str(screenshot_b64)
except TimeoutError:
self.logger.warning('📸 Clean screenshot timed out after 6 seconds - no handler registered or slow page?')
raise
except Exception as e:
self.logger.warning(f'📸 Clean screenshot failed: {type(e).__name__}: {e}')
raise
async def _wait_for_stable_network(self):
"""Wait for page stability - simplified for CDP-only branch."""
start_time = time.time()
# Apply minimum wait time first (let page settle)
min_wait = self.browser_session.browser_profile.minimum_wait_page_load_time
if min_wait > 0:
self.logger.debug(f'⏳ Minimum wait: {min_wait}s')
await asyncio.sleep(min_wait)
# Apply network idle wait time (for dynamic content like iframes)
network_idle_wait = self.browser_session.browser_profile.wait_for_network_idle_page_load_time
if network_idle_wait > 0:
self.logger.debug(f'⏳ Network idle wait: {network_idle_wait}s')
await asyncio.sleep(network_idle_wait)
elapsed = time.time() - start_time
self.logger.debug(f'✅ Page stability wait completed in {elapsed:.2f}s')
async def _get_page_info(self) -> 'PageInfo':
"""Get comprehensive page information using a single CDP call.
TODO: should we make this an event as well?
Returns:
PageInfo with all viewport, page dimensions, and scroll information
"""
from browser_use.browser.views import PageInfo
# Get CDP session for the current target
if not self.browser_session.agent_focus:
raise RuntimeError('No active CDP session - browser may not be connected yet')
cdp_session = await self.browser_session.get_or_create_cdp_session(
target_id=self.browser_session.agent_focus.target_id, focus=True
)
# Get layout metrics which includes all the information we need
metrics = await asyncio.wait_for(
cdp_session.cdp_client.send.Page.getLayoutMetrics(session_id=cdp_session.session_id), timeout=10.0
)
# Extract different viewport types
layout_viewport = metrics.get('layoutViewport', {})
visual_viewport = metrics.get('visualViewport', {})
css_visual_viewport = metrics.get('cssVisualViewport', {})
css_layout_viewport = metrics.get('cssLayoutViewport', {})
content_size = metrics.get('contentSize', {})
# Calculate device pixel ratio to convert between device pixels and CSS pixels
# This matches the approach in dom/service.py _get_viewport_ratio method
css_width = css_visual_viewport.get('clientWidth', css_layout_viewport.get('clientWidth', 1280.0))
device_width = visual_viewport.get('clientWidth', css_width)
device_pixel_ratio = device_width / css_width if css_width > 0 else 1.0
# For viewport dimensions, use CSS pixels (what JavaScript sees)
# Prioritize CSS layout viewport, then fall back to layout viewport
viewport_width = int(css_layout_viewport.get('clientWidth') or layout_viewport.get('clientWidth', 1280))
viewport_height = int(css_layout_viewport.get('clientHeight') or layout_viewport.get('clientHeight', 720))
# For total page dimensions, content size is typically in device pixels, so convert to CSS pixels
# by dividing by device pixel ratio
raw_page_width = content_size.get('width', viewport_width * device_pixel_ratio)
raw_page_height = content_size.get('height', viewport_height * device_pixel_ratio)
page_width = int(raw_page_width / device_pixel_ratio)
page_height = int(raw_page_height / device_pixel_ratio)
# For scroll position, use CSS visual viewport if available, otherwise CSS layout viewport
# These should already be in CSS pixels
scroll_x = int(css_visual_viewport.get('pageX') or css_layout_viewport.get('pageX', 0))
scroll_y = int(css_visual_viewport.get('pageY') or css_layout_viewport.get('pageY', 0))
# Calculate scroll information - pixels that are above/below/left/right of current viewport
pixels_above = scroll_y
pixels_below = max(0, page_height - viewport_height - scroll_y)
pixels_left = scroll_x
pixels_right = max(0, page_width - viewport_width - scroll_x)
page_info = PageInfo(
viewport_width=viewport_width,
viewport_height=viewport_height,
page_width=page_width,
page_height=page_height,
scroll_x=scroll_x,
scroll_y=scroll_y,
pixels_above=pixels_above,
pixels_below=pixels_below,
pixels_left=pixels_left,
pixels_right=pixels_right,
)
return page_info
# ========== Public Helper Methods ==========
async def get_element_by_index(self, index: int) -> EnhancedDOMTreeNode | None:
"""Get DOM element by index from cached selector map.
Builds DOM if not cached.
Returns:
EnhancedDOMTreeNode or None if index not found
"""
if not self.selector_map:
# Build DOM if not cached
await self._build_dom_tree_without_highlights()
return self.selector_map.get(index) if self.selector_map else None
def clear_cache(self) -> None:
"""Clear cached DOM state to force rebuild on next access."""
self.selector_map = None
self.current_dom_state = None
self.enhanced_dom_tree = None
# Keep the DOM service instance to reuse its CDP client connection
def is_file_input(self, element: EnhancedDOMTreeNode) -> bool:
"""Check if element is a file input."""
return element.node_name.upper() == 'INPUT' and element.attributes.get('type', '').lower() == 'file'
@staticmethod
def is_element_visible_according_to_all_parents(node: EnhancedDOMTreeNode, html_frames: list[EnhancedDOMTreeNode]) -> bool:
"""Check if the element is visible according to all its parent HTML frames.
Delegates to the DomService static method.
"""
return DomService.is_element_visible_according_to_all_parents(node, html_frames)
async def __aexit__(self, exc_type, exc_value, traceback):
"""Clean up DOM service on exit."""
if self._dom_service:
await self._dom_service.__aexit__(exc_type, exc_value, traceback)
self._dom_service = None
def __del__(self):
"""Clean up DOM service on deletion."""
super().__del__()
# DOM service will clean up its own CDP client
self._dom_service = None
@@ -0,0 +1,933 @@
"""Downloads watchdog for monitoring and handling file downloads."""
import asyncio
import json
import os
import tempfile
from pathlib import Path
from typing import TYPE_CHECKING, Any, ClassVar
from urllib.parse import urlparse
import anyio
from bubus import BaseEvent
from cdp_use.cdp.browser import DownloadProgressEvent, DownloadWillBeginEvent
from cdp_use.cdp.target import SessionID, TargetID
from pydantic import PrivateAttr
from browser_use.browser.events import (
BrowserLaunchEvent,
BrowserStateRequestEvent,
BrowserStoppedEvent,
FileDownloadedEvent,
NavigationCompleteEvent,
TabClosedEvent,
TabCreatedEvent,
)
from browser_use.browser.watchdog_base import BaseWatchdog
if TYPE_CHECKING:
pass
class DownloadsWatchdog(BaseWatchdog):
"""Monitors downloads and handles file download events."""
# Events this watchdog listens to (for documentation)
LISTENS_TO: ClassVar[list[type[BaseEvent[Any]]]] = [
BrowserLaunchEvent,
BrowserStateRequestEvent,
BrowserStoppedEvent,
TabCreatedEvent,
TabClosedEvent,
NavigationCompleteEvent,
]
# Events this watchdog emits
EMITS: ClassVar[list[type[BaseEvent[Any]]]] = [
FileDownloadedEvent,
]
# Private state
_sessions_with_listeners: set[str] = PrivateAttr(default_factory=set) # Track sessions that already have download listeners
_active_downloads: dict[str, Any] = PrivateAttr(default_factory=dict)
_pdf_viewer_cache: dict[str, bool] = PrivateAttr(default_factory=dict) # Cache PDF viewer status by target URL
_download_cdp_session_setup: bool = PrivateAttr(default=False) # Track if CDP session is set up
_download_cdp_session: Any = PrivateAttr(default=None) # Store CDP session reference
_cdp_event_tasks: set[asyncio.Task] = PrivateAttr(default_factory=set) # Track CDP event handler tasks
_cdp_downloads_info: dict[str, dict[str, Any]] = PrivateAttr(default_factory=dict) # Map guid -> info
_use_js_fetch_for_local: bool = PrivateAttr(default=False) # Guard JS fetch path for local regular downloads
async def on_BrowserLaunchEvent(self, event: BrowserLaunchEvent) -> None:
self.logger.debug(f'[DownloadsWatchdog] Received BrowserLaunchEvent, EventBus ID: {id(self.event_bus)}')
# Ensure downloads directory exists
downloads_path = self.browser_session.browser_profile.downloads_path
if downloads_path:
expanded_path = Path(downloads_path).expanduser().resolve()
expanded_path.mkdir(parents=True, exist_ok=True)
self.logger.debug(f'[DownloadsWatchdog] Ensured downloads directory exists: {expanded_path}')
async def on_TabCreatedEvent(self, event: TabCreatedEvent) -> None:
"""Monitor new tabs for downloads."""
# logger.info(f'[DownloadsWatchdog] TabCreatedEvent received for tab {event.target_id[-4:]}: {event.url}')
# Assert downloads path is configured (should always be set by BrowserProfile default)
assert self.browser_session.browser_profile.downloads_path is not None, 'Downloads path must be configured'
if event.target_id:
# logger.info(f'[DownloadsWatchdog] Found target for tab {event.target_id}, calling attach_to_target')
await self.attach_to_target(event.target_id)
else:
self.logger.warning(f'[DownloadsWatchdog] No target found for tab {event.target_id}')
async def on_TabClosedEvent(self, event: TabClosedEvent) -> None:
"""Stop monitoring closed tabs."""
pass # No cleanup needed, browser context handles target lifecycle
async def on_BrowserStateRequestEvent(self, event: BrowserStateRequestEvent) -> None:
"""Handle browser state request events."""
cdp_session = self.browser_session.agent_focus
if not cdp_session:
return
url = await self.browser_session.get_current_page_url()
if not url:
return
target_id = cdp_session.target_id
self.event_bus.dispatch(
NavigationCompleteEvent(
event_type='NavigationCompleteEvent',
url=url,
target_id=target_id,
event_parent_id=event.event_id,
)
)
async def on_BrowserStoppedEvent(self, event: BrowserStoppedEvent) -> None:
"""Clean up when browser stops."""
# Cancel all CDP event handler tasks
for task in list(self._cdp_event_tasks):
if not task.done():
task.cancel()
# Wait for all tasks to complete cancellation
if self._cdp_event_tasks:
await asyncio.gather(*self._cdp_event_tasks, return_exceptions=True)
self._cdp_event_tasks.clear()
# Clean up CDP session
# CDP sessions are now cached and managed by BrowserSession
self._download_cdp_session = None
self._download_cdp_session_setup = False
# Clear other state
self._sessions_with_listeners.clear()
self._active_downloads.clear()
self._pdf_viewer_cache.clear()
async def on_NavigationCompleteEvent(self, event: NavigationCompleteEvent) -> None:
"""Check for PDFs after navigation completes."""
self.logger.debug(f'[DownloadsWatchdog] NavigationCompleteEvent received for {event.url}, tab #{event.target_id[-4:]}')
# Clear PDF cache for the navigated URL since content may have changed
if event.url in self._pdf_viewer_cache:
del self._pdf_viewer_cache[event.url]
# Check if auto-download is enabled
auto_download_enabled = self._is_auto_download_enabled()
if not auto_download_enabled:
return
# Note: Using network-based PDF detection that doesn't require JavaScript
target_id = event.target_id
self.logger.debug(f'[DownloadsWatchdog] Got target_id={target_id} for tab #{event.target_id[-4:]}')
is_pdf = await self.check_for_pdf_viewer(target_id)
if is_pdf:
self.logger.debug(f'[DownloadsWatchdog] 📄 PDF detected at {event.url}, triggering auto-download...')
download_path = await self.trigger_pdf_download(target_id)
if not download_path:
self.logger.warning(f'[DownloadsWatchdog] ⚠️ PDF download failed for {event.url}')
def _is_auto_download_enabled(self) -> bool:
"""Check if auto-download PDFs is enabled in browser profile."""
return self.browser_session.browser_profile.auto_download_pdfs
async def attach_to_target(self, target_id: TargetID) -> None:
"""Set up download monitoring for a specific target."""
# Define CDP event handlers outside of try to avoid indentation/scope issues
async def download_will_begin_handler(event: DownloadWillBeginEvent, session_id: SessionID | None):
self.logger.debug(f'[DownloadsWatchdog] Download will begin: {event}')
# Cache info for later completion event handling (esp. remote browsers)
guid = event.get('guid', '')
try:
suggested_filename = event.get('suggestedFilename')
assert suggested_filename, 'CDP DownloadWillBegin missing suggestedFilename'
self._cdp_downloads_info[guid] = {
'url': event.get('url', ''),
'suggested_filename': suggested_filename,
'handled': False,
}
except (AssertionError, KeyError):
pass
# Create and track the task
task = asyncio.create_task(self._handle_cdp_download(event, target_id, session_id))
self._cdp_event_tasks.add(task)
# Remove from set when done
task.add_done_callback(lambda t: self._cdp_event_tasks.discard(t))
async def download_progress_handler(event: DownloadProgressEvent, session_id: SessionID | None):
# Check if download is complete
if event.get('state') == 'completed':
file_path = event.get('filePath')
guid = event.get('guid', '')
if self.browser_session.is_local:
if file_path:
self.logger.debug(f'[DownloadsWatchdog] Download completed: {file_path}')
# Track the download
self._track_download(file_path)
# Mark as handled to prevent fallback duplicate dispatch
try:
if guid in self._cdp_downloads_info:
self._cdp_downloads_info[guid]['handled'] = True
except (KeyError, AttributeError):
pass
else:
# No local file path provided, local polling in _handle_cdp_download will handle it
self.logger.debug(
'[DownloadsWatchdog] No filePath in progress event (local); polling will handle detection'
)
else:
# Remote browser: do not touch local filesystem. Fallback to downloadPath+suggestedFilename
info = self._cdp_downloads_info.get(guid, {})
try:
suggested_filename = info.get('suggested_filename') or (Path(file_path).name if file_path else 'download')
downloads_path = str(self.browser_session.browser_profile.downloads_path or '')
effective_path = file_path or str(Path(downloads_path) / suggested_filename)
file_name = Path(effective_path).name
file_ext = Path(file_name).suffix.lower().lstrip('.')
self.event_bus.dispatch(
FileDownloadedEvent(
url=info.get('url', ''),
path=str(effective_path),
file_name=file_name,
file_size=0,
file_type=file_ext if file_ext else None,
)
)
self.logger.debug(f'[DownloadsWatchdog] ✅ (remote) Download completed: {effective_path}')
finally:
if guid in self._cdp_downloads_info:
del self._cdp_downloads_info[guid]
try:
downloads_path_raw = self.browser_session.browser_profile.downloads_path
if not downloads_path_raw:
# logger.info(f'[DownloadsWatchdog] No downloads path configured, skipping target: {target_id}')
return # No downloads path configured
# Check if we already have a download listener on this session
# to prevent duplicate listeners from being added
# Note: Since download listeners are set up once per browser session, not per target,
# we just track if we've set up the browser-level listener
if self._download_cdp_session_setup:
self.logger.debug('[DownloadsWatchdog] Download listener already set up for browser session')
return
# logger.debug(f'[DownloadsWatchdog] Setting up CDP download listener for target: {target_id}')
# Use CDP session for download events but store reference in watchdog
if not self._download_cdp_session_setup:
# Set up CDP session for downloads (only once per browser session)
cdp_client = self.browser_session.cdp_client
# Set download behavior to allow downloads and enable events
downloads_path = self.browser_session.browser_profile.downloads_path
if not downloads_path:
self.logger.warning('[DownloadsWatchdog] No downloads path configured, skipping CDP download setup')
return
# Ensure path is properly expanded (~ -> absolute path)
expanded_downloads_path = Path(downloads_path).expanduser().resolve()
await cdp_client.send.Browser.setDownloadBehavior(
params={
'behavior': 'allow',
'downloadPath': str(expanded_downloads_path), # Use expanded absolute path
'eventsEnabled': True,
}
)
# Register the handlers with CDP
cdp_client.register.Browser.downloadWillBegin(download_will_begin_handler) # type: ignore[arg-type]
cdp_client.register.Browser.downloadProgress(download_progress_handler) # type: ignore[arg-type]
self._download_cdp_session_setup = True
self.logger.debug('[DownloadsWatchdog] Set up CDP download listeners')
# No need to track individual targets since download listener is browser-level
# logger.debug(f'[DownloadsWatchdog] Successfully set up CDP download listener for target: {target_id}')
except Exception as e:
self.logger.warning(f'[DownloadsWatchdog] Failed to set up CDP download listener for target {target_id}: {e}')
def _track_download(self, file_path: str) -> None:
"""Track a completed download and dispatch the appropriate event.
Args:
file_path: The path to the downloaded file
"""
try:
# Get file info
path = Path(file_path)
if path.exists():
file_size = path.stat().st_size
self.logger.debug(f'[DownloadsWatchdog] Tracked download: {path.name} ({file_size} bytes)')
# Dispatch download event
from browser_use.browser.events import FileDownloadedEvent
self.event_bus.dispatch(
FileDownloadedEvent(
url=str(path), # Use the file path as URL for local files
path=str(path),
file_name=path.name,
file_size=file_size,
)
)
else:
self.logger.warning(f'[DownloadsWatchdog] Downloaded file not found: {file_path}')
except Exception as e:
self.logger.error(f'[DownloadsWatchdog] Error tracking download: {e}')
async def _handle_cdp_download(
self, event: DownloadWillBeginEvent, target_id: TargetID, session_id: SessionID | None
) -> None:
"""Handle a CDP Page.downloadWillBegin event."""
downloads_dir = (
Path(
self.browser_session.browser_profile.downloads_path
or f'{tempfile.gettempdir()}/browser_use_downloads.{str(self.browser_session.id)[-4:]}'
)
.expanduser()
.resolve()
) # Ensure path is properly expanded
# Initialize variables that may be used outside try blocks
unique_filename = None
file_size = 0
expected_path = None
download_result = None
download_url = event.get('url', '')
suggested_filename = event.get('suggestedFilename', 'download')
guid = event.get('guid', '')
try:
self.logger.debug(f'[DownloadsWatchdog] ⬇️ File download starting: {suggested_filename} from {download_url[:100]}...')
self.logger.debug(f'[DownloadsWatchdog] Full CDP event: {event}')
# Since Browser.setDownloadBehavior is already configured, the browser will download the file
# We just need to wait for it to appear in the downloads directory
expected_path = downloads_dir / suggested_filename
# Debug: List current directory contents
self.logger.debug(f'[DownloadsWatchdog] Downloads directory: {downloads_dir}')
if downloads_dir.exists():
files_before = list(downloads_dir.iterdir())
self.logger.debug(f'[DownloadsWatchdog] Files before download: {[f.name for f in files_before]}')
# Try manual JavaScript fetch as a fallback for local browsers (disabled for regular local downloads)
if self.browser_session.is_local and self._use_js_fetch_for_local:
self.logger.debug(f'[DownloadsWatchdog] Attempting JS fetch fallback for {download_url}')
unique_filename = None
file_size = None
download_result = None
try:
# Escape the URL for JavaScript
import json
escaped_url = json.dumps(download_url)
# Get the proper session for the frame that initiated the download
cdp_session = await self.browser_session.cdp_client_for_frame(event.get('frameId'))
assert cdp_session
result = await cdp_session.cdp_client.send.Runtime.evaluate(
params={
'expression': f"""
(async () => {{
try {{
const response = await fetch({escaped_url});
if (!response.ok) {{
throw new Error(`HTTP error! status: ${{response.status}}`);
}}
const blob = await response.blob();
const arrayBuffer = await blob.arrayBuffer();
const uint8Array = new Uint8Array(arrayBuffer);
return {{
data: Array.from(uint8Array),
size: uint8Array.length,
contentType: response.headers.get('content-type') || 'application/octet-stream'
}};
}} catch (error) {{
throw new Error(`Fetch failed: ${{error.message}}`);
}}
}})()
""",
'awaitPromise': True,
'returnByValue': True,
},
session_id=cdp_session.session_id,
)
download_result = result.get('result', {}).get('value')
if download_result and download_result.get('data'):
# Save the file
file_data = bytes(download_result['data'])
file_size = len(file_data)
# Ensure unique filename
unique_filename = await self._get_unique_filename(str(downloads_dir), suggested_filename)
final_path = downloads_dir / unique_filename
# Write the file
import anyio
async with await anyio.open_file(final_path, 'wb') as f:
await f.write(file_data)
self.logger.debug(f'[DownloadsWatchdog] ✅ Downloaded and saved file: {final_path} ({file_size} bytes)')
expected_path = final_path
# Emit download event immediately
file_ext = expected_path.suffix.lower().lstrip('.')
file_type = file_ext if file_ext else None
self.event_bus.dispatch(
FileDownloadedEvent(
url=download_url,
path=str(expected_path),
file_name=unique_filename or expected_path.name,
file_size=file_size or 0,
file_type=file_type,
mime_type=(download_result.get('contentType') if download_result else None),
from_cache=False,
auto_download=False,
)
)
# Mark as handled to prevent duplicate dispatch from progress/polling paths
try:
if guid in self._cdp_downloads_info:
self._cdp_downloads_info[guid]['handled'] = True
except (KeyError, AttributeError):
pass
self.logger.debug(
f'[DownloadsWatchdog] ✅ File download completed via CDP: {suggested_filename} ({file_size} bytes) saved to {expected_path}'
)
return
else:
self.logger.error('[DownloadsWatchdog] ❌ No data received from fetch')
except Exception as fetch_error:
self.logger.error(f'[DownloadsWatchdog] ❌ Failed to download file via fetch: {fetch_error}')
# For remote browsers, don't poll local filesystem; downloadProgress handler will emit the event
if not self.browser_session.is_local:
return
except Exception as e:
self.logger.error(f'[DownloadsWatchdog] ❌ Error handling CDP download: {type(e).__name__} {e}')
# If we reach here, the fetch method failed, so wait for native download
# Poll the downloads directory for new files
self.logger.debug(f'[DownloadsWatchdog] Checking if browser auto-download saved the file for us: {suggested_filename}')
# Get initial list of files in downloads directory
initial_files = set()
if Path(downloads_dir).exists():
for f in Path(downloads_dir).iterdir():
if f.is_file() and not f.name.startswith('.'):
initial_files.add(f.name)
# Poll for new files
max_wait = 20 # seconds
start_time = asyncio.get_event_loop().time()
while asyncio.get_event_loop().time() - start_time < max_wait:
await asyncio.sleep(5.0) # Check every 5 seconds
if Path(downloads_dir).exists():
for file_path in Path(downloads_dir).iterdir():
# Skip hidden files and files that were already there
if file_path.is_file() and not file_path.name.startswith('.') and file_path.name not in initial_files:
# Check if file has content (> 4 bytes)
try:
file_size = file_path.stat().st_size
if file_size > 4:
# Found a new download!
self.logger.debug(
f'[DownloadsWatchdog] ✅ Found downloaded file: {file_path} ({file_size} bytes)'
)
# Determine file type from extension
file_ext = file_path.suffix.lower().lstrip('.')
file_type = file_ext if file_ext else None
# Dispatch download event
# Skip if already handled by progress/JS fetch
info = self._cdp_downloads_info.get(guid, {})
if info.get('handled'):
return
self.event_bus.dispatch(
FileDownloadedEvent(
url=download_url,
path=str(file_path),
file_name=file_path.name,
file_size=file_size,
file_type=file_type,
)
)
# Mark as handled after dispatch
try:
if guid in self._cdp_downloads_info:
self._cdp_downloads_info[guid]['handled'] = True
except (KeyError, AttributeError):
pass
return
except Exception as e:
self.logger.debug(f'[DownloadsWatchdog] Error checking file {file_path}: {e}')
self.logger.warning(f'[DownloadsWatchdog] Download did not complete within {max_wait} seconds')
async def _handle_download(self, download: Any) -> None:
"""Handle a download event."""
download_id = f'{id(download)}'
self._active_downloads[download_id] = download
self.logger.debug(f'[DownloadsWatchdog] ⬇️ Handling download: {download.suggested_filename} from {download.url[:100]}...')
# Debug: Check if download is already being handled elsewhere
failure = (
await download.failure()
) # TODO: it always fails for some reason, figure out why connect_over_cdp makes accept_downloads not work
self.logger.warning(f'[DownloadsWatchdog] ❌ Download state - canceled: {failure}, url: {download.url}')
# logger.info(f'[DownloadsWatchdog] Active downloads count: {len(self._active_downloads)}')
try:
current_step = 'getting_download_info'
# Get download info immediately
url = download.url
suggested_filename = download.suggested_filename
current_step = 'determining_download_directory'
# Determine download directory from browser profile
downloads_dir = self.browser_session.browser_profile.downloads_path
if not downloads_dir:
downloads_dir = str(Path.home() / 'Downloads')
else:
downloads_dir = str(downloads_dir) # Ensure it's a string
# Check if Playwright already auto-downloaded the file (due to CDP setup)
original_path = Path(downloads_dir) / suggested_filename
if original_path.exists() and original_path.stat().st_size > 0:
self.logger.debug(
f'[DownloadsWatchdog] File already downloaded by Playwright: {original_path} ({original_path.stat().st_size} bytes)'
)
# Use the existing file instead of creating a duplicate
download_path = original_path
file_size = original_path.stat().st_size
unique_filename = suggested_filename
else:
current_step = 'generating_unique_filename'
# Ensure unique filename
unique_filename = await self._get_unique_filename(downloads_dir, suggested_filename)
download_path = Path(downloads_dir) / unique_filename
self.logger.debug(f'[DownloadsWatchdog] Download started: {unique_filename} from {url[:100]}...')
current_step = 'calling_save_as'
# Save the download using Playwright's save_as method
self.logger.debug(f'[DownloadsWatchdog] Saving download to: {download_path}')
self.logger.debug(f'[DownloadsWatchdog] Download path exists: {download_path.parent.exists()}')
self.logger.debug(f'[DownloadsWatchdog] Download path writable: {os.access(download_path.parent, os.W_OK)}')
try:
self.logger.debug('[DownloadsWatchdog] About to call download.save_as()...')
await download.save_as(str(download_path))
self.logger.debug(f'[DownloadsWatchdog] Successfully saved download to: {download_path}')
current_step = 'save_as_completed'
except Exception as save_error:
self.logger.error(f'[DownloadsWatchdog] save_as() failed with error: {save_error}')
raise save_error
# Get file info
file_size = download_path.stat().st_size if download_path.exists() else 0
# Determine file type from extension
file_ext = download_path.suffix.lower().lstrip('.')
file_type = file_ext if file_ext else None
# Try to get MIME type from response headers if available
mime_type = None
# Note: Playwright doesn't expose response headers directly from Download object
# Check if this was a PDF auto-download
auto_download = False
if file_type == 'pdf':
auto_download = self._is_auto_download_enabled()
# Emit download event
self.event_bus.dispatch(
FileDownloadedEvent(
url=url,
path=str(download_path),
file_name=suggested_filename,
file_size=file_size,
file_type=file_type,
mime_type=mime_type,
from_cache=False,
auto_download=auto_download,
)
)
self.logger.debug(
f'[DownloadsWatchdog] ✅ Download completed: {suggested_filename} ({file_size} bytes) saved to {download_path}'
)
# File is now tracked on filesystem, no need to track in memory
except Exception as e:
self.logger.error(
f'[DownloadsWatchdog] Error handling download at step "{locals().get("current_step", "unknown")}", error: {e}'
)
self.logger.error(
f'[DownloadsWatchdog] Download state - URL: {download.url}, filename: {download.suggested_filename}'
)
finally:
# Clean up tracking
if download_id in self._active_downloads:
del self._active_downloads[download_id]
async def check_for_pdf_viewer(self, target_id: TargetID) -> bool:
"""Check if the current target is a PDF using network-based detection.
This method avoids JavaScript execution that can crash WebSocket connections.
Returns True if a PDF is detected and should be downloaded.
"""
self.logger.debug(f'[DownloadsWatchdog] Checking if target {target_id} is PDF viewer...')
# Get target info to get URL
cdp_client = self.browser_session.cdp_client
targets = await cdp_client.send.Target.getTargets()
target_info = next((t for t in targets['targetInfos'] if t['targetId'] == target_id), None)
if not target_info:
self.logger.warning(f'[DownloadsWatchdog] No target info found for {target_id}')
return False
page_url = target_info.get('url', '')
# Check cache first
if page_url in self._pdf_viewer_cache:
cached_result = self._pdf_viewer_cache[page_url]
self.logger.debug(f'[DownloadsWatchdog] Using cached PDF check result for {page_url}: {cached_result}')
return cached_result
try:
# Method 1: Check URL patterns (fastest, most reliable)
url_is_pdf = self._check_url_for_pdf(page_url)
if url_is_pdf:
self.logger.debug(f'[DownloadsWatchdog] PDF detected via URL pattern: {page_url}')
self._pdf_viewer_cache[page_url] = True
return True
# Method 2: Check network response headers via CDP (safer than JavaScript)
header_is_pdf = await self._check_network_headers_for_pdf(target_id)
if header_is_pdf:
self.logger.debug(f'[DownloadsWatchdog] PDF detected via network headers: {page_url}')
self._pdf_viewer_cache[page_url] = True
return True
# Method 3: Check Chrome's PDF viewer specific URLs
chrome_pdf_viewer = self._is_chrome_pdf_viewer_url(page_url)
if chrome_pdf_viewer:
self.logger.debug(f'[DownloadsWatchdog] Chrome PDF viewer detected: {page_url}')
self._pdf_viewer_cache[page_url] = True
return True
# Not a PDF
self._pdf_viewer_cache[page_url] = False
return False
except Exception as e:
self.logger.warning(f'[DownloadsWatchdog] ❌ Error checking for PDF viewer: {e}')
self._pdf_viewer_cache[page_url] = False
return False
def _check_url_for_pdf(self, url: str) -> bool:
"""Check if URL indicates a PDF file."""
if not url:
return False
url_lower = url.lower()
# Direct PDF file extensions
if url_lower.endswith('.pdf'):
return True
# PDF in path
if '.pdf' in url_lower:
return True
# PDF MIME type in URL parameters
if any(
param in url_lower
for param in [
'content-type=application/pdf',
'content-type=application%2fpdf',
'mimetype=application/pdf',
'type=application/pdf',
]
):
return True
return False
def _is_chrome_pdf_viewer_url(self, url: str) -> bool:
"""Check if this is Chrome's internal PDF viewer URL."""
if not url:
return False
url_lower = url.lower()
# Chrome PDF viewer uses chrome-extension:// URLs
if 'chrome-extension://' in url_lower and 'pdf' in url_lower:
return True
# Chrome PDF viewer internal URLs
if url_lower.startswith('chrome://') and 'pdf' in url_lower:
return True
return False
async def _check_network_headers_for_pdf(self, target_id: TargetID) -> bool:
"""Infer PDF via navigation history/URL; headers are not available post-navigation in this context."""
try:
import asyncio
# Get CDP session
temp_session = await self.browser_session.get_or_create_cdp_session(target_id, focus=False)
# Get navigation history to find the main resource
history = await asyncio.wait_for(
temp_session.cdp_client.send.Page.getNavigationHistory(session_id=temp_session.session_id), timeout=3.0
)
current_entry = history.get('entries', [])
if current_entry:
current_index = history.get('currentIndex', 0)
if 0 <= current_index < len(current_entry):
current_url = current_entry[current_index].get('url', '')
# Check if the URL itself suggests PDF
if self._check_url_for_pdf(current_url):
return True
# Note: CDP doesn't easily expose response headers for completed navigations
# For more complex cases, we'd need to set up Network.responseReceived listeners
# before navigation, but that's overkill for most PDF detection cases
return False
except Exception as e:
self.logger.debug(f'[DownloadsWatchdog] Network headers check failed (non-critical): {e}')
return False
async def trigger_pdf_download(self, target_id: TargetID) -> str | None:
"""Trigger download of a PDF from Chrome's PDF viewer.
Returns the download path if successful, None otherwise.
"""
self.logger.debug(f'[DownloadsWatchdog] trigger_pdf_download called for target_id={target_id}')
if not self.browser_session.browser_profile.downloads_path:
self.logger.warning('[DownloadsWatchdog] ❌ No downloads path configured, cannot save PDF download')
return None
downloads_path = self.browser_session.browser_profile.downloads_path
self.logger.debug(f'[DownloadsWatchdog] Downloads path: {downloads_path}')
try:
# Create a temporary CDP session for this target without switching focus
import asyncio
self.logger.debug(f'[DownloadsWatchdog] Creating CDP session for PDF download from target {target_id}')
temp_session = await self.browser_session.get_or_create_cdp_session(target_id, focus=False)
# Try to get the PDF URL with timeout
result = await asyncio.wait_for(
temp_session.cdp_client.send.Runtime.evaluate(
params={
'expression': """
(() => {
// For Chrome's PDF viewer, the actual URL is in window.location.href
// The embed element's src is often "about:blank"
const embedElement = document.querySelector('embed[type="application/x-google-chrome-pdf"]') ||
document.querySelector('embed[type="application/pdf"]');
if (embedElement) {
// Chrome PDF viewer detected - use the page URL
return { url: window.location.href };
}
// Fallback to window.location.href anyway
return { url: window.location.href };
})()
""",
'returnByValue': True,
},
session_id=temp_session.session_id,
),
timeout=5.0, # 5 second timeout to prevent hanging
)
pdf_info = result.get('result', {}).get('value', {})
pdf_url = pdf_info.get('url', '')
if not pdf_url:
self.logger.warning(f'[DownloadsWatchdog] ❌ Could not determine PDF URL for download {pdf_info}')
return None
# Generate filename from URL
pdf_filename = os.path.basename(pdf_url.split('?')[0]) # Remove query params
if not pdf_filename or not pdf_filename.endswith('.pdf'):
parsed = urlparse(pdf_url)
pdf_filename = os.path.basename(parsed.path) or 'document.pdf'
if not pdf_filename.endswith('.pdf'):
pdf_filename += '.pdf'
self.logger.debug(f'[DownloadsWatchdog] Generated filename: {pdf_filename}')
# Check if already downloaded by looking in the downloads directory
downloads_dir = str(self.browser_session.browser_profile.downloads_path)
if os.path.exists(downloads_dir):
existing_files = os.listdir(downloads_dir)
if pdf_filename in existing_files:
self.logger.debug(f'[DownloadsWatchdog] PDF already downloaded: {pdf_filename}')
return None
self.logger.debug(f'[DownloadsWatchdog] Starting PDF download from: {pdf_url[:100]}...')
# Download using JavaScript fetch to leverage browser cache
try:
# Properly escape the URL to prevent JavaScript injection
escaped_pdf_url = json.dumps(pdf_url)
result = await asyncio.wait_for(
temp_session.cdp_client.send.Runtime.evaluate(
params={
'expression': f"""
(async () => {{
try {{
// Use fetch with cache: 'force-cache' to prioritize cached version
const response = await fetch({escaped_pdf_url}, {{
cache: 'force-cache'
}});
if (!response.ok) {{
throw new Error(`HTTP error! status: ${{response.status}}`);
}}
const blob = await response.blob();
const arrayBuffer = await blob.arrayBuffer();
const uint8Array = new Uint8Array(arrayBuffer);
// Check if served from cache
const fromCache = response.headers.has('age') ||
!response.headers.has('date');
return {{
data: Array.from(uint8Array),
fromCache: fromCache,
responseSize: uint8Array.length,
transferSize: response.headers.get('content-length') || 'unknown'
}};
}} catch (error) {{
throw new Error(`Fetch failed: ${{error.message}}`);
}}
}})()
""",
'awaitPromise': True,
'returnByValue': True,
},
session_id=temp_session.session_id,
),
timeout=10.0, # 10 second timeout for download operation
)
download_result = result.get('result', {}).get('value', {})
if download_result and download_result.get('data') and len(download_result['data']) > 0:
# Ensure unique filename
downloads_dir = str(self.browser_session.browser_profile.downloads_path)
# Ensure downloads directory exists
os.makedirs(downloads_dir, exist_ok=True)
unique_filename = await self._get_unique_filename(downloads_dir, pdf_filename)
download_path = os.path.join(downloads_dir, unique_filename)
# Save the PDF asynchronously
async with await anyio.open_file(download_path, 'wb') as f:
await f.write(bytes(download_result['data']))
# Verify file was written successfully
if os.path.exists(download_path):
actual_size = os.path.getsize(download_path)
self.logger.debug(
f'[DownloadsWatchdog] PDF file written successfully: {download_path} ({actual_size} bytes)'
)
else:
self.logger.error(f'[DownloadsWatchdog] ❌ Failed to write PDF file to: {download_path}')
return None
# Log cache information
cache_status = 'from cache' if download_result.get('fromCache') else 'from network'
response_size = download_result.get('responseSize', 0)
self.logger.debug(
f'[DownloadsWatchdog] ✅ Auto-downloaded PDF ({cache_status}, {response_size:,} bytes): {download_path}'
)
# Emit file downloaded event
self.logger.debug(f'[DownloadsWatchdog] Dispatching FileDownloadedEvent for {unique_filename}')
self.event_bus.dispatch(
FileDownloadedEvent(
url=pdf_url,
path=download_path,
file_name=unique_filename,
file_size=response_size,
file_type='pdf',
mime_type='application/pdf',
from_cache=download_result.get('fromCache', False),
auto_download=True,
)
)
# No need to detach - session is cached
return download_path
else:
self.logger.warning(f'[DownloadsWatchdog] No data received when downloading PDF from {pdf_url}')
return None
except Exception as e:
self.logger.warning(f'[DownloadsWatchdog] Failed to auto-download PDF from {pdf_url}: {type(e).__name__}: {e}')
return None
except TimeoutError:
self.logger.debug('[DownloadsWatchdog] PDF download operation timed out')
return None
except Exception as e:
self.logger.error(f'[DownloadsWatchdog] Error in PDF download: {type(e).__name__}: {e}')
return None
@staticmethod
async def _get_unique_filename(directory: str, filename: str) -> str:
"""Generate a unique filename for downloads by appending (1), (2), etc., if a file already exists."""
base, ext = os.path.splitext(filename)
counter = 1
new_filename = filename
while os.path.exists(os.path.join(directory, new_filename)):
new_filename = f'{base} ({counter}){ext}'
counter += 1
return new_filename
# Fix Pydantic circular dependency - this will be called from session.py after BrowserSession is defined
@@ -0,0 +1,456 @@
"""Local browser watchdog for managing browser subprocess lifecycle."""
import asyncio
import os
import shutil
import tempfile
from pathlib import Path
from typing import TYPE_CHECKING, Any, ClassVar
import psutil
from bubus import BaseEvent
from pydantic import PrivateAttr
from browser_use.browser.events import (
BrowserKillEvent,
BrowserLaunchEvent,
BrowserLaunchResult,
BrowserStopEvent,
)
from browser_use.browser.watchdog_base import BaseWatchdog
from browser_use.observability import observe_debug
if TYPE_CHECKING:
pass
class LocalBrowserWatchdog(BaseWatchdog):
"""Manages local browser subprocess lifecycle."""
# Events this watchdog listens to
LISTENS_TO: ClassVar[list[type[BaseEvent[Any]]]] = [
BrowserLaunchEvent,
BrowserKillEvent,
BrowserStopEvent,
]
# Events this watchdog emits
EMITS: ClassVar[list[type[BaseEvent[Any]]]] = []
# Private state for subprocess management
_subprocess: psutil.Process | None = PrivateAttr(default=None)
_owns_browser_resources: bool = PrivateAttr(default=True)
_temp_dirs_to_cleanup: list[Path] = PrivateAttr(default_factory=list)
_original_user_data_dir: str | None = PrivateAttr(default=None)
@observe_debug(ignore_input=True, ignore_output=True, name='browser_launch_event')
async def on_BrowserLaunchEvent(self, event: BrowserLaunchEvent) -> BrowserLaunchResult:
"""Launch a local browser process."""
try:
self.logger.debug('[LocalBrowserWatchdog] Received BrowserLaunchEvent, launching local browser...')
# self.logger.debug('[LocalBrowserWatchdog] Calling _launch_browser...')
process, cdp_url = await self._launch_browser()
self._subprocess = process
# self.logger.debug(f'[LocalBrowserWatchdog] _launch_browser returned: process={process}, cdp_url={cdp_url}')
return BrowserLaunchResult(cdp_url=cdp_url)
except Exception as e:
self.logger.error(f'[LocalBrowserWatchdog] Exception in on_BrowserLaunchEvent: {e}', exc_info=True)
raise
async def on_BrowserKillEvent(self, event: BrowserKillEvent) -> None:
"""Kill the local browser subprocess."""
self.logger.debug('[LocalBrowserWatchdog] Killing local browser process')
if self._subprocess:
await self._cleanup_process(self._subprocess)
self._subprocess = None
# Clean up temp directories if any were created
for temp_dir in self._temp_dirs_to_cleanup:
self._cleanup_temp_dir(temp_dir)
self._temp_dirs_to_cleanup.clear()
# Restore original user_data_dir if it was modified
if self._original_user_data_dir is not None:
self.browser_session.browser_profile.user_data_dir = self._original_user_data_dir
self._original_user_data_dir = None
self.logger.debug('[LocalBrowserWatchdog] Browser cleanup completed')
async def on_BrowserStopEvent(self, event: BrowserStopEvent) -> None:
"""Listen for BrowserStopEvent and dispatch BrowserKillEvent without awaiting it."""
if self.browser_session.is_local and self._subprocess:
self.logger.debug('[LocalBrowserWatchdog] BrowserStopEvent received, dispatching BrowserKillEvent')
# Dispatch BrowserKillEvent without awaiting so it gets processed after all BrowserStopEvent handlers
self.event_bus.dispatch(BrowserKillEvent())
@observe_debug(ignore_input=True, ignore_output=True, name='launch_browser_process')
async def _launch_browser(self, max_retries: int = 3) -> tuple[psutil.Process, str]:
"""Launch browser process and return (process, cdp_url).
Handles launch errors by falling back to temporary directories if needed.
Returns:
Tuple of (psutil.Process, cdp_url)
"""
# Keep track of original user_data_dir to restore if needed
profile = self.browser_session.browser_profile
self._original_user_data_dir = str(profile.user_data_dir) if profile.user_data_dir else None
self._temp_dirs_to_cleanup = []
for attempt in range(max_retries):
try:
# Get launch args from profile
launch_args = profile.get_args()
# Add debugging port
debug_port = self._find_free_port()
launch_args.extend(
[
f'--remote-debugging-port={debug_port}',
]
)
assert '--user-data-dir' in str(launch_args), (
'User data dir must be set somewhere in launch args to a non-default path, otherwise Chrome will not let us attach via CDP'
)
# Get browser executable
# Priority: custom executable > fallback paths > playwright subprocess
if profile.executable_path:
browser_path = profile.executable_path
self.logger.debug(f'[LocalBrowserWatchdog] 📦 Using custom local browser executable_path= {browser_path}')
else:
# self.logger.debug('[LocalBrowserWatchdog] 🔍 Looking for local browser binary path...')
# Try fallback paths first (system browsers preferred)
browser_path = self._find_installed_browser_path()
if not browser_path:
self.logger.error(
'[LocalBrowserWatchdog] ⚠️ No local browser binary found, installing browser using playwright subprocess...'
)
browser_path = await self._install_browser_with_playwright()
self.logger.debug(f'[LocalBrowserWatchdog] 📦 Found local browser installed at executable_path= {browser_path}')
if not browser_path:
raise RuntimeError('No local Chrome/Chromium install found, and failed to install with playwright')
# Launch browser subprocess directly
self.logger.debug(f'[LocalBrowserWatchdog] 🚀 Launching browser subprocess with {len(launch_args)} args...')
subprocess = await asyncio.create_subprocess_exec(
browser_path,
*launch_args,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
self.logger.debug(
f'[LocalBrowserWatchdog] 🎭 Browser running with browser_pid= {subprocess.pid} 🔗 listening on CDP port :{debug_port}'
)
# Convert to psutil.Process
process = psutil.Process(subprocess.pid)
# Wait for CDP to be ready and get the URL
cdp_url = await self._wait_for_cdp_url(debug_port)
# Success! Clean up any temp dirs we created but didn't use
for tmp_dir in self._temp_dirs_to_cleanup:
try:
shutil.rmtree(tmp_dir, ignore_errors=True)
except Exception:
pass
return process, cdp_url
except Exception as e:
error_str = str(e).lower()
# Check if this is a user_data_dir related error
if any(err in error_str for err in ['singletonlock', 'user data directory', 'cannot create', 'already in use']):
self.logger.warning(f'Browser launch failed (attempt {attempt + 1}/{max_retries}): {e}')
if attempt < max_retries - 1:
# Create a temporary directory for next attempt
tmp_dir = Path(tempfile.mkdtemp(prefix='browseruse-tmp-'))
self._temp_dirs_to_cleanup.append(tmp_dir)
# Update profile to use temp directory
profile.user_data_dir = str(tmp_dir)
self.logger.debug(f'Retrying with temporary user_data_dir: {tmp_dir}')
# Small delay before retry
await asyncio.sleep(0.5)
continue
# Not a recoverable error or last attempt failed
# Restore original user_data_dir before raising
if self._original_user_data_dir is not None:
profile.user_data_dir = self._original_user_data_dir
# Clean up any temp dirs we created
for tmp_dir in self._temp_dirs_to_cleanup:
try:
shutil.rmtree(tmp_dir, ignore_errors=True)
except Exception:
pass
raise
# Should not reach here, but just in case
if self._original_user_data_dir is not None:
profile.user_data_dir = self._original_user_data_dir
raise RuntimeError(f'Failed to launch browser after {max_retries} attempts')
@staticmethod
def _find_installed_browser_path() -> str | None:
"""Try to find browser executable from common fallback locations.
Prioritizes:
1. System Chrome Stable
1. Playwright chromium
2. Other system native browsers (Chromium -> Chrome Canary/Dev -> Brave)
3. Playwright headless-shell fallback
Returns:
Path to browser executable or None if not found
"""
import glob
import platform
from pathlib import Path
system = platform.system()
patterns = []
# Get playwright browsers path from environment variable if set
playwright_path = os.environ.get('PLAYWRIGHT_BROWSERS_PATH')
if system == 'Darwin': # macOS
if not playwright_path:
playwright_path = '~/Library/Caches/ms-playwright'
patterns = [
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
f'{playwright_path}/chromium-*/chrome-mac/Chromium.app/Contents/MacOS/Chromium',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
'/Applications/Brave Browser.app/Contents/MacOS/Brave Browser',
f'{playwright_path}/chromium_headless_shell-*/chrome-mac/Chromium.app/Contents/MacOS/Chromium',
]
elif system == 'Linux':
if not playwright_path:
playwright_path = '~/.cache/ms-playwright'
patterns = [
'/usr/bin/google-chrome-stable',
'/usr/bin/google-chrome',
'/usr/local/bin/google-chrome',
f'{playwright_path}/chromium-*/chrome-linux/chrome',
'/usr/bin/chromium',
'/usr/bin/chromium-browser',
'/usr/local/bin/chromium',
'/snap/bin/chromium',
'/usr/bin/google-chrome-beta',
'/usr/bin/google-chrome-dev',
'/usr/bin/brave-browser',
f'{playwright_path}/chromium_headless_shell-*/chrome-linux/chrome',
]
elif system == 'Windows':
if not playwright_path:
playwright_path = r'%LOCALAPPDATA%\ms-playwright'
patterns = [
r'C:\Program Files\Google\Chrome\Application\chrome.exe',
r'C:\Program Files (x86)\Google\Chrome\Application\chrome.exe',
r'%LOCALAPPDATA%\Google\Chrome\Application\chrome.exe',
r'%PROGRAMFILES%\Google\Chrome\Application\chrome.exe',
r'%PROGRAMFILES(X86)%\Google\Chrome\Application\chrome.exe',
f'{playwright_path}\\chromium-*\\chrome-win\\chrome.exe',
r'C:\Program Files\Chromium\Application\chrome.exe',
r'C:\Program Files (x86)\Chromium\Application\chrome.exe',
r'%LOCALAPPDATA%\Chromium\Application\chrome.exe',
r'C:\Program Files\BraveSoftware\Brave-Browser\Application\brave.exe',
r'C:\Program Files (x86)\BraveSoftware\Brave-Browser\Application\brave.exe',
r'C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe',
r'C:\Program Files\Microsoft\Edge\Application\msedge.exe',
r'%LOCALAPPDATA%\Microsoft\Edge\Application\msedge.exe',
f'{playwright_path}\\chromium_headless_shell-*\\chrome-win\\chrome.exe',
]
for pattern in patterns:
# Expand user home directory
expanded_pattern = Path(pattern).expanduser()
# Handle Windows environment variables
if system == 'Windows':
pattern_str = str(expanded_pattern)
for env_var in ['%LOCALAPPDATA%', '%PROGRAMFILES%', '%PROGRAMFILES(X86)%']:
if env_var in pattern_str:
env_key = env_var.strip('%').replace('(X86)', ' (x86)')
env_value = os.environ.get(env_key, '')
if env_value:
pattern_str = pattern_str.replace(env_var, env_value)
expanded_pattern = Path(pattern_str)
# Convert to string for glob
pattern_str = str(expanded_pattern)
# Check if pattern contains wildcards
if '*' in pattern_str:
# Use glob to expand the pattern
matches = glob.glob(pattern_str)
if matches:
# Sort matches and take the last one (alphanumerically highest version)
matches.sort()
browser_path = matches[-1]
if Path(browser_path).exists() and Path(browser_path).is_file():
return browser_path
else:
# Direct path check
if expanded_pattern.exists() and expanded_pattern.is_file():
return str(expanded_pattern)
return None
async def _install_browser_with_playwright(self) -> str:
"""Get browser executable path from playwright in a subprocess to avoid thread issues."""
# Run in subprocess with timeout
process = await asyncio.create_subprocess_exec(
'uvx',
'playwright',
'install',
'chrome',
'--with-deps',
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
try:
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=60.0)
self.logger.debug(f'[LocalBrowserWatchdog] 📦 Playwright install output: {stdout}')
browser_path = self._find_installed_browser_path()
if browser_path:
return browser_path
self.logger.error(f'[LocalBrowserWatchdog] ❌ Playwright local browser installation error: \n{stdout}\n{stderr}')
raise RuntimeError('No local browser path found after: uvx playwright install chrome --with-deps')
except TimeoutError:
# Kill the subprocess if it times out
process.kill()
await process.wait()
raise RuntimeError('Timeout getting browser path from playwright')
except Exception as e:
# Make sure subprocess is terminated
if process.returncode is None:
process.kill()
await process.wait()
raise RuntimeError(f'Error getting browser path: {e}')
@staticmethod
def _find_free_port() -> int:
"""Find a free port for the debugging interface."""
import socket
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(('127.0.0.1', 0))
s.listen(1)
port = s.getsockname()[1]
return port
@staticmethod
async def _wait_for_cdp_url(port: int, timeout: float = 30) -> str:
"""Wait for the browser to start and return the CDP URL."""
import aiohttp
start_time = asyncio.get_event_loop().time()
while asyncio.get_event_loop().time() - start_time < timeout:
try:
async with aiohttp.ClientSession() as session:
async with session.get(f'http://localhost:{port}/json/version') as resp:
if resp.status == 200:
# Chrome is ready
return f'http://localhost:{port}/'
else:
# Chrome is starting up and returning 502/500 errors
await asyncio.sleep(0.1)
except Exception:
# Connection error - Chrome might not be ready yet
await asyncio.sleep(0.1)
raise TimeoutError(f'Browser did not start within {timeout} seconds')
@staticmethod
async def _cleanup_process(process: psutil.Process) -> None:
"""Clean up browser process.
Args:
process: psutil.Process to terminate
"""
if not process:
return
try:
# Try graceful shutdown first
process.terminate()
# Use async wait instead of blocking wait
for _ in range(50): # Wait up to 5 seconds (50 * 0.1)
if not process.is_running():
return
await asyncio.sleep(0.1)
# If still running after 5 seconds, force kill
if process.is_running():
process.kill()
# Give it a moment to die
await asyncio.sleep(0.1)
except psutil.NoSuchProcess:
# Process already gone
pass
except Exception:
# Ignore any other errors during cleanup
pass
def _cleanup_temp_dir(self, temp_dir: Path | str) -> None:
"""Clean up temporary directory.
Args:
temp_dir: Path to temporary directory to remove
"""
if not temp_dir:
return
try:
temp_path = Path(temp_dir)
# Only remove if it's actually a temp directory we created
if 'browseruse-tmp-' in str(temp_path):
shutil.rmtree(temp_path, ignore_errors=True)
except Exception as e:
self.logger.debug(f'Failed to cleanup temp dir {temp_dir}: {e}')
@property
def browser_pid(self) -> int | None:
"""Get the browser process ID."""
if self._subprocess:
return self._subprocess.pid
return None
@staticmethod
async def get_browser_pid_via_cdp(browser) -> int | None:
"""Get the browser process ID via CDP SystemInfo.getProcessInfo.
Args:
browser: Playwright Browser instance
Returns:
Process ID or None if failed
"""
try:
cdp_session = await browser.new_browser_cdp_session()
result = await cdp_session.send('SystemInfo.getProcessInfo')
process_info = result.get('processInfo', {})
pid = process_info.get('id')
await cdp_session.detach()
return pid
except Exception:
# If we can't get PID via CDP, it's not critical
return None
@@ -0,0 +1,43 @@
"""Permissions watchdog for granting browser permissions on connection."""
from typing import TYPE_CHECKING, ClassVar
from bubus import BaseEvent
from browser_use.browser.events import BrowserConnectedEvent
from browser_use.browser.watchdog_base import BaseWatchdog
if TYPE_CHECKING:
pass
class PermissionsWatchdog(BaseWatchdog):
"""Grants browser permissions when browser connects."""
# Event contracts
LISTENS_TO: ClassVar[list[type[BaseEvent]]] = [
BrowserConnectedEvent,
]
EMITS: ClassVar[list[type[BaseEvent]]] = []
async def on_BrowserConnectedEvent(self, event: BrowserConnectedEvent) -> None:
"""Grant permissions when browser connects."""
permissions = self.browser_session.browser_profile.permissions
if not permissions:
self.logger.debug('No permissions to grant')
return
self.logger.debug(f'🔓 Granting browser permissions: {permissions}')
try:
# Grant permissions using CDP Browser.grantPermissions
# origin=None means grant to all origins
# Browser domain commands don't use session_id
await self.browser_session.cdp_client.send.Browser.grantPermissions(
params={'permissions': permissions} # type: ignore
)
self.logger.debug(f'✅ Successfully granted permissions: {permissions}')
except Exception as e:
self.logger.error(f'❌ Failed to grant permissions: {str(e)}')
# Don't raise - permissions are not critical to browser operation
@@ -0,0 +1,112 @@
"""Watchdog for handling JavaScript dialogs (alert, confirm, prompt) automatically."""
import asyncio
from typing import ClassVar
from bubus import BaseEvent
from pydantic import PrivateAttr
from browser_use.browser.events import TabCreatedEvent
from browser_use.browser.watchdog_base import BaseWatchdog
class PopupsWatchdog(BaseWatchdog):
"""Handles JavaScript dialogs (alert, confirm, prompt) by automatically accepting them immediately."""
# Events this watchdog listens to and emits
LISTENS_TO: ClassVar[list[type[BaseEvent]]] = [TabCreatedEvent]
EMITS: ClassVar[list[type[BaseEvent]]] = []
# Track which targets have dialog handlers registered
_dialog_listeners_registered: set[str] = PrivateAttr(default_factory=set)
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.logger.debug(f'🚀 PopupsWatchdog initialized with browser_session={self.browser_session}, ID={id(self)}')
async def on_TabCreatedEvent(self, event: TabCreatedEvent) -> None:
"""Set up JavaScript dialog handling when a new tab is created."""
target_id = event.target_id
self.logger.debug(f'🎯 PopupsWatchdog received TabCreatedEvent for target {target_id}')
# Skip if we've already registered for this target
if target_id in self._dialog_listeners_registered:
self.logger.debug(f'Already registered dialog handlers for target {target_id}')
return
self.logger.debug(f'📌 Starting dialog handler setup for target {target_id}')
try:
# Get all CDP sessions for this target and any child frames
cdp_session = await self.browser_session.get_or_create_cdp_session(
target_id, focus=False
) # don't auto-focus new tabs! sometimes we need to open tabs in background
# Also register for the root CDP client to catch dialogs from any frame
if self.browser_session._cdp_client_root:
self.logger.debug('📌 Also registering handler on root CDP client')
# Set up async handler for JavaScript dialogs - accept immediately without event dispatch
async def handle_dialog(event_data, session_id: str | None = None):
"""Handle JavaScript dialog events - accept immediately."""
try:
dialog_type = event_data.get('type', 'alert')
message = event_data.get('message', '')
self.logger.info(f"🔔 JavaScript {dialog_type} dialog: '{message[:100]}' - attempting to accept...")
self.logger.debug('Trying all approaches to accept dialog...')
# Approach 1: Use the session that detected the dialog
if self.browser_session._cdp_client_root and session_id:
try:
self.logger.debug(f'🔄 Approach 1: Using session {session_id}')
await asyncio.wait_for(
self.browser_session._cdp_client_root.send.Page.handleJavaScriptDialog(
params={'accept': True},
session_id=session_id,
),
timeout=0.25,
)
except (TimeoutError, Exception) as e:
pass
# Approach 2: Try with current agent focus session
if self.browser_session._cdp_client_root and self.browser_session.agent_focus:
try:
self.logger.debug(
f'🔄 Approach 2: Using agent focus session {self.browser_session.agent_focus.session_id}'
)
await asyncio.wait_for(
self.browser_session._cdp_client_root.send.Page.handleJavaScriptDialog(
params={'accept': True},
session_id=self.browser_session.agent_focus.session_id,
),
timeout=0.25,
)
except (TimeoutError, Exception) as e:
pass
except Exception as e:
self.logger.error(f'❌ Critical error in dialog handler: {type(e).__name__}: {e}')
# Register handler on the specific session
cdp_session.cdp_client.register.Page.javascriptDialogOpening(handle_dialog) # type: ignore[arg-type]
self.logger.debug(
f'Successfully registered Page.javascriptDialogOpening handler for session {cdp_session.session_id}'
)
# Also register on root CDP client to catch dialogs from any frame
if hasattr(self.browser_session._cdp_client_root, 'register'):
try:
self.browser_session._cdp_client_root.register.Page.javascriptDialogOpening(handle_dialog) # type: ignore[arg-type]
self.logger.debug('Successfully registered dialog handler on root CDP client for all frames')
except Exception as root_error:
self.logger.warning(f'Failed to register on root CDP client: {root_error}')
# Mark this target as having dialog handling set up
self._dialog_listeners_registered.add(target_id)
self.logger.debug(f'Set up JavaScript dialog handling for tab {target_id}')
except Exception as e:
self.logger.warning(f'Failed to set up popup handling for tab {target_id}: {e}')
@@ -0,0 +1,126 @@
"""Recording Watchdog for Browser Use Sessions."""
import asyncio
from pathlib import Path
from typing import ClassVar
from bubus import BaseEvent
from cdp_use.cdp.page.events import ScreencastFrameEvent
from uuid_extensions import uuid7str
from browser_use.browser.events import BrowserConnectedEvent, BrowserStopEvent
from browser_use.browser.profile import ViewportSize
from browser_use.browser.video_recorder import VideoRecorderService
from browser_use.browser.watchdog_base import BaseWatchdog
class RecordingWatchdog(BaseWatchdog):
"""
Manages video recording of a browser session using CDP screencasting.
"""
LISTENS_TO: ClassVar[list[type[BaseEvent]]] = [BrowserConnectedEvent, BrowserStopEvent]
EMITS: ClassVar[list[type[BaseEvent]]] = []
_recorder: VideoRecorderService | None = None
async def on_BrowserConnectedEvent(self, event: BrowserConnectedEvent) -> None:
"""
Starts video recording if it is configured in the browser profile.
"""
profile = self.browser_session.browser_profile
if not profile.record_video_dir:
return
# Dynamically determine video size
size = profile.record_video_size
if not size:
self.logger.debug('record_video_size not specified, detecting viewport size...')
size = await self._get_current_viewport_size()
if not size:
self.logger.warning('Cannot start video recording: viewport size could not be determined.')
return
video_format = getattr(profile, 'record_video_format', 'mp4').strip('.')
output_path = Path(profile.record_video_dir) / f'{uuid7str()}.{video_format}'
self.logger.debug(f'Initializing video recorder for format: {video_format}')
self._recorder = VideoRecorderService(output_path=output_path, size=size, framerate=profile.record_video_framerate)
self._recorder.start()
if not self._recorder._is_active:
self._recorder = None
return
self.browser_session.cdp_client.register.Page.screencastFrame(self.on_screencastFrame)
try:
cdp_session = await self.browser_session.get_or_create_cdp_session()
await cdp_session.cdp_client.send.Page.startScreencast(
params={
'format': 'png',
'quality': 90,
'maxWidth': size['width'],
'maxHeight': size['height'],
'everyNthFrame': 1,
},
session_id=cdp_session.session_id,
)
self.logger.info(f'📹 Started video recording to {output_path}')
except Exception as e:
self.logger.error(f'Failed to start screencast via CDP: {e}')
if self._recorder:
self._recorder.stop_and_save()
self._recorder = None
async def _get_current_viewport_size(self) -> ViewportSize | None:
"""Gets the current viewport size directly from the browser via CDP."""
try:
cdp_session = await self.browser_session.get_or_create_cdp_session()
metrics = await cdp_session.cdp_client.send.Page.getLayoutMetrics(session_id=cdp_session.session_id)
# Use cssVisualViewport for the most accurate representation of the visible area
viewport = metrics.get('cssVisualViewport', {})
width = viewport.get('clientWidth')
height = viewport.get('clientHeight')
if width and height:
self.logger.debug(f'Detected viewport size: {width}x{height}')
return ViewportSize(width=int(width), height=int(height))
except Exception as e:
self.logger.warning(f'Failed to get viewport size from browser: {e}')
return None
def on_screencastFrame(self, event: ScreencastFrameEvent, session_id: str | None) -> None:
"""
Synchronous handler for incoming screencast frames.
"""
if not self._recorder:
return
self._recorder.add_frame(event['data'])
asyncio.create_task(self._ack_screencast_frame(event, session_id))
async def _ack_screencast_frame(self, event: ScreencastFrameEvent, session_id: str | None) -> None:
"""
Asynchronously acknowledges a screencast frame.
"""
try:
await self.browser_session.cdp_client.send.Page.screencastFrameAck(
params={'sessionId': event['sessionId']}, session_id=session_id
)
except Exception as e:
self.logger.debug(f'Failed to acknowledge screencast frame: {e}')
async def on_BrowserStopEvent(self, event: BrowserStopEvent) -> None:
"""
Stops the video recording and finalizes the video file.
"""
if self._recorder:
recorder = self._recorder
self._recorder = None
self.logger.debug('Stopping video recording and saving file...')
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, recorder.stop_and_save)
@@ -0,0 +1,62 @@
"""Screenshot watchdog for handling screenshot requests using CDP."""
from typing import TYPE_CHECKING, Any, ClassVar
from bubus import BaseEvent
from cdp_use.cdp.page import CaptureScreenshotParameters
from browser_use.browser.events import ScreenshotEvent
from browser_use.browser.views import BrowserError
from browser_use.browser.watchdog_base import BaseWatchdog
from browser_use.observability import observe_debug
if TYPE_CHECKING:
pass
class ScreenshotWatchdog(BaseWatchdog):
"""Handles screenshot requests using CDP."""
# Events this watchdog listens to
LISTENS_TO: ClassVar[list[type[BaseEvent[Any]]]] = [ScreenshotEvent]
# Events this watchdog emits
EMITS: ClassVar[list[type[BaseEvent[Any]]]] = []
@observe_debug(ignore_input=True, ignore_output=True, name='screenshot_event_handler')
async def on_ScreenshotEvent(self, event: ScreenshotEvent) -> str:
"""Handle screenshot request using CDP.
Args:
event: ScreenshotEvent with optional full_page and clip parameters
Returns:
Dict with 'screenshot' key containing base64-encoded screenshot or None
"""
self.logger.debug('[ScreenshotWatchdog] Handler START - on_ScreenshotEvent called')
try:
# Get CDP client and session for current target
cdp_session = await self.browser_session.get_or_create_cdp_session()
# Prepare screenshot parameters
params = CaptureScreenshotParameters(format='png', captureBeyondViewport=False)
# Take screenshot using CDP
self.logger.debug(f'[ScreenshotWatchdog] Taking screenshot with params: {params}')
result = await cdp_session.cdp_client.send.Page.captureScreenshot(params=params, session_id=cdp_session.session_id)
# Return base64-encoded screenshot data
if result and 'data' in result:
self.logger.debug('[ScreenshotWatchdog] Screenshot captured successfully')
return result['data']
raise BrowserError('[ScreenshotWatchdog] Screenshot result missing data')
except Exception as e:
self.logger.error(f'[ScreenshotWatchdog] Screenshot failed: {e}')
raise
finally:
# Try to remove highlights even on failure
try:
await self.browser_session.remove_highlights()
except Exception:
pass
@@ -0,0 +1,219 @@
"""Security watchdog for enforcing URL access policies."""
from typing import TYPE_CHECKING, ClassVar
from bubus import BaseEvent
from browser_use.browser.events import (
BrowserErrorEvent,
NavigateToUrlEvent,
NavigationCompleteEvent,
TabCreatedEvent,
)
from browser_use.browser.watchdog_base import BaseWatchdog
if TYPE_CHECKING:
pass
# Track if we've shown the glob warning
_GLOB_WARNING_SHOWN = False
class SecurityWatchdog(BaseWatchdog):
"""Monitors and enforces security policies for URL access."""
# Event contracts
LISTENS_TO: ClassVar[list[type[BaseEvent]]] = [
NavigateToUrlEvent,
NavigationCompleteEvent,
TabCreatedEvent,
]
EMITS: ClassVar[list[type[BaseEvent]]] = [
BrowserErrorEvent,
]
async def on_NavigateToUrlEvent(self, event: NavigateToUrlEvent) -> None:
"""Check if navigation URL is allowed before navigation starts."""
# Security check BEFORE navigation
if not self._is_url_allowed(event.url):
self.logger.warning(f'⛔️ Blocking navigation to disallowed URL: {event.url}')
self.event_bus.dispatch(
BrowserErrorEvent(
error_type='NavigationBlocked',
message=f'Navigation blocked to disallowed URL: {event.url}',
details={'url': event.url, 'reason': 'not_in_allowed_domains'},
)
)
# Stop event propagation by raising exception
raise ValueError(f'Navigation to {event.url} blocked by security policy')
async def on_NavigationCompleteEvent(self, event: NavigationCompleteEvent) -> None:
"""Check if navigated URL is allowed and close tab if not."""
# Check if the navigated URL is allowed (in case of redirects)
if not self._is_url_allowed(event.url):
self.logger.warning(f'⛔️ Navigation to non-allowed URL detected: {event.url}')
# Dispatch browser error
self.event_bus.dispatch(
BrowserErrorEvent(
error_type='NavigationBlocked',
message=f'Navigation to non-allowed URL: {event.url}',
details={'url': event.url, 'target_id': event.target_id},
)
)
# Close the target that navigated to the disallowed URL
try:
await self.browser_session._cdp_close_page(event.target_id)
self.logger.info(f'⛔️ Closed target with non-allowed URL: {event.url}')
except Exception as e:
self.logger.error(f'⛔️ Failed to close target with non-allowed URL: {type(e).__name__} {e}')
async def on_TabCreatedEvent(self, event: TabCreatedEvent) -> None:
"""Check if new tab URL is allowed."""
if not self._is_url_allowed(event.url):
self.logger.warning(f'⛔️ New tab created with disallowed URL: {event.url}')
# Dispatch error and try to close the tab
self.event_bus.dispatch(
BrowserErrorEvent(
error_type='TabCreationBlocked',
message=f'Tab created with non-allowed URL: {event.url}',
details={'url': event.url, 'target_id': event.target_id},
)
)
# Try to close the offending tab
try:
await self.browser_session._cdp_close_page(event.target_id)
self.logger.info(f'⛔️ Closed new tab with non-allowed URL: {event.url}')
except Exception as e:
self.logger.error(f'⛔️ Failed to close new tab with non-allowed URL: {type(e).__name__} {e}')
def _is_root_domain(self, domain: str) -> bool:
"""Check if a domain is a root domain (no subdomain present).
Simple heuristic: only add www for domains with exactly 1 dot (domain.tld).
For complex cases like country TLDs or subdomains, users should configure explicitly.
Args:
domain: The domain to check
Returns:
True if it's a simple root domain, False otherwise
"""
# Skip if it contains wildcards or protocol
if '*' in domain or '://' in domain:
return False
return domain.count('.') == 1
def _log_glob_warning(self) -> None:
"""Log a warning about glob patterns in allowed_domains."""
global _GLOB_WARNING_SHOWN
if not _GLOB_WARNING_SHOWN:
_GLOB_WARNING_SHOWN = True
self.logger.warning(
'⚠️ Using glob patterns in allowed_domains. '
'Note: Patterns like "*.example.com" will match both subdomains AND the main domain.'
)
def _is_url_allowed(self, url: str) -> bool:
"""Check if a URL is allowed based on the allowed_domains configuration.
Args:
url: The URL to check
Returns:
True if the URL is allowed, False otherwise
"""
# If no allowed_domains specified, allow all URLs
if (
not self.browser_session.browser_profile.allowed_domains
and not self.browser_session.browser_profile.prohibited_domains
):
return True
# Always allow internal browser targets
if url in ['about:blank', 'chrome://new-tab-page/', 'chrome://new-tab-page', 'chrome://newtab/']:
return True
# Parse the URL to extract components
from urllib.parse import urlparse
try:
parsed = urlparse(url)
except Exception:
# Invalid URL
return False
# Get the actual host (domain)
host = parsed.hostname
if not host:
return False
# Check each allowed domain pattern
if self.browser_session.browser_profile.allowed_domains:
for pattern in self.browser_session.browser_profile.allowed_domains:
if self._is_url_match(url, host, parsed.scheme, pattern):
return True
return False
# Check each prohibited domain pattern
if self.browser_session.browser_profile.prohibited_domains:
for pattern in self.browser_session.browser_profile.prohibited_domains:
if self._is_url_match(url, host, parsed.scheme, pattern):
return False
return True
return True
def _is_url_match(self, url: str, host: str, scheme: str, pattern: str) -> bool:
"""Check if a URL matches a pattern."""
# Full URL for matching (scheme + host)
full_url_pattern = f'{scheme}://{host}'
# Handle glob patterns
if '*' in pattern:
self._log_glob_warning()
import fnmatch
# Check if pattern matches the host
if pattern.startswith('*.'):
# Pattern like *.example.com should match subdomains and main domain
domain_part = pattern[2:] # Remove *.
if host == domain_part or host.endswith('.' + domain_part):
# Only match http/https URLs for domain-only patterns
if scheme in ['http', 'https']:
return True
elif pattern.endswith('/*'):
# Pattern like brave://* should match any brave:// URL
prefix = pattern[:-1] # Remove the * at the end
if url.startswith(prefix):
return True
else:
# Use fnmatch for other glob patterns
if fnmatch.fnmatch(
full_url_pattern if '://' in pattern else host,
pattern,
):
return True
else:
# Exact match
if '://' in pattern:
# Full URL pattern
if url.startswith(pattern):
return True
else:
# Domain-only pattern (case-insensitive comparison)
if host.lower() == pattern.lower():
return True
# If pattern is a root domain, also check www subdomain
if self._is_root_domain(pattern) and host.lower() == f'www.{pattern.lower()}':
return True
return False
@@ -0,0 +1,335 @@
"""Storage state watchdog for managing browser cookies and storage persistence."""
import asyncio
import json
import os
from pathlib import Path
from typing import Any, ClassVar
from bubus import BaseEvent
from cdp_use.cdp.network import Cookie
from pydantic import Field, PrivateAttr
from browser_use.browser.events import (
BrowserConnectedEvent,
BrowserStopEvent,
LoadStorageStateEvent,
SaveStorageStateEvent,
StorageStateLoadedEvent,
StorageStateSavedEvent,
)
from browser_use.browser.watchdog_base import BaseWatchdog
class StorageStateWatchdog(BaseWatchdog):
"""Monitors and persists browser storage state including cookies and localStorage."""
# Event contracts
LISTENS_TO: ClassVar[list[type[BaseEvent]]] = [
BrowserConnectedEvent,
BrowserStopEvent,
SaveStorageStateEvent,
LoadStorageStateEvent,
]
EMITS: ClassVar[list[type[BaseEvent]]] = [
StorageStateSavedEvent,
StorageStateLoadedEvent,
]
# Configuration
auto_save_interval: float = Field(default=30.0) # Auto-save every 30 seconds
save_on_change: bool = Field(default=True) # Save immediately when cookies change
# Private state
_monitoring_task: asyncio.Task | None = PrivateAttr(default=None)
_last_cookie_state: list[dict] = PrivateAttr(default_factory=list)
_save_lock: asyncio.Lock = PrivateAttr(default_factory=asyncio.Lock)
async def on_BrowserConnectedEvent(self, event: BrowserConnectedEvent) -> None:
"""Start monitoring when browser starts."""
self.logger.debug('[StorageStateWatchdog] 🍪 Initializing auth/cookies sync <-> with storage_state.json file')
# Start monitoring
await self._start_monitoring()
# Automatically load storage state after browser start
await self.event_bus.dispatch(LoadStorageStateEvent())
async def on_BrowserStopEvent(self, event: BrowserStopEvent) -> None:
"""Stop monitoring when browser stops."""
self.logger.debug('[StorageStateWatchdog] Stopping storage_state monitoring')
await self._stop_monitoring()
async def on_SaveStorageStateEvent(self, event: SaveStorageStateEvent) -> None:
"""Handle storage state save request."""
# Use provided path or fall back to profile default
path = event.path
if path is None:
# Use profile default path if available
if self.browser_session.browser_profile.storage_state:
path = str(self.browser_session.browser_profile.storage_state)
else:
path = None # Skip saving if no path available
await self._save_storage_state(path)
async def on_LoadStorageStateEvent(self, event: LoadStorageStateEvent) -> None:
"""Handle storage state load request."""
# Use provided path or fall back to profile default
path = event.path
if path is None:
# Use profile default path if available
if self.browser_session.browser_profile.storage_state:
path = str(self.browser_session.browser_profile.storage_state)
else:
path = None # Skip loading if no path available
await self._load_storage_state(path)
async def _start_monitoring(self) -> None:
"""Start the monitoring task."""
if self._monitoring_task and not self._monitoring_task.done():
return
assert self.browser_session.cdp_client is not None
self._monitoring_task = asyncio.create_task(self._monitor_storage_changes())
# self.logger'[StorageStateWatchdog] Started storage monitoring task')
async def _stop_monitoring(self) -> None:
"""Stop the monitoring task."""
if self._monitoring_task and not self._monitoring_task.done():
self._monitoring_task.cancel()
try:
await self._monitoring_task
except asyncio.CancelledError:
pass
# self.logger.debug('[StorageStateWatchdog] Stopped storage monitoring task')
async def _check_for_cookie_changes_cdp(self, event: dict) -> None:
"""Check if a CDP network event indicates cookie changes.
This would be called by Network.responseReceivedExtraInfo events
if we set up CDP event listeners.
"""
try:
# Check for Set-Cookie headers in the response
headers = event.get('headers', {})
if 'set-cookie' in headers or 'Set-Cookie' in headers:
self.logger.debug('[StorageStateWatchdog] Cookie change detected via CDP')
# If save on change is enabled, trigger save immediately
if self.save_on_change:
await self._save_storage_state()
except Exception as e:
self.logger.warning(f'[StorageStateWatchdog] Error checking for cookie changes: {e}')
async def _monitor_storage_changes(self) -> None:
"""Periodically check for storage changes and auto-save."""
while True:
try:
await asyncio.sleep(self.auto_save_interval)
# Check if cookies have changed
if await self._have_cookies_changed():
self.logger.debug('[StorageStateWatchdog] Detected changes to sync with storage_state.json')
await self._save_storage_state()
except asyncio.CancelledError:
break
except Exception as e:
self.logger.error(f'[StorageStateWatchdog] Error in monitoring loop: {e}')
async def _have_cookies_changed(self) -> bool:
"""Check if cookies have changed since last save."""
if not self.browser_session.cdp_client:
return False
try:
# Get current cookies using CDP
current_cookies = await self.browser_session._cdp_get_cookies()
# Convert to comparable format, using .get() for optional fields
current_cookie_set = {
(c.get('name', ''), c.get('domain', ''), c.get('path', '')): c.get('value', '') for c in current_cookies
}
last_cookie_set = {
(c.get('name', ''), c.get('domain', ''), c.get('path', '')): c.get('value', '') for c in self._last_cookie_state
}
return current_cookie_set != last_cookie_set
except Exception as e:
self.logger.debug(f'[StorageStateWatchdog] Error comparing cookies: {e}')
return False
async def _save_storage_state(self, path: str | None = None) -> None:
"""Save browser storage state to file."""
async with self._save_lock:
# Check if CDP client is available
assert await self.browser_session.get_or_create_cdp_session(target_id=None, new_socket=False)
save_path = path or self.browser_session.browser_profile.storage_state
if not save_path:
return
# Skip saving if the storage state is already a dict (indicates it was loaded from memory)
# We only save to file if it started as a file path
if isinstance(save_path, dict):
self.logger.debug('[StorageStateWatchdog] Storage state is already a dict, skipping file save')
return
try:
# Get current storage state using CDP
storage_state = await self.browser_session._cdp_get_storage_state()
# Update our last known state
self._last_cookie_state = storage_state.get('cookies', []).copy()
# Convert path to Path object
json_path = Path(save_path).expanduser().resolve()
json_path.parent.mkdir(parents=True, exist_ok=True)
# Merge with existing state if file exists
merged_state = storage_state
if json_path.exists():
try:
existing_state = json.loads(json_path.read_text())
merged_state = self._merge_storage_states(existing_state, dict(storage_state))
except Exception as e:
self.logger.error(f'[StorageStateWatchdog] Failed to merge with existing state: {e}')
# Write atomically
temp_path = json_path.with_suffix('.json.tmp')
temp_path.write_text(json.dumps(merged_state, indent=4))
# Backup existing file
if json_path.exists():
backup_path = json_path.with_suffix('.json.bak')
json_path.replace(backup_path)
# Move temp to final
temp_path.replace(json_path)
# Emit success event
self.event_bus.dispatch(
StorageStateSavedEvent(
path=str(json_path),
cookies_count=len(merged_state.get('cookies', [])),
origins_count=len(merged_state.get('origins', [])),
)
)
self.logger.debug(
f'[StorageStateWatchdog] Saved storage state to {json_path} '
f'({len(merged_state.get("cookies", []))} cookies, '
f'{len(merged_state.get("origins", []))} origins)'
)
except Exception as e:
self.logger.error(f'[StorageStateWatchdog] Failed to save storage state: {e}')
async def _load_storage_state(self, path: str | None = None) -> None:
"""Load browser storage state from file."""
if not self.browser_session.cdp_client:
self.logger.warning('[StorageStateWatchdog] No CDP client available for loading')
return
load_path = path or self.browser_session.browser_profile.storage_state
if not load_path or not os.path.exists(str(load_path)):
return
try:
# Read the storage state file asynchronously
import anyio
content = await anyio.Path(str(load_path)).read_text()
storage = json.loads(content)
# Apply cookies if present
if 'cookies' in storage and storage['cookies']:
await self.browser_session._cdp_set_cookies(storage['cookies'])
self._last_cookie_state = storage['cookies'].copy()
self.logger.debug(f'[StorageStateWatchdog] Added {len(storage["cookies"])} cookies from storage state')
# Apply origins (localStorage/sessionStorage) if present
if 'origins' in storage and storage['origins']:
for origin in storage['origins']:
if 'localStorage' in origin:
for item in origin['localStorage']:
script = f"""
window.localStorage.setItem({json.dumps(item['name'])}, {json.dumps(item['value'])});
"""
await self.browser_session._cdp_add_init_script(script)
if 'sessionStorage' in origin:
for item in origin['sessionStorage']:
script = f"""
window.sessionStorage.setItem({json.dumps(item['name'])}, {json.dumps(item['value'])});
"""
await self.browser_session._cdp_add_init_script(script)
self.logger.debug(
f'[StorageStateWatchdog] Applied localStorage/sessionStorage from {len(storage["origins"])} origins'
)
self.event_bus.dispatch(
StorageStateLoadedEvent(
path=str(load_path),
cookies_count=len(storage.get('cookies', [])),
origins_count=len(storage.get('origins', [])),
)
)
self.logger.debug(f'[StorageStateWatchdog] Loaded storage state from: {load_path}')
except Exception as e:
self.logger.error(f'[StorageStateWatchdog] Failed to load storage state: {e}')
@staticmethod
def _merge_storage_states(existing: dict[str, Any], new: dict[str, Any]) -> dict[str, Any]:
"""Merge two storage states, with new values taking precedence."""
merged = existing.copy()
# Merge cookies
existing_cookies = {(c['name'], c['domain'], c['path']): c for c in existing.get('cookies', [])}
for cookie in new.get('cookies', []):
key = (cookie['name'], cookie['domain'], cookie['path'])
existing_cookies[key] = cookie
merged['cookies'] = list(existing_cookies.values())
# Merge origins
existing_origins = {origin['origin']: origin for origin in existing.get('origins', [])}
for origin in new.get('origins', []):
existing_origins[origin['origin']] = origin
merged['origins'] = list(existing_origins.values())
return merged
async def get_current_cookies(self) -> list[dict[str, Any]]:
"""Get current cookies using CDP."""
if not self.browser_session.cdp_client:
return []
try:
cookies = await self.browser_session._cdp_get_cookies()
# Cookie is a TypedDict, cast to dict for compatibility
return [dict(cookie) for cookie in cookies]
except Exception as e:
self.logger.error(f'[StorageStateWatchdog] Failed to get cookies: {e}')
return []
async def add_cookies(self, cookies: list[dict[str, Any]]) -> None:
"""Add cookies using CDP."""
if not self.browser_session.cdp_client:
self.logger.warning('[StorageStateWatchdog] No CDP client available for adding cookies')
return
try:
# Convert dicts to Cookie objects
cookie_objects = [Cookie(**cookie_dict) if isinstance(cookie_dict, dict) else cookie_dict for cookie_dict in cookies]
# Set cookies using CDP
await self.browser_session._cdp_set_cookies(cookie_objects)
self.logger.debug(f'[StorageStateWatchdog] Added {len(cookies)} cookies')
except Exception as e:
self.logger.error(f'[StorageStateWatchdog] Failed to add cookies: {e}')
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,505 @@
"""Configuration system for browser-use with automatic migration support."""
import json
import logging
import os
from datetime import datetime
from functools import cache
from pathlib import Path
from typing import Any
from uuid import uuid4
import psutil
from pydantic import BaseModel, ConfigDict, Field
from pydantic_settings import BaseSettings, SettingsConfigDict
logger = logging.getLogger(__name__)
@cache
def is_running_in_docker() -> bool:
"""Detect if we are running in a docker container, for the purpose of optimizing chrome launch flags (dev shm usage, gpu settings, etc.)"""
try:
if Path('/.dockerenv').exists() or 'docker' in Path('/proc/1/cgroup').read_text().lower():
return True
except Exception:
pass
try:
# if init proc (PID 1) looks like uvicorn/python/uv/etc. then we're in Docker
# if init proc (PID 1) looks like bash/systemd/init/etc. then we're probably NOT in Docker
init_cmd = ' '.join(psutil.Process(1).cmdline())
if ('py' in init_cmd) or ('uv' in init_cmd) or ('app' in init_cmd):
return True
except Exception:
pass
try:
# if less than 10 total running procs, then we're almost certainly in a container
if len(psutil.pids()) < 10:
return True
except Exception:
pass
return False
class OldConfig:
"""Original lazy-loading configuration class for environment variables."""
# Cache for directory creation tracking
_dirs_created = False
@property
def BROWSER_USE_LOGGING_LEVEL(self) -> str:
return os.getenv('BROWSER_USE_LOGGING_LEVEL', 'info').lower()
@property
def ANONYMIZED_TELEMETRY(self) -> bool:
return os.getenv('ANONYMIZED_TELEMETRY', 'true').lower()[:1] in 'ty1'
@property
def BROWSER_USE_CLOUD_SYNC(self) -> bool:
return os.getenv('BROWSER_USE_CLOUD_SYNC', str(self.ANONYMIZED_TELEMETRY)).lower()[:1] in 'ty1'
@property
def BROWSER_USE_CLOUD_API_URL(self) -> str:
url = os.getenv('BROWSER_USE_CLOUD_API_URL', 'https://api.browser-use.com')
assert '://' in url, 'BROWSER_USE_CLOUD_API_URL must be a valid URL'
return url
@property
def BROWSER_USE_CLOUD_UI_URL(self) -> str:
url = os.getenv('BROWSER_USE_CLOUD_UI_URL', '')
# Allow empty string as default, only validate if set
if url and '://' not in url:
raise AssertionError('BROWSER_USE_CLOUD_UI_URL must be a valid URL if set')
return url
# Path configuration
@property
def XDG_CACHE_HOME(self) -> Path:
return Path(os.getenv('XDG_CACHE_HOME', '~/.cache')).expanduser().resolve()
@property
def XDG_CONFIG_HOME(self) -> Path:
return Path(os.getenv('XDG_CONFIG_HOME', '~/.config')).expanduser().resolve()
@property
def BROWSER_USE_CONFIG_DIR(self) -> Path:
path = Path(os.getenv('BROWSER_USE_CONFIG_DIR', str(self.XDG_CONFIG_HOME / 'browseruse'))).expanduser().resolve()
self._ensure_dirs()
return path
@property
def BROWSER_USE_CONFIG_FILE(self) -> Path:
return self.BROWSER_USE_CONFIG_DIR / 'config.json'
@property
def BROWSER_USE_PROFILES_DIR(self) -> Path:
path = self.BROWSER_USE_CONFIG_DIR / 'profiles'
self._ensure_dirs()
return path
@property
def BROWSER_USE_DEFAULT_USER_DATA_DIR(self) -> Path:
return self.BROWSER_USE_PROFILES_DIR / 'default'
@property
def BROWSER_USE_EXTENSIONS_DIR(self) -> Path:
path = self.BROWSER_USE_CONFIG_DIR / 'extensions'
self._ensure_dirs()
return path
def _ensure_dirs(self) -> None:
"""Create directories if they don't exist (only once)"""
if not self._dirs_created:
config_dir = (
Path(os.getenv('BROWSER_USE_CONFIG_DIR', str(self.XDG_CONFIG_HOME / 'browseruse'))).expanduser().resolve()
)
config_dir.mkdir(parents=True, exist_ok=True)
(config_dir / 'profiles').mkdir(parents=True, exist_ok=True)
(config_dir / 'extensions').mkdir(parents=True, exist_ok=True)
self._dirs_created = True
# LLM API key configuration
@property
def OPENAI_API_KEY(self) -> str:
return os.getenv('OPENAI_API_KEY', '')
@property
def ANTHROPIC_API_KEY(self) -> str:
return os.getenv('ANTHROPIC_API_KEY', '')
@property
def GOOGLE_API_KEY(self) -> str:
return os.getenv('GOOGLE_API_KEY', '')
@property
def DEEPSEEK_API_KEY(self) -> str:
return os.getenv('DEEPSEEK_API_KEY', '')
@property
def GROK_API_KEY(self) -> str:
return os.getenv('GROK_API_KEY', '')
@property
def NOVITA_API_KEY(self) -> str:
return os.getenv('NOVITA_API_KEY', '')
@property
def AZURE_OPENAI_ENDPOINT(self) -> str:
return os.getenv('AZURE_OPENAI_ENDPOINT', '')
@property
def AZURE_OPENAI_KEY(self) -> str:
return os.getenv('AZURE_OPENAI_KEY', '')
@property
def SKIP_LLM_API_KEY_VERIFICATION(self) -> bool:
return os.getenv('SKIP_LLM_API_KEY_VERIFICATION', 'false').lower()[:1] in 'ty1'
@property
def DEFAULT_LLM(self) -> str:
return os.getenv('DEFAULT_LLM', '')
# Runtime hints
@property
def IN_DOCKER(self) -> bool:
return os.getenv('IN_DOCKER', 'false').lower()[:1] in 'ty1' or is_running_in_docker()
@property
def IS_IN_EVALS(self) -> bool:
return os.getenv('IS_IN_EVALS', 'false').lower()[:1] in 'ty1'
@property
def WIN_FONT_DIR(self) -> str:
return os.getenv('WIN_FONT_DIR', 'C:\\Windows\\Fonts')
class FlatEnvConfig(BaseSettings):
"""All environment variables in a flat namespace."""
model_config = SettingsConfigDict(env_file='.env', env_file_encoding='utf-8', case_sensitive=True, extra='allow')
# Logging and telemetry
BROWSER_USE_LOGGING_LEVEL: str = Field(default='info')
CDP_LOGGING_LEVEL: str = Field(default='WARNING')
BROWSER_USE_DEBUG_LOG_FILE: str | None = Field(default=None)
BROWSER_USE_INFO_LOG_FILE: str | None = Field(default=None)
ANONYMIZED_TELEMETRY: bool = Field(default=True)
BROWSER_USE_CLOUD_SYNC: bool | None = Field(default=None)
BROWSER_USE_CLOUD_API_URL: str = Field(default='https://api.browser-use.com')
BROWSER_USE_CLOUD_UI_URL: str = Field(default='')
# Path configuration
XDG_CACHE_HOME: str = Field(default='~/.cache')
XDG_CONFIG_HOME: str = Field(default='~/.config')
BROWSER_USE_CONFIG_DIR: str | None = Field(default=None)
# LLM API keys
OPENAI_API_KEY: str = Field(default='')
ANTHROPIC_API_KEY: str = Field(default='')
GOOGLE_API_KEY: str = Field(default='')
DEEPSEEK_API_KEY: str = Field(default='')
GROK_API_KEY: str = Field(default='')
NOVITA_API_KEY: str = Field(default='')
AZURE_OPENAI_ENDPOINT: str = Field(default='')
AZURE_OPENAI_KEY: str = Field(default='')
SKIP_LLM_API_KEY_VERIFICATION: bool = Field(default=False)
DEFAULT_LLM: str = Field(default='')
# Runtime hints
IN_DOCKER: bool | None = Field(default=None)
IS_IN_EVALS: bool = Field(default=False)
WIN_FONT_DIR: str = Field(default='C:\\Windows\\Fonts')
# MCP-specific env vars
BROWSER_USE_CONFIG_PATH: str | None = Field(default=None)
BROWSER_USE_HEADLESS: bool | None = Field(default=None)
BROWSER_USE_ALLOWED_DOMAINS: str | None = Field(default=None)
BROWSER_USE_LLM_MODEL: str | None = Field(default=None)
# Proxy env vars
BROWSER_USE_PROXY_URL: str | None = Field(default=None)
BROWSER_USE_NO_PROXY: str | None = Field(default=None)
BROWSER_USE_PROXY_USERNAME: str | None = Field(default=None)
BROWSER_USE_PROXY_PASSWORD: str | None = Field(default=None)
class DBStyleEntry(BaseModel):
"""Database-style entry with UUID and metadata."""
id: str = Field(default_factory=lambda: str(uuid4()))
default: bool = Field(default=False)
created_at: str = Field(default_factory=lambda: datetime.utcnow().isoformat())
class BrowserProfileEntry(DBStyleEntry):
"""Browser profile configuration entry - accepts any BrowserProfile fields."""
model_config = ConfigDict(extra='allow')
# Common browser profile fields for reference
headless: bool | None = None
user_data_dir: str | None = None
allowed_domains: list[str] | None = None
downloads_path: str | None = None
class LLMEntry(DBStyleEntry):
"""LLM configuration entry."""
api_key: str | None = None
model: str | None = None
temperature: float | None = None
max_tokens: int | None = None
class AgentEntry(DBStyleEntry):
"""Agent configuration entry."""
max_steps: int | None = None
use_vision: bool | None = None
system_prompt: str | None = None
class DBStyleConfigJSON(BaseModel):
"""New database-style configuration format."""
browser_profile: dict[str, BrowserProfileEntry] = Field(default_factory=dict)
llm: dict[str, LLMEntry] = Field(default_factory=dict)
agent: dict[str, AgentEntry] = Field(default_factory=dict)
def create_default_config() -> DBStyleConfigJSON:
"""Create a fresh default configuration."""
logger.debug('Creating fresh default config.json')
new_config = DBStyleConfigJSON()
# Generate default IDs
profile_id = str(uuid4())
llm_id = str(uuid4())
agent_id = str(uuid4())
# Create default browser profile entry
new_config.browser_profile[profile_id] = BrowserProfileEntry(id=profile_id, default=True, headless=False, user_data_dir=None)
# Create default LLM entry
new_config.llm[llm_id] = LLMEntry(id=llm_id, default=True, model='gpt-4.1-mini', api_key='your-openai-api-key-here')
# Create default agent entry
new_config.agent[agent_id] = AgentEntry(id=agent_id, default=True)
return new_config
def load_and_migrate_config(config_path: Path) -> DBStyleConfigJSON:
"""Load config.json or create fresh one if old format detected."""
if not config_path.exists():
# Create fresh config with defaults
config_path.parent.mkdir(parents=True, exist_ok=True)
new_config = create_default_config()
with open(config_path, 'w') as f:
json.dump(new_config.model_dump(), f, indent=2)
return new_config
try:
with open(config_path) as f:
data = json.load(f)
# Check if it's already in DB-style format
if all(key in data for key in ['browser_profile', 'llm', 'agent']) and all(
isinstance(data.get(key, {}), dict) for key in ['browser_profile', 'llm', 'agent']
):
# Check if the values are DB-style entries (have UUIDs as keys)
if data.get('browser_profile') and all(isinstance(v, dict) and 'id' in v for v in data['browser_profile'].values()):
# Already in new format
return DBStyleConfigJSON(**data)
# Old format detected - delete it and create fresh config
logger.debug(f'Old config format detected at {config_path}, creating fresh config')
new_config = create_default_config()
# Overwrite with new config
with open(config_path, 'w') as f:
json.dump(new_config.model_dump(), f, indent=2)
logger.debug(f'Created fresh config.json at {config_path}')
return new_config
except Exception as e:
logger.error(f'Failed to load config from {config_path}: {e}, creating fresh config')
# On any error, create fresh config
new_config = create_default_config()
try:
with open(config_path, 'w') as f:
json.dump(new_config.model_dump(), f, indent=2)
except Exception as write_error:
logger.error(f'Failed to write fresh config: {write_error}')
return new_config
class Config:
"""Backward-compatible configuration class that merges all config sources.
Re-reads environment variables on every access to maintain compatibility.
"""
def __init__(self):
# Cache for directory creation tracking only
self._dirs_created = False
def __getattr__(self, name: str) -> Any:
"""Dynamically proxy all attributes to fresh instances.
This ensures env vars are re-read on every access.
"""
# Special handling for internal attributes
if name.startswith('_'):
raise AttributeError(f"'{self.__class__.__name__}' object has no attribute '{name}'")
# Create fresh instances on every access
old_config = OldConfig()
# Always use old config for all attributes (it handles env vars with proper transformations)
if hasattr(old_config, name):
return getattr(old_config, name)
# For new MCP-specific attributes not in old config
env_config = FlatEnvConfig()
if hasattr(env_config, name):
return getattr(env_config, name)
# Handle special methods
if name == 'get_default_profile':
return lambda: self._get_default_profile()
elif name == 'get_default_llm':
return lambda: self._get_default_llm()
elif name == 'get_default_agent':
return lambda: self._get_default_agent()
elif name == 'load_config':
return lambda: self._load_config()
elif name == '_ensure_dirs':
return lambda: old_config._ensure_dirs()
raise AttributeError(f"'{self.__class__.__name__}' object has no attribute '{name}'")
def _get_config_path(self) -> Path:
"""Get config path from fresh env config."""
env_config = FlatEnvConfig()
if env_config.BROWSER_USE_CONFIG_PATH:
return Path(env_config.BROWSER_USE_CONFIG_PATH).expanduser()
elif env_config.BROWSER_USE_CONFIG_DIR:
return Path(env_config.BROWSER_USE_CONFIG_DIR).expanduser() / 'config.json'
else:
xdg_config = Path(env_config.XDG_CONFIG_HOME).expanduser()
return xdg_config / 'browseruse' / 'config.json'
def _get_db_config(self) -> DBStyleConfigJSON:
"""Load and migrate config.json."""
config_path = self._get_config_path()
return load_and_migrate_config(config_path)
def _get_default_profile(self) -> dict[str, Any]:
"""Get the default browser profile configuration."""
db_config = self._get_db_config()
for profile in db_config.browser_profile.values():
if profile.default:
return profile.model_dump(exclude_none=True)
# Return first profile if no default
if db_config.browser_profile:
return next(iter(db_config.browser_profile.values())).model_dump(exclude_none=True)
return {}
def _get_default_llm(self) -> dict[str, Any]:
"""Get the default LLM configuration."""
db_config = self._get_db_config()
for llm in db_config.llm.values():
if llm.default:
return llm.model_dump(exclude_none=True)
# Return first LLM if no default
if db_config.llm:
return next(iter(db_config.llm.values())).model_dump(exclude_none=True)
return {}
def _get_default_agent(self) -> dict[str, Any]:
"""Get the default agent configuration."""
db_config = self._get_db_config()
for agent in db_config.agent.values():
if agent.default:
return agent.model_dump(exclude_none=True)
# Return first agent if no default
if db_config.agent:
return next(iter(db_config.agent.values())).model_dump(exclude_none=True)
return {}
def _load_config(self) -> dict[str, Any]:
"""Load configuration with env var overrides for MCP components."""
config = {
'browser_profile': self._get_default_profile(),
'llm': self._get_default_llm(),
'agent': self._get_default_agent(),
}
# Fresh env config for overrides
env_config = FlatEnvConfig()
# Apply MCP-specific env var overrides
if env_config.BROWSER_USE_HEADLESS is not None:
config['browser_profile']['headless'] = env_config.BROWSER_USE_HEADLESS
if env_config.BROWSER_USE_ALLOWED_DOMAINS:
domains = [d.strip() for d in env_config.BROWSER_USE_ALLOWED_DOMAINS.split(',') if d.strip()]
config['browser_profile']['allowed_domains'] = domains
# Proxy settings (Chromium) -> consolidated `proxy` dict
proxy_dict: dict[str, Any] = {}
if env_config.BROWSER_USE_PROXY_URL:
proxy_dict['server'] = env_config.BROWSER_USE_PROXY_URL
if env_config.BROWSER_USE_NO_PROXY:
# store bypass as comma-separated string to match Chrome flag
proxy_dict['bypass'] = ','.join([d.strip() for d in env_config.BROWSER_USE_NO_PROXY.split(',') if d.strip()])
if env_config.BROWSER_USE_PROXY_USERNAME:
proxy_dict['username'] = env_config.BROWSER_USE_PROXY_USERNAME
if env_config.BROWSER_USE_PROXY_PASSWORD:
proxy_dict['password'] = env_config.BROWSER_USE_PROXY_PASSWORD
if proxy_dict:
# ensure section exists
config.setdefault('browser_profile', {})
config['browser_profile']['proxy'] = proxy_dict
if env_config.OPENAI_API_KEY:
config['llm']['api_key'] = env_config.OPENAI_API_KEY
if env_config.BROWSER_USE_LLM_MODEL:
config['llm']['model'] = env_config.BROWSER_USE_LLM_MODEL
return config
# Create singleton instance
CONFIG = Config()
# Helper functions for MCP components
def load_browser_use_config() -> dict[str, Any]:
"""Load browser-use configuration for MCP components."""
return CONFIG.load_config()
def get_default_profile(config: dict[str, Any]) -> dict[str, Any]:
"""Get default browser profile from config dict."""
return config.get('browser_profile', {})
def get_default_llm(config: dict[str, Any]) -> dict[str, Any]:
"""Get default LLM config from config dict."""
return config.get('llm', {})
@@ -0,0 +1,3 @@
from browser_use.tools.service import Controller
__all__ = ['Controller']
@@ -0,0 +1,161 @@
"""
Enhanced snapshot processing for browser-use DOM tree extraction.
This module provides stateless functions for parsing Chrome DevTools Protocol (CDP) DOMSnapshot data
to extract visibility, clickability, cursor styles, and other layout information.
"""
from cdp_use.cdp.domsnapshot.commands import CaptureSnapshotReturns
from cdp_use.cdp.domsnapshot.types import (
LayoutTreeSnapshot,
NodeTreeSnapshot,
RareBooleanData,
)
from browser_use.dom.views import DOMRect, EnhancedSnapshotNode
# Only the ESSENTIAL computed styles for interactivity and visibility detection
REQUIRED_COMPUTED_STYLES = [
# Only styles actually accessed in the codebase (prevents Chrome crashes on heavy sites)
'display', # Used in service.py visibility detection
'visibility', # Used in service.py visibility detection
'opacity', # Used in service.py visibility detection
'overflow', # Used in views.py scrollability detection
'overflow-x', # Used in views.py scrollability detection
'overflow-y', # Used in views.py scrollability detection
'cursor', # Used in enhanced_snapshot.py cursor extraction
'pointer-events', # Used for clickability logic
'position', # Used for visibility logic
'background-color', # Used for visibility logic
]
def _parse_rare_boolean_data(rare_data: RareBooleanData, index: int) -> bool | None:
"""Parse rare boolean data from snapshot - returns True if index is in the rare data."""
return index in rare_data['index']
def _parse_computed_styles(strings: list[str], style_indices: list[int]) -> dict[str, str]:
"""Parse computed styles from layout tree using string indices."""
styles = {}
for i, style_index in enumerate(style_indices):
if i < len(REQUIRED_COMPUTED_STYLES) and 0 <= style_index < len(strings):
styles[REQUIRED_COMPUTED_STYLES[i]] = strings[style_index]
return styles
def build_snapshot_lookup(
snapshot: CaptureSnapshotReturns,
device_pixel_ratio: float = 1.0,
) -> dict[int, EnhancedSnapshotNode]:
"""Build a lookup table of backend node ID to enhanced snapshot data with everything calculated upfront."""
snapshot_lookup: dict[int, EnhancedSnapshotNode] = {}
if not snapshot['documents']:
return snapshot_lookup
strings = snapshot['strings']
for document in snapshot['documents']:
nodes: NodeTreeSnapshot = document['nodes']
layout: LayoutTreeSnapshot = document['layout']
# Build backend node id to snapshot index lookup
backend_node_to_snapshot_index = {}
if 'backendNodeId' in nodes:
for i, backend_node_id in enumerate(nodes['backendNodeId']):
backend_node_to_snapshot_index[backend_node_id] = i
# PERFORMANCE: Pre-build layout index map to eliminate O(n²) double lookups
# Preserve original behavior: use FIRST occurrence for duplicates
layout_index_map = {}
if layout and 'nodeIndex' in layout:
for layout_idx, node_index in enumerate(layout['nodeIndex']):
if node_index not in layout_index_map: # Only store first occurrence
layout_index_map[node_index] = layout_idx
# Build snapshot lookup for each backend node id
for backend_node_id, snapshot_index in backend_node_to_snapshot_index.items():
is_clickable = None
if 'isClickable' in nodes:
is_clickable = _parse_rare_boolean_data(nodes['isClickable'], snapshot_index)
# Find corresponding layout node
cursor_style = None
is_visible = None
bounding_box = None
computed_styles = {}
# Look for layout tree node that corresponds to this snapshot node
paint_order = None
client_rects = None
scroll_rects = None
stacking_contexts = None
if snapshot_index in layout_index_map:
layout_idx = layout_index_map[snapshot_index]
if layout_idx < len(layout.get('bounds') or []):
# Parse bounding box
bounds = layout['bounds'][layout_idx]
if len(bounds) >= 4:
# IMPORTANT: CDP coordinates are in device pixels, convert to CSS pixels
# by dividing by the device pixel ratio
raw_x, raw_y, raw_width, raw_height = bounds[0], bounds[1], bounds[2], bounds[3]
# Apply device pixel ratio scaling to convert device pixels to CSS pixels
bounding_box = DOMRect(
x=raw_x / device_pixel_ratio,
y=raw_y / device_pixel_ratio,
width=raw_width / device_pixel_ratio,
height=raw_height / device_pixel_ratio,
)
# Parse computed styles for this layout node
if layout_idx < len(layout.get('styles') or []):
style_indices = layout['styles'][layout_idx]
computed_styles = _parse_computed_styles(strings, style_indices)
cursor_style = computed_styles.get('cursor')
# Extract paint order if available
if layout_idx < len(layout.get('paintOrders') or []):
paint_order = layout.get('paintOrders', [])[layout_idx]
# Extract client rects if available
client_rects_data = layout.get('clientRects') or []
if layout_idx < len(client_rects_data):
client_rect_data = client_rects_data[layout_idx]
if client_rect_data and len(client_rect_data) >= 4:
client_rects = DOMRect(
x=client_rect_data[0],
y=client_rect_data[1],
width=client_rect_data[2],
height=client_rect_data[3],
)
# Extract scroll rects if available
scroll_rects_data = layout.get('scrollRects') or []
if layout_idx < len(scroll_rects_data):
scroll_rect_data = scroll_rects_data[layout_idx]
if scroll_rect_data and len(scroll_rect_data) >= 4:
scroll_rects = DOMRect(
x=scroll_rect_data[0],
y=scroll_rect_data[1],
width=scroll_rect_data[2],
height=scroll_rect_data[3],
)
# Extract stacking contexts if available
if layout_idx < len(layout.get('stackingContexts') or []):
stacking_contexts = layout.get('stackingContexts', {}).get('index', [])[layout_idx]
snapshot_lookup[backend_node_id] = EnhancedSnapshotNode(
is_clickable=is_clickable,
cursor_style=cursor_style,
bounds=bounding_box,
clientRects=client_rects,
scrollRects=scroll_rects,
computed_styles=computed_styles if computed_styles else None,
paint_order=paint_order,
stacking_contexts=stacking_contexts,
)
return snapshot_lookup
@@ -0,0 +1,312 @@
import asyncio
import json
import os
import time
import anyio
import pyperclip
import tiktoken
from browser_use.agent.prompts import AgentMessagePrompt
from browser_use.browser import BrowserProfile, BrowserSession
from browser_use.browser.events import ClickElementEvent, TypeTextEvent
from browser_use.browser.profile import ViewportSize
from browser_use.dom.service import DomService
from browser_use.dom.views import DEFAULT_INCLUDE_ATTRIBUTES
from browser_use.filesystem.file_system import FileSystem
TIMEOUT = 60
async def test_focus_vs_all_elements():
browser_session = BrowserSession(
browser_profile=BrowserProfile(
# executable_path='/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
window_size=ViewportSize(width=1100, height=1000),
disable_security=False,
wait_for_network_idle_page_load_time=1,
headless=False,
args=['--incognito'],
paint_order_filtering=True,
),
)
# 10 Sample websites with various interactive elements
sample_websites = [
'https://browser-use.github.io/stress-tests/challenges/iframe-inception-level2.html',
'https://www.google.com/travel/flights',
'https://v0-simple-ui-test-site.vercel.app',
'https://browser-use.github.io/stress-tests/challenges/iframe-inception-level1.html',
'https://browser-use.github.io/stress-tests/challenges/angular-form.html',
'https://www.google.com/travel/flights',
'https://www.amazon.com/s?k=laptop',
'https://github.com/trending',
'https://www.reddit.com',
'https://www.ycombinator.com/companies',
'https://www.kayak.com/flights',
'https://www.booking.com',
'https://www.airbnb.com',
'https://www.linkedin.com/jobs',
'https://stackoverflow.com/questions',
]
# 5 Difficult websites with complex elements (iframes, canvas, dropdowns, etc.)
difficult_websites = [
'https://www.w3schools.com/html/tryit.asp?filename=tryhtml_iframe', # Nested iframes
'https://semantic-ui.com/modules/dropdown.html', # Complex dropdowns
'https://www.dezlearn.com/nested-iframes-example/', # Cross-origin nested iframes
'https://codepen.io/towc/pen/mJzOWJ', # Canvas elements with interactions
'https://jqueryui.com/accordion/', # Complex accordion/dropdown widgets
'https://v0-simple-landing-page-seven-xi.vercel.app/', # Simple landing page with iframe
'https://www.unesco.org/en',
]
# Descriptions for difficult websites
difficult_descriptions = {
'https://www.w3schools.com/html/tryit.asp?filename=tryhtml_iframe': '🔸 NESTED IFRAMES: Multiple iframe layers',
'https://semantic-ui.com/modules/dropdown.html': '🔸 COMPLEX DROPDOWNS: Custom dropdown components',
'https://www.dezlearn.com/nested-iframes-example/': '🔸 CROSS-ORIGIN IFRAMES: Different domain iframes',
'https://codepen.io/towc/pen/mJzOWJ': '🔸 CANVAS ELEMENTS: Interactive canvas graphics',
'https://jqueryui.com/accordion/': '🔸 ACCORDION WIDGETS: Collapsible content sections',
}
websites = sample_websites + difficult_websites
current_website_index = 0
def get_website_list_for_prompt() -> str:
"""Get a compact website list for the input prompt."""
lines = []
lines.append('📋 Websites:')
# Sample websites (1-10)
for i, site in enumerate(sample_websites, 1):
current_marker = '' if (i - 1) == current_website_index else ''
domain = site.replace('https://', '').split('/')[0]
lines.append(f' {i:2d}.{domain[:15]:<15}{current_marker}')
# Difficult websites (11-15)
for i, site in enumerate(difficult_websites, len(sample_websites) + 1):
current_marker = '' if (i - 1) == current_website_index else ''
domain = site.replace('https://', '').split('/')[0]
desc = difficult_descriptions.get(site, '')
challenge = desc.split(': ')[1][:15] if ': ' in desc else ''
lines.append(f' {i:2d}.{domain[:15]:<15} ({challenge}){current_marker}')
return '\n'.join(lines)
await browser_session.start()
# Show startup info
print('\n🌐 BROWSER-USE DOM EXTRACTION TESTER')
print(f'📊 {len(websites)} websites total: {len(sample_websites)} standard + {len(difficult_websites)} complex')
print('🔧 Controls: Type 1-15 to jump | Enter to re-run | "n" next | "q" quit')
print('💾 Outputs: tmp/user_message.txt & tmp/element_tree.json\n')
dom_service = DomService(browser_session)
while True:
# Cycle through websites
if current_website_index >= len(websites):
current_website_index = 0
print('Cycled back to first website!')
website = websites[current_website_index]
# sleep 2
await browser_session._cdp_navigate(website)
await asyncio.sleep(1)
last_clicked_index = None # Track the index for text input
while True:
try:
# all_elements_state = await dom_service.get_serialized_dom_tree()
website_type = 'DIFFICULT' if website in difficult_websites else 'SAMPLE'
print(f'\n{"=" * 60}')
print(f'[{current_website_index + 1}/{len(websites)}] [{website_type}] Testing: {website}')
if website in difficult_descriptions:
print(f'{difficult_descriptions[website]}')
print(f'{"=" * 60}')
# Get/refresh the state (includes removing old highlights)
print('\nGetting page state...')
start_time = time.time()
all_elements_state = await browser_session.get_browser_state_summary(True)
end_time = time.time()
get_state_time = end_time - start_time
print(f'get_state_summary took {get_state_time:.2f} seconds')
# Get detailed timing info from DOM service
print('\nGetting detailed DOM timing...')
serialized_state, _, timing_info = await dom_service.get_serialized_dom_tree()
# Combine all timing info
all_timing = {'get_state_summary_total': get_state_time, **timing_info}
selector_map = all_elements_state.dom_state.selector_map
total_elements = len(selector_map.keys())
print(f'Total number of elements: {total_elements}')
# print(all_elements_state.element_tree.clickable_elements_to_string())
prompt = AgentMessagePrompt(
browser_state_summary=all_elements_state,
file_system=FileSystem(base_dir='./tmp'),
include_attributes=DEFAULT_INCLUDE_ATTRIBUTES,
step_info=None,
)
# Write the user message to a file for analysis
user_message = prompt.get_user_message(use_vision=False).text
# clickable_elements_str = all_elements_state.element_tree.clickable_elements_to_string()
text_to_save = user_message
os.makedirs('./tmp', exist_ok=True)
async with await anyio.open_file('./tmp/user_message.txt', 'w', encoding='utf-8') as f:
await f.write(text_to_save)
# save pure clickable elements to a file
if all_elements_state.dom_state._root:
async with await anyio.open_file('./tmp/simplified_element_tree.json', 'w', encoding='utf-8') as f:
await f.write(json.dumps(all_elements_state.dom_state._root.__json__(), indent=2))
async with await anyio.open_file('./tmp/original_element_tree.json', 'w', encoding='utf-8') as f:
await f.write(json.dumps(all_elements_state.dom_state._root.original_node.__json__(), indent=2))
# copy the user message to the clipboard
# pyperclip.copy(text_to_save)
encoding = tiktoken.encoding_for_model('gpt-4.1-mini')
token_count = len(encoding.encode(text_to_save))
print(f'Token count: {token_count}')
print('User message written to ./tmp/user_message.txt')
print('Element tree written to ./tmp/simplified_element_tree.json')
print('Original element tree written to ./tmp/original_element_tree.json')
# Save timing information
timing_text = '🔍 DOM EXTRACTION PERFORMANCE ANALYSIS\n'
timing_text += f'{"=" * 50}\n\n'
timing_text += f'📄 Website: {website}\n'
timing_text += f'📊 Total Elements: {total_elements}\n'
timing_text += f'🎯 Token Count: {token_count}\n\n'
timing_text += '⏱️ TIMING BREAKDOWN:\n'
timing_text += f'{"" * 30}\n'
for key, value in all_timing.items():
timing_text += f'{key:<35}: {value * 1000:>8.2f} ms\n'
# Calculate percentages
total_time = all_timing.get('get_state_summary_total', 0)
if total_time > 0 and total_elements > 0:
timing_text += '\n📈 PERCENTAGE BREAKDOWN:\n'
timing_text += f'{"" * 30}\n'
for key, value in all_timing.items():
if key != 'get_state_summary_total':
percentage = (value / total_time) * 100
timing_text += f'{key:<35}: {percentage:>7.1f}%\n'
timing_text += '\n🎯 CLICKABLE DETECTION ANALYSIS:\n'
timing_text += f'{"" * 35}\n'
clickable_time = all_timing.get('clickable_detection_time', 0)
if clickable_time > 0 and total_elements > 0:
avg_per_element = (clickable_time / total_elements) * 1000000 # microseconds
timing_text += f'Total clickable detection time: {clickable_time * 1000:.2f} ms\n'
timing_text += f'Average per element: {avg_per_element:.2f} μs\n'
timing_text += f'Clickable detection calls: ~{total_elements} (approx)\n'
async with await anyio.open_file('./tmp/timing_analysis.txt', 'w', encoding='utf-8') as f:
await f.write(timing_text)
print('Timing analysis written to ./tmp/timing_analysis.txt')
# also save all_elements_state.element_tree.clickable_elements_to_string() to a file
# with open('./tmp/clickable_elements.json', 'w', encoding='utf-8') as f:
# f.write(json.dumps(all_elements_state.element_tree.__json__(), indent=2))
# print('Clickable elements written to ./tmp/clickable_elements.json')
website_list = get_website_list_for_prompt()
answer = input(
"🎮 Enter: element index | 'index' click (clickable) | 'index,text' input | 'c,index' copy | Enter re-run | 'n' next | 'q' quit: "
)
if answer.lower() == 'q':
return # Exit completely
elif answer.lower() == 'n':
print('Moving to next website...')
current_website_index += 1
break # Break inner loop to go to next website
elif answer.strip() == '':
print('Re-running extraction on current page state...')
continue # Continue inner loop to re-extract DOM without reloading page
elif answer.strip().isdigit():
# Click element format: index
try:
clicked_index = int(answer)
if clicked_index in selector_map:
element_node = selector_map[clicked_index]
print(f'Clicking element {clicked_index}: {element_node.tag_name}')
event = browser_session.event_bus.dispatch(ClickElementEvent(node=element_node))
await event
print('Click successful.')
except ValueError:
print(f"Invalid input: '{answer}'. Enter an index, 'index,text', 'c,index', or 'q'.")
continue
try:
if answer.lower().startswith('c,'):
# Copy element JSON format: c,index
parts = answer.split(',', 1)
if len(parts) == 2:
try:
target_index = int(parts[1].strip())
if target_index in selector_map:
element_node = selector_map[target_index]
element_json = json.dumps(element_node.__json__(), indent=2, default=str)
pyperclip.copy(element_json)
print(f'Copied element {target_index} JSON to clipboard: {element_node.tag_name}')
else:
print(f'Invalid index: {target_index}')
except ValueError:
print(f'Invalid index format: {parts[1]}')
else:
print("Invalid input format. Use 'c,index'.")
elif ',' in answer:
# Input text format: index,text
parts = answer.split(',', 1)
if len(parts) == 2:
try:
target_index = int(parts[0].strip())
text_to_input = parts[1]
if target_index in selector_map:
element_node = selector_map[target_index]
print(
f"Inputting text '{text_to_input}' into element {target_index}: {element_node.tag_name}"
)
event = await browser_session.event_bus.dispatch(
TypeTextEvent(node=element_node, text=text_to_input)
)
print('Input successful.')
else:
print(f'Invalid index: {target_index}')
except ValueError:
print(f'Invalid index format: {parts[0]}')
else:
print("Invalid input format. Use 'index,text'.")
except Exception as action_e:
print(f'Action failed: {action_e}')
# No explicit highlight removal here, get_state handles it at the start of the loop
except Exception as e:
print(f'Error in loop: {e}')
# Optionally add a small delay before retrying
await asyncio.sleep(1)
if __name__ == '__main__':
asyncio.run(test_focus_vs_all_elements())
# asyncio.run(test_process_html_file()) # Commented out the other test
@@ -0,0 +1,32 @@
from browser_use import Agent
from browser_use.browser import BrowserProfile, BrowserSession
from browser_use.browser.types import ViewportSize
from browser_use.llm import ChatAzureOpenAI
# Initialize the Azure OpenAI client
llm = ChatAzureOpenAI(
model='gpt-4.1-mini',
)
TASK = """
Go to https://browser-use.github.io/stress-tests/challenges/react-native-web-form.html and complete the React Native Web form by filling in all required fields and submitting.
"""
async def main():
browser = BrowserSession(
browser_profile=BrowserProfile(
window_size=ViewportSize(width=1100, height=1000),
)
)
agent = Agent(task=TASK, llm=llm)
await agent.run()
if __name__ == '__main__':
import asyncio
asyncio.run(main())
@@ -0,0 +1,199 @@
from browser_use.dom.views import EnhancedDOMTreeNode, NodeType
class ClickableElementDetector:
@staticmethod
def is_interactive(node: EnhancedDOMTreeNode) -> bool:
"""Check if this node is clickable/interactive using enhanced scoring."""
# Skip non-element nodes
if node.node_type != NodeType.ELEMENT_NODE:
return False
# # if ax ignored skip
# if node.ax_node and node.ax_node.ignored:
# return False
# remove html and body nodes
if node.tag_name in {'html', 'body'}:
return False
# IFRAME elements should be interactive if they're large enough to potentially need scrolling
# Small iframes (< 100px width or height) are unlikely to have scrollable content
if node.tag_name and node.tag_name.upper() == 'IFRAME' or node.tag_name.upper() == 'FRAME':
if node.snapshot_node and node.snapshot_node.bounds:
width = node.snapshot_node.bounds.width
height = node.snapshot_node.bounds.height
# Only include iframes larger than 100x100px
if width > 100 and height > 100:
return True
# RELAXED SIZE CHECK: Allow all elements including size 0 (they might be interactive overlays, etc.)
# Note: Size 0 elements can still be interactive (e.g., invisible clickable overlays)
# Visibility is determined separately by CSS styles, not just bounding box size
# SEARCH ELEMENT DETECTION: Check for search-related classes and attributes
if node.attributes:
search_indicators = {
'search',
'magnify',
'glass',
'lookup',
'find',
'query',
'search-icon',
'search-btn',
'search-button',
'searchbox',
}
# Check class names for search indicators
class_list = node.attributes.get('class', '').lower().split()
if any(indicator in ' '.join(class_list) for indicator in search_indicators):
return True
# Check id for search indicators
element_id = node.attributes.get('id', '').lower()
if any(indicator in element_id for indicator in search_indicators):
return True
# Check data attributes for search functionality
for attr_name, attr_value in node.attributes.items():
if attr_name.startswith('data-') and any(indicator in attr_value.lower() for indicator in search_indicators):
return True
# Enhanced accessibility property checks - direct clear indicators only
if node.ax_node and node.ax_node.properties:
for prop in node.ax_node.properties:
try:
# aria disabled
if prop.name == 'disabled' and prop.value:
return False
# aria hidden
if prop.name == 'hidden' and prop.value:
return False
# Direct interactiveness indicators
if prop.name in ['focusable', 'editable', 'settable'] and prop.value:
return True
# Interactive state properties (presence indicates interactive widget)
if prop.name in ['checked', 'expanded', 'pressed', 'selected']:
# These properties only exist on interactive elements
return True
# Form-related interactiveness
if prop.name in ['required', 'autocomplete'] and prop.value:
return True
# Elements with keyboard shortcuts are interactive
if prop.name == 'keyshortcuts' and prop.value:
return True
except (AttributeError, ValueError):
# Skip properties we can't process
continue
# ENHANCED TAG CHECK: Include truly interactive elements
# Note: 'label' removed - labels are handled by other attribute checks below - other wise labels with "for" attribute can destroy the real clickable element on apartments.com
interactive_tags = {
'button',
'input',
'select',
'textarea',
'a',
'details',
'summary',
'option',
'optgroup',
}
if node.tag_name in interactive_tags:
return True
# SVG elements need special handling - only interactive if they have explicit handlers
# svg_tags = {'svg', 'path', 'circle', 'rect', 'polygon', 'ellipse', 'line', 'polyline', 'g'}
# if node.tag_name in svg_tags:
# # Only consider SVG elements interactive if they have:
# # 1. Explicit event handlers
# # 2. Interactive role attributes
# # 3. Cursor pointer style
# if node.attributes:
# # Check for event handlers
# if any(attr.startswith('on') for attr in node.attributes):
# return True
# # Check for interactive roles
# if node.attributes.get('role') in {'button', 'link', 'menuitem'}:
# return True
# # Check for cursor pointer (indicating clickability)
# if node.attributes.get('style') and 'cursor: pointer' in node.attributes.get('style', ''):
# return True
# # Otherwise, SVG elements are decorative
# return False
# Tertiary check: elements with interactive attributes
if node.attributes:
# Check for event handlers or interactive attributes
interactive_attributes = {'onclick', 'onmousedown', 'onmouseup', 'onkeydown', 'onkeyup', 'tabindex'}
if any(attr in node.attributes for attr in interactive_attributes):
return True
# Check for interactive ARIA roles
if 'role' in node.attributes:
interactive_roles = {
'button',
'link',
'menuitem',
'option',
'radio',
'checkbox',
'tab',
'textbox',
'combobox',
'slider',
'spinbutton',
'search',
'searchbox',
}
if node.attributes['role'] in interactive_roles:
return True
# Quaternary check: accessibility tree roles
if node.ax_node and node.ax_node.role:
interactive_ax_roles = {
'button',
'link',
'menuitem',
'option',
'radio',
'checkbox',
'tab',
'textbox',
'combobox',
'slider',
'spinbutton',
'listbox',
'search',
'searchbox',
}
if node.ax_node.role in interactive_ax_roles:
return True
# ICON AND SMALL ELEMENT CHECK: Elements that might be icons
if (
node.snapshot_node
and node.snapshot_node.bounds
and 10 <= node.snapshot_node.bounds.width <= 50 # Icon-sized elements
and 10 <= node.snapshot_node.bounds.height <= 50
):
# Check if this small element has interactive properties
if node.attributes:
# Small elements with these attributes are likely interactive icons
icon_attributes = {'class', 'role', 'onclick', 'data-action', 'aria-label'}
if any(attr in node.attributes for attr in icon_attributes):
return True
# Final fallback: cursor style indicates interactivity (for cases Chrome missed)
if node.snapshot_node and node.snapshot_node.cursor_style and node.snapshot_node.cursor_style == 'pointer':
return True
return False
@@ -0,0 +1,197 @@
from collections import defaultdict
from dataclasses import dataclass
from browser_use.dom.views import SimplifiedNode
"""
Helper class for maintaining a union of rectangles (used for order of elements calculation)
"""
@dataclass(frozen=True, slots=True)
class Rect:
"""Closed axis-aligned rectangle with (x1,y1) bottom-left, (x2,y2) top-right."""
x1: float
y1: float
x2: float
y2: float
def __post_init__(self):
if not (self.x1 <= self.x2 and self.y1 <= self.y2):
return False
# --- fast relations ----------------------------------------------------
def area(self) -> float:
return (self.x2 - self.x1) * (self.y2 - self.y1)
def intersects(self, other: 'Rect') -> bool:
return not (self.x2 <= other.x1 or other.x2 <= self.x1 or self.y2 <= other.y1 or other.y2 <= self.y1)
def contains(self, other: 'Rect') -> bool:
return self.x1 <= other.x1 and self.y1 <= other.y1 and self.x2 >= other.x2 and self.y2 >= other.y2
class RectUnionPure:
"""
Maintains a *disjoint* set of rectangles.
No external dependencies - fine for a few thousand rectangles.
"""
__slots__ = ('_rects',)
def __init__(self):
self._rects: list[Rect] = []
# -----------------------------------------------------------------
def _split_diff(self, a: Rect, b: Rect) -> list[Rect]:
r"""
Return list of up to 4 rectangles = a \ b.
Assumes a intersects b.
"""
parts = []
# Bottom slice
if a.y1 < b.y1:
parts.append(Rect(a.x1, a.y1, a.x2, b.y1))
# Top slice
if b.y2 < a.y2:
parts.append(Rect(a.x1, b.y2, a.x2, a.y2))
# Middle (vertical) strip: y overlap is [max(a.y1,b.y1), min(a.y2,b.y2)]
y_lo = max(a.y1, b.y1)
y_hi = min(a.y2, b.y2)
# Left slice
if a.x1 < b.x1:
parts.append(Rect(a.x1, y_lo, b.x1, y_hi))
# Right slice
if b.x2 < a.x2:
parts.append(Rect(b.x2, y_lo, a.x2, y_hi))
return parts
# -----------------------------------------------------------------
def contains(self, r: Rect) -> bool:
"""
True iff r is fully covered by the current union.
"""
if not self._rects:
return False
stack = [r]
for s in self._rects:
new_stack = []
for piece in stack:
if s.contains(piece):
# piece completely gone
continue
if piece.intersects(s):
new_stack.extend(self._split_diff(piece, s))
else:
new_stack.append(piece)
if not new_stack: # everything eaten covered
return True
stack = new_stack
return False # something survived
# -----------------------------------------------------------------
def add(self, r: Rect) -> bool:
"""
Insert r unless it is already covered.
Returns True if the union grew.
"""
if self.contains(r):
return False
pending = [r]
i = 0
while i < len(self._rects):
s = self._rects[i]
new_pending = []
changed = False
for piece in pending:
if piece.intersects(s):
new_pending.extend(self._split_diff(piece, s))
changed = True
else:
new_pending.append(piece)
pending = new_pending
if changed:
# s unchanged; proceed with next existing rectangle
i += 1
else:
i += 1
# Any leftover pieces are new, nonoverlapping areas
self._rects.extend(pending)
return True
class PaintOrderRemover:
"""
Calculates which elements should be removed based on the paint order parameter.
"""
def __init__(self, root: SimplifiedNode):
self.root = root
def calculate_paint_order(self) -> None:
all_simplified_nodes_with_paint_order: list[SimplifiedNode] = []
def collect_paint_order(node: SimplifiedNode) -> None:
if (
node.original_node.snapshot_node
and node.original_node.snapshot_node.paint_order is not None
and node.original_node.snapshot_node.bounds is not None
):
all_simplified_nodes_with_paint_order.append(node)
for child in node.children:
collect_paint_order(child)
collect_paint_order(self.root)
grouped_by_paint_order: defaultdict[int, list[SimplifiedNode]] = defaultdict(list)
for node in all_simplified_nodes_with_paint_order:
if node.original_node.snapshot_node and node.original_node.snapshot_node.paint_order is not None:
grouped_by_paint_order[node.original_node.snapshot_node.paint_order].append(node)
rect_union = RectUnionPure()
for paint_order, nodes in sorted(grouped_by_paint_order.items(), key=lambda x: -x[0]):
rects_to_add = []
for node in nodes:
if not node.original_node.snapshot_node or not node.original_node.snapshot_node.bounds:
continue # shouldn't happen by how we filter them out in the first place
rect = Rect(
x1=node.original_node.snapshot_node.bounds.x,
y1=node.original_node.snapshot_node.bounds.y,
x2=node.original_node.snapshot_node.bounds.x + node.original_node.snapshot_node.bounds.width,
y2=node.original_node.snapshot_node.bounds.y + node.original_node.snapshot_node.bounds.height,
)
if rect_union.contains(rect):
node.ignored_by_paint_order = True
# don't add to the nodes if opacity is less then 0.95 or background-color is transparent
if (
node.original_node.snapshot_node.computed_styles
and node.original_node.snapshot_node.computed_styles.get('background-color', 'rgba(0, 0, 0, 0)')
== 'rgba(0, 0, 0, 0)'
) or (
node.original_node.snapshot_node.computed_styles
and float(node.original_node.snapshot_node.computed_styles.get('opacity', '1'))
< 0.8 # this is highly vibes based number
):
continue
rects_to_add.append(rect)
for rect in rects_to_add:
rect_union.add(rect)
return None
@@ -0,0 +1,954 @@
# @file purpose: Serializes enhanced DOM trees to string format for LLM consumption
from typing import Any
from browser_use.dom.serializer.clickable_elements import ClickableElementDetector
from browser_use.dom.serializer.paint_order import PaintOrderRemover
from browser_use.dom.utils import cap_text_length
from browser_use.dom.views import (
DOMRect,
DOMSelectorMap,
EnhancedDOMTreeNode,
NodeType,
PropagatingBounds,
SerializedDOMState,
SimplifiedNode,
)
DISABLED_ELEMENTS = {'style', 'script', 'head', 'meta', 'link', 'title'}
class DOMTreeSerializer:
"""Serializes enhanced DOM trees to string format."""
# Configuration - elements that propagate bounds to their children
PROPAGATING_ELEMENTS = [
{'tag': 'a', 'role': None}, # Any <a> tag
{'tag': 'button', 'role': None}, # Any <button> tag
{'tag': 'div', 'role': 'button'}, # <div role="button">
{'tag': 'div', 'role': 'combobox'}, # <div role="combobox"> - dropdowns/selects
{'tag': 'span', 'role': 'button'}, # <span role="button">
{'tag': 'span', 'role': 'combobox'}, # <span role="combobox">
{'tag': 'input', 'role': 'combobox'}, # <input role="combobox"> - autocomplete inputs
{'tag': 'input', 'role': 'combobox'}, # <input type="text"> - text inputs with suggestions
# {'tag': 'div', 'role': 'link'}, # <div role="link">
# {'tag': 'span', 'role': 'link'}, # <span role="link">
]
DEFAULT_CONTAINMENT_THRESHOLD = 0.99 # 99% containment by default
def __init__(
self,
root_node: EnhancedDOMTreeNode,
previous_cached_state: SerializedDOMState | None = None,
enable_bbox_filtering: bool = True,
containment_threshold: float | None = None,
paint_order_filtering: bool = True,
):
self.root_node = root_node
self._interactive_counter = 1
self._selector_map: DOMSelectorMap = {}
self._previous_cached_selector_map = previous_cached_state.selector_map if previous_cached_state else None
# Add timing tracking
self.timing_info: dict[str, float] = {}
# Cache for clickable element detection to avoid redundant calls
self._clickable_cache: dict[int, bool] = {}
# Bounding box filtering configuration
self.enable_bbox_filtering = enable_bbox_filtering
self.containment_threshold = containment_threshold or self.DEFAULT_CONTAINMENT_THRESHOLD
# Paint order filtering configuration
self.paint_order_filtering = paint_order_filtering
def _safe_parse_number(self, value_str: str, default: float) -> float:
"""Parse string to float, handling negatives and decimals."""
try:
return float(value_str)
except (ValueError, TypeError):
return default
def _safe_parse_optional_number(self, value_str: str | None) -> float | None:
"""Parse string to float, returning None for invalid values."""
if not value_str:
return None
try:
return float(value_str)
except (ValueError, TypeError):
return None
def serialize_accessible_elements(self) -> tuple[SerializedDOMState, dict[str, float]]:
import time
start_total = time.time()
# Reset state
self._interactive_counter = 1
self._selector_map = {}
self._semantic_groups = []
self._clickable_cache = {} # Clear cache for new serialization
# Step 1: Create simplified tree (includes clickable element detection)
start_step1 = time.time()
simplified_tree = self._create_simplified_tree(self.root_node)
end_step1 = time.time()
self.timing_info['create_simplified_tree'] = end_step1 - start_step1
# Step 2: Remove elements based on paint order
start_step3 = time.time()
if self.paint_order_filtering and simplified_tree:
PaintOrderRemover(simplified_tree).calculate_paint_order()
end_step3 = time.time()
self.timing_info['calculate_paint_order'] = end_step3 - start_step3
# Step 3: Optimize tree (remove unnecessary parents)
start_step2 = time.time()
optimized_tree = self._optimize_tree(simplified_tree)
end_step2 = time.time()
self.timing_info['optimize_tree'] = end_step2 - start_step2
# Step 3: Apply bounding box filtering (NEW)
if self.enable_bbox_filtering and optimized_tree:
start_step3 = time.time()
filtered_tree = self._apply_bounding_box_filtering(optimized_tree)
end_step3 = time.time()
self.timing_info['bbox_filtering'] = end_step3 - start_step3
else:
filtered_tree = optimized_tree
# Step 4: Assign interactive indices to clickable elements
start_step4 = time.time()
self._assign_interactive_indices_and_mark_new_nodes(filtered_tree)
end_step4 = time.time()
self.timing_info['assign_interactive_indices'] = end_step4 - start_step4
end_total = time.time()
self.timing_info['serialize_accessible_elements_total'] = end_total - start_total
return SerializedDOMState(_root=filtered_tree, selector_map=self._selector_map), self.timing_info
def _add_compound_components(self, simplified: SimplifiedNode, node: EnhancedDOMTreeNode) -> None:
"""Enhance compound controls with information from their child components."""
# Only process elements that might have compound components
if node.tag_name not in ['input', 'select', 'details', 'audio', 'video']:
return
# For input elements, check for compound input types
if node.tag_name == 'input':
if not node.attributes or node.attributes.get('type') not in [
'date',
'time',
'datetime-local',
'month',
'week',
'range',
'number',
'color',
'file',
]:
return
# For other elements, check if they have AX child indicators
elif not node.ax_node or not node.ax_node.child_ids:
return
# Add compound component information based on element type
element_type = node.tag_name
input_type = node.attributes.get('type', '') if node.attributes else ''
if element_type == 'input':
if input_type == 'date':
node._compound_children.extend(
[
{'role': 'spinbutton', 'name': 'Day', 'valuemin': 1, 'valuemax': 31, 'valuenow': None},
{'role': 'spinbutton', 'name': 'Month', 'valuemin': 1, 'valuemax': 12, 'valuenow': None},
{'role': 'spinbutton', 'name': 'Year', 'valuemin': 1, 'valuemax': 275760, 'valuenow': None},
]
)
simplified.is_compound_component = True
elif input_type == 'time':
node._compound_children.extend(
[
{'role': 'spinbutton', 'name': 'Hour', 'valuemin': 0, 'valuemax': 23, 'valuenow': None},
{'role': 'spinbutton', 'name': 'Minute', 'valuemin': 0, 'valuemax': 59, 'valuenow': None},
]
)
simplified.is_compound_component = True
elif input_type == 'datetime-local':
node._compound_children.extend(
[
{'role': 'spinbutton', 'name': 'Day', 'valuemin': 1, 'valuemax': 31, 'valuenow': None},
{'role': 'spinbutton', 'name': 'Month', 'valuemin': 1, 'valuemax': 12, 'valuenow': None},
{'role': 'spinbutton', 'name': 'Year', 'valuemin': 1, 'valuemax': 275760, 'valuenow': None},
{'role': 'spinbutton', 'name': 'Hour', 'valuemin': 0, 'valuemax': 23, 'valuenow': None},
{'role': 'spinbutton', 'name': 'Minute', 'valuemin': 0, 'valuemax': 59, 'valuenow': None},
]
)
simplified.is_compound_component = True
elif input_type == 'month':
node._compound_children.extend(
[
{'role': 'spinbutton', 'name': 'Month', 'valuemin': 1, 'valuemax': 12, 'valuenow': None},
{'role': 'spinbutton', 'name': 'Year', 'valuemin': 1, 'valuemax': 275760, 'valuenow': None},
]
)
simplified.is_compound_component = True
elif input_type == 'week':
node._compound_children.extend(
[
{'role': 'spinbutton', 'name': 'Week', 'valuemin': 1, 'valuemax': 53, 'valuenow': None},
{'role': 'spinbutton', 'name': 'Year', 'valuemin': 1, 'valuemax': 275760, 'valuenow': None},
]
)
simplified.is_compound_component = True
elif input_type == 'range':
# Range slider with value indicator
min_val = node.attributes.get('min', '0') if node.attributes else '0'
max_val = node.attributes.get('max', '100') if node.attributes else '100'
node._compound_children.append(
{
'role': 'slider',
'name': 'Value',
'valuemin': self._safe_parse_number(min_val, 0.0),
'valuemax': self._safe_parse_number(max_val, 100.0),
'valuenow': None,
}
)
simplified.is_compound_component = True
elif input_type == 'number':
# Number input with increment/decrement buttons
min_val = node.attributes.get('min') if node.attributes else None
max_val = node.attributes.get('max') if node.attributes else None
node._compound_children.extend(
[
{'role': 'button', 'name': 'Increment', 'valuemin': None, 'valuemax': None, 'valuenow': None},
{'role': 'button', 'name': 'Decrement', 'valuemin': None, 'valuemax': None, 'valuenow': None},
{
'role': 'textbox',
'name': 'Value',
'valuemin': self._safe_parse_optional_number(min_val),
'valuemax': self._safe_parse_optional_number(max_val),
'valuenow': None,
},
]
)
simplified.is_compound_component = True
elif input_type == 'color':
# Color picker with components
node._compound_children.extend(
[
{'role': 'textbox', 'name': 'Hex Value', 'valuemin': None, 'valuemax': None, 'valuenow': None},
{'role': 'button', 'name': 'Color Picker', 'valuemin': None, 'valuemax': None, 'valuenow': None},
]
)
simplified.is_compound_component = True
elif input_type == 'file':
# File input with browse button
multiple = 'multiple' in node.attributes if node.attributes else False
node._compound_children.extend(
[
{'role': 'button', 'name': 'Browse Files', 'valuemin': None, 'valuemax': None, 'valuenow': None},
{
'role': 'textbox',
'name': f'{"Files" if multiple else "File"} Selected',
'valuemin': None,
'valuemax': None,
'valuenow': None,
},
]
)
simplified.is_compound_component = True
elif element_type == 'select':
# Select dropdown with option list and detailed option information
base_components = [
{'role': 'button', 'name': 'Dropdown Toggle', 'valuemin': None, 'valuemax': None, 'valuenow': None}
]
# Extract option information from child nodes
options_info = self._extract_select_options(node)
if options_info:
options_component = {
'role': 'listbox',
'name': 'Options',
'valuemin': None,
'valuemax': None,
'valuenow': None,
'options_count': options_info['count'],
'first_options': options_info['first_options'],
}
if options_info['format_hint']:
options_component['format_hint'] = options_info['format_hint']
base_components.append(options_component)
else:
base_components.append(
{'role': 'listbox', 'name': 'Options', 'valuemin': None, 'valuemax': None, 'valuenow': None}
)
node._compound_children.extend(base_components)
simplified.is_compound_component = True
elif element_type == 'details':
# Details/summary disclosure widget
node._compound_children.extend(
[
{'role': 'button', 'name': 'Toggle Disclosure', 'valuemin': None, 'valuemax': None, 'valuenow': None},
{'role': 'region', 'name': 'Content Area', 'valuemin': None, 'valuemax': None, 'valuenow': None},
]
)
simplified.is_compound_component = True
elif element_type == 'audio':
# Audio player controls
node._compound_children.extend(
[
{'role': 'button', 'name': 'Play/Pause', 'valuemin': None, 'valuemax': None, 'valuenow': None},
{'role': 'slider', 'name': 'Progress', 'valuemin': 0, 'valuemax': 100, 'valuenow': None},
{'role': 'button', 'name': 'Mute', 'valuemin': None, 'valuemax': None, 'valuenow': None},
{'role': 'slider', 'name': 'Volume', 'valuemin': 0, 'valuemax': 100, 'valuenow': None},
]
)
simplified.is_compound_component = True
elif element_type == 'video':
# Video player controls
node._compound_children.extend(
[
{'role': 'button', 'name': 'Play/Pause', 'valuemin': None, 'valuemax': None, 'valuenow': None},
{'role': 'slider', 'name': 'Progress', 'valuemin': 0, 'valuemax': 100, 'valuenow': None},
{'role': 'button', 'name': 'Mute', 'valuemin': None, 'valuemax': None, 'valuenow': None},
{'role': 'slider', 'name': 'Volume', 'valuemin': 0, 'valuemax': 100, 'valuenow': None},
{'role': 'button', 'name': 'Fullscreen', 'valuemin': None, 'valuemax': None, 'valuenow': None},
]
)
simplified.is_compound_component = True
def _extract_select_options(self, select_node: EnhancedDOMTreeNode) -> dict[str, Any] | None:
"""Extract option information from a select element."""
if not select_node.children:
return None
options = []
option_values = []
def extract_options_recursive(node: EnhancedDOMTreeNode) -> None:
"""Recursively extract option elements, including from optgroups."""
if node.tag_name.lower() == 'option':
# Extract option text and value
option_text = ''
option_value = ''
# Get value attribute if present
if node.attributes and 'value' in node.attributes:
option_value = str(node.attributes['value']).strip()
# Get text content from direct child text nodes only to avoid duplication
def get_direct_text_content(n: EnhancedDOMTreeNode) -> str:
text = ''
for child in n.children:
if child.node_type == NodeType.TEXT_NODE and child.node_value:
text += child.node_value.strip() + ' '
return text.strip()
option_text = get_direct_text_content(node)
# Use text as value if no explicit value
if not option_value and option_text:
option_value = option_text
if option_text or option_value:
options.append({'text': option_text, 'value': option_value})
option_values.append(option_value)
elif node.tag_name.lower() == 'optgroup':
# Process optgroup children
for child in node.children:
extract_options_recursive(child)
else:
# Process other children that might contain options
for child in node.children:
extract_options_recursive(child)
# Extract all options from select children
for child in select_node.children:
extract_options_recursive(child)
if not options:
return None
# Prepare first 4 options for display
first_options = []
for option in options[:4]:
if option['text'] and option['value'] and option['text'] != option['value']:
# Limit individual option text to avoid overly long attributes
text = option['text'][:20] + ('...' if len(option['text']) > 20 else '')
value = option['value'][:10] + ('...' if len(option['value']) > 10 else '')
first_options.append(f'{text} ({value})')
elif option['text']:
text = option['text'][:25] + ('...' if len(option['text']) > 25 else '')
first_options.append(text)
elif option['value']:
value = option['value'][:25] + ('...' if len(option['value']) > 25 else '')
first_options.append(value)
# Try to infer format hint from option values
format_hint = None
if len(option_values) >= 2:
# Check for common patterns
if all(val.isdigit() for val in option_values[:5] if val):
format_hint = 'numeric'
elif all(len(val) == 2 and val.isupper() for val in option_values[:5] if val):
format_hint = 'country/state codes'
elif all('/' in val or '-' in val for val in option_values[:5] if val):
format_hint = 'date/path format'
elif any('@' in val for val in option_values[:5] if val):
format_hint = 'email addresses'
return {'count': len(options), 'first_options': first_options, 'format_hint': format_hint}
def _is_interactive_cached(self, node: EnhancedDOMTreeNode) -> bool:
"""Cached version of clickable element detection to avoid redundant calls."""
if node.node_id not in self._clickable_cache:
import time
start_time = time.time()
result = ClickableElementDetector.is_interactive(node)
end_time = time.time()
if 'clickable_detection_time' not in self.timing_info:
self.timing_info['clickable_detection_time'] = 0
self.timing_info['clickable_detection_time'] += end_time - start_time
self._clickable_cache[node.node_id] = result
return self._clickable_cache[node.node_id]
def _create_simplified_tree(self, node: EnhancedDOMTreeNode, depth: int = 0) -> SimplifiedNode | None:
"""Step 1: Create a simplified tree with enhanced element detection."""
if node.node_type == NodeType.DOCUMENT_NODE:
# for all cldren including shadow roots
for child in node.children_and_shadow_roots:
simplified_child = self._create_simplified_tree(child, depth + 1)
if simplified_child:
return simplified_child
return None
if node.node_type == NodeType.DOCUMENT_FRAGMENT_NODE:
# ENHANCED shadow DOM processing - always include shadow content
simplified = SimplifiedNode(original_node=node, children=[])
for child in node.children_and_shadow_roots:
simplified_child = self._create_simplified_tree(child, depth + 1)
if simplified_child:
simplified.children.append(simplified_child)
# Always return shadow DOM fragments, even if children seem empty
# Shadow DOM often contains the actual interactive content in SPAs
return simplified if simplified.children else SimplifiedNode(original_node=node, children=[])
elif node.node_type == NodeType.ELEMENT_NODE:
# Skip non-content elements
if node.node_name.lower() in DISABLED_ELEMENTS:
return None
if node.node_name == 'IFRAME' or node.node_name == 'FRAME':
if node.content_document:
simplified = SimplifiedNode(original_node=node, children=[])
for child in node.content_document.children_nodes or []:
simplified_child = self._create_simplified_tree(child, depth + 1)
if simplified_child is not None:
simplified.children.append(simplified_child)
return simplified
is_visible = node.is_visible
is_scrollable = node.is_actually_scrollable
has_shadow_content = bool(node.children_and_shadow_roots)
# ENHANCED SHADOW DOM DETECTION: Include shadow hosts even if not visible
is_shadow_host = any(child.node_type == NodeType.DOCUMENT_FRAGMENT_NODE for child in node.children_and_shadow_roots)
# Override visibility for elements with validation attributes
if not is_visible and node.attributes:
has_validation_attrs = any(attr.startswith(('aria-', 'pseudo')) for attr in node.attributes.keys())
if has_validation_attrs:
is_visible = True # Force visibility for validation elements
# Include if visible, scrollable, has children, or is shadow host
if is_visible or is_scrollable or has_shadow_content or is_shadow_host:
simplified = SimplifiedNode(original_node=node, children=[], is_shadow_host=is_shadow_host)
# Process ALL children including shadow roots with enhanced logging
for child in node.children_and_shadow_roots:
simplified_child = self._create_simplified_tree(child, depth + 1)
if simplified_child:
simplified.children.append(simplified_child)
# COMPOUND CONTROL PROCESSING: Add virtual components for compound controls
self._add_compound_components(simplified, node)
# SHADOW DOM SPECIAL CASE: Always include shadow hosts even if not visible
# Many SPA frameworks (React, Vue) render content in shadow DOM
if is_shadow_host and simplified.children:
return simplified
# Return if meaningful or has meaningful children
if is_visible or is_scrollable or simplified.children:
return simplified
elif node.node_type == NodeType.TEXT_NODE:
# Include meaningful text nodes
is_visible = node.snapshot_node and node.is_visible
if is_visible and node.node_value and node.node_value.strip() and len(node.node_value.strip()) > 1:
return SimplifiedNode(original_node=node, children=[])
return None
def _optimize_tree(self, node: SimplifiedNode | None) -> SimplifiedNode | None:
"""Step 2: Optimize tree structure."""
if not node:
return None
# Process children
optimized_children = []
for child in node.children:
optimized_child = self._optimize_tree(child)
if optimized_child:
optimized_children.append(optimized_child)
node.children = optimized_children
# Keep meaningful nodes
is_visible = node.original_node.snapshot_node and node.original_node.is_visible
if (
is_visible # Keep all visible nodes
or node.original_node.is_actually_scrollable
or node.original_node.node_type == NodeType.TEXT_NODE
or node.children
):
return node
return None
def _collect_interactive_elements(self, node: SimplifiedNode, elements: list[SimplifiedNode]) -> None:
"""Recursively collect interactive elements that are also visible."""
is_interactive = self._is_interactive_cached(node.original_node)
is_visible = node.original_node.snapshot_node and node.original_node.is_visible
# Only collect elements that are both interactive AND visible
if is_interactive and is_visible:
elements.append(node)
for child in node.children:
self._collect_interactive_elements(child, elements)
def _assign_interactive_indices_and_mark_new_nodes(self, node: SimplifiedNode | None) -> None:
"""Assign interactive indices to clickable elements that are also visible."""
if not node:
return
# Skip assigning index to excluded nodes, or ignored by paint order
if not node.excluded_by_parent and not node.ignored_by_paint_order:
# Regular interactive element assignment (including enhanced compound controls)
is_interactive_assign = self._is_interactive_cached(node.original_node)
is_visible = node.original_node.snapshot_node and node.original_node.is_visible
# Only add to selector map if element is both interactive AND visible
if is_interactive_assign and is_visible:
node.interactive_index = self._interactive_counter
node.original_node.element_index = self._interactive_counter
self._selector_map[self._interactive_counter] = node.original_node
self._interactive_counter += 1
# Mark compound components as new for visibility
if node.is_compound_component:
node.is_new = True
elif self._previous_cached_selector_map:
# Check if node is new for regular elements
previous_backend_node_ids = {node.backend_node_id for node in self._previous_cached_selector_map.values()}
if node.original_node.backend_node_id not in previous_backend_node_ids:
node.is_new = True
# Process children
for child in node.children:
self._assign_interactive_indices_and_mark_new_nodes(child)
def _apply_bounding_box_filtering(self, node: SimplifiedNode | None) -> SimplifiedNode | None:
"""Filter children contained within propagating parent bounds."""
if not node:
return None
# Start with no active bounds
self._filter_tree_recursive(node, active_bounds=None, depth=0)
# Log statistics
excluded_count = self._count_excluded_nodes(node)
if excluded_count > 0:
import logging
logging.debug(f'BBox filtering excluded {excluded_count} nodes')
return node
def _filter_tree_recursive(self, node: SimplifiedNode, active_bounds: PropagatingBounds | None = None, depth: int = 0):
"""
Recursively filter tree with bounding box propagation.
Bounds propagate to ALL descendants until overridden.
"""
# Check if this node should be excluded by active bounds
if active_bounds and self._should_exclude_child(node, active_bounds):
node.excluded_by_parent = True
# Important: Still check if this node starts NEW propagation
# Check if this node starts new propagation (even if excluded!)
new_bounds = None
tag = node.original_node.tag_name.lower()
role = node.original_node.attributes.get('role') if node.original_node.attributes else None
attributes = {
'tag': tag,
'role': role,
}
# Check if this element matches any propagating element pattern
if self._is_propagating_element(attributes):
# This node propagates bounds to ALL its descendants
if node.original_node.snapshot_node and node.original_node.snapshot_node.bounds:
new_bounds = PropagatingBounds(
tag=tag,
bounds=node.original_node.snapshot_node.bounds,
node_id=node.original_node.node_id,
depth=depth,
)
# Propagate to ALL children
# Use new_bounds if this node starts propagation, otherwise continue with active_bounds
propagate_bounds = new_bounds if new_bounds else active_bounds
for child in node.children:
self._filter_tree_recursive(child, propagate_bounds, depth + 1)
def _should_exclude_child(self, node: SimplifiedNode, active_bounds: PropagatingBounds) -> bool:
"""
Determine if child should be excluded based on propagating bounds.
"""
# Never exclude text nodes - we always want to preserve text content
if node.original_node.node_type == NodeType.TEXT_NODE:
return False
# Get child bounds
if not node.original_node.snapshot_node or not node.original_node.snapshot_node.bounds:
return False # No bounds = can't determine containment
child_bounds = node.original_node.snapshot_node.bounds
# Check containment with configured threshold
if not self._is_contained(child_bounds, active_bounds.bounds, self.containment_threshold):
return False # Not sufficiently contained
# EXCEPTION RULES - Keep these even if contained:
child_tag = node.original_node.tag_name.lower()
child_role = node.original_node.attributes.get('role') if node.original_node.attributes else None
child_attributes = {
'tag': child_tag,
'role': child_role,
}
# 1. Never exclude form elements (they need individual interaction)
if child_tag in ['input', 'select', 'textarea', 'label']:
return False
# 2. Keep if child is also a propagating element
# (might have stopPropagation, e.g., button in button)
if self._is_propagating_element(child_attributes):
return False
# 3. Keep if has explicit onclick handler
if node.original_node.attributes and 'onclick' in node.original_node.attributes:
return False
# 4. Keep if has aria-label suggesting it's independently interactive
if node.original_node.attributes:
aria_label = node.original_node.attributes.get('aria-label')
if aria_label and aria_label.strip():
# Has meaningful aria-label, likely interactive
return False
# 5. Keep if has role suggesting interactivity
if node.original_node.attributes:
role = node.original_node.attributes.get('role')
if role in ['button', 'link', 'checkbox', 'radio', 'tab', 'menuitem']:
return False
# Default: exclude this child
return True
def _is_contained(self, child: DOMRect, parent: DOMRect, threshold: float) -> bool:
"""
Check if child is contained within parent bounds.
Args:
threshold: Percentage (0.0-1.0) of child that must be within parent
"""
# Calculate intersection
x_overlap = max(0, min(child.x + child.width, parent.x + parent.width) - max(child.x, parent.x))
y_overlap = max(0, min(child.y + child.height, parent.y + parent.height) - max(child.y, parent.y))
intersection_area = x_overlap * y_overlap
child_area = child.width * child.height
if child_area == 0:
return False # Zero-area element
containment_ratio = intersection_area / child_area
return containment_ratio >= threshold
def _count_excluded_nodes(self, node: SimplifiedNode, count: int = 0) -> int:
"""Count how many nodes were excluded (for debugging)."""
if hasattr(node, 'excluded_by_parent') and node.excluded_by_parent:
count += 1
for child in node.children:
count = self._count_excluded_nodes(child, count)
return count
def _is_propagating_element(self, attributes: dict[str, str | None]) -> bool:
"""
Check if an element should propagate bounds based on attributes.
If the element satisfies one of the patterns, it propagates bounds to all its children.
"""
keys_to_check = ['tag', 'role']
for pattern in self.PROPAGATING_ELEMENTS:
# Check if the element satisfies the pattern
check = [pattern.get(key) is None or pattern.get(key) == attributes.get(key) for key in keys_to_check]
if all(check):
return True
return False
@staticmethod
def serialize_tree(node: SimplifiedNode | None, include_attributes: list[str], depth: int = 0) -> str:
"""Serialize the optimized tree to string format."""
if not node:
return ''
# Skip rendering excluded nodes, but process their children
if hasattr(node, 'excluded_by_parent') and node.excluded_by_parent:
formatted_text = []
for child in node.children:
child_text = DOMTreeSerializer.serialize_tree(child, include_attributes, depth)
if child_text:
formatted_text.append(child_text)
return '\n'.join(formatted_text)
formatted_text = []
depth_str = depth * '\t'
next_depth = depth
if node.original_node.node_type == NodeType.ELEMENT_NODE:
# Skip displaying nodes marked as should_display=False
if not node.should_display:
for child in node.children:
child_text = DOMTreeSerializer.serialize_tree(child, include_attributes, depth)
if child_text:
formatted_text.append(child_text)
return '\n'.join(formatted_text)
# Add element with interactive_index if clickable, scrollable, or iframe
is_any_scrollable = node.original_node.is_actually_scrollable or node.original_node.is_scrollable
should_show_scroll = node.original_node.should_show_scroll_info
if (
node.interactive_index is not None
or is_any_scrollable
or node.original_node.tag_name.upper() == 'IFRAME'
or node.original_node.tag_name.upper() == 'FRAME'
):
next_depth += 1
# Build attributes string with compound component info
text_content = ''
attributes_html_str = DOMTreeSerializer._build_attributes_string(
node.original_node, include_attributes, text_content
)
# Add compound component information to attributes if present
if node.original_node._compound_children:
compound_info = []
for child_info in node.original_node._compound_children:
parts = []
if child_info['name']:
parts.append(f'name={child_info["name"]}')
if child_info['role']:
parts.append(f'role={child_info["role"]}')
if child_info['valuemin'] is not None:
parts.append(f'min={child_info["valuemin"]}')
if child_info['valuemax'] is not None:
parts.append(f'max={child_info["valuemax"]}')
if child_info['valuenow'] is not None:
parts.append(f'current={child_info["valuenow"]}')
# Add select-specific information
if 'options_count' in child_info and child_info['options_count'] is not None:
parts.append(f'count={child_info["options_count"]}')
if 'first_options' in child_info and child_info['first_options']:
options_str = '|'.join(child_info['first_options'][:4]) # Limit to 4 options
parts.append(f'options={options_str}')
if 'format_hint' in child_info and child_info['format_hint']:
parts.append(f'format={child_info["format_hint"]}')
if parts:
compound_info.append(f'({",".join(parts)})')
if compound_info:
compound_attr = f'compound_components={",".join(compound_info)}'
if attributes_html_str:
attributes_html_str += f' {compound_attr}'
else:
attributes_html_str = compound_attr
# Build the line with shadow host indicator
shadow_prefix = ''
if node.is_shadow_host:
# Check if any shadow children are closed
has_closed_shadow = any(
child.original_node.node_type == NodeType.DOCUMENT_FRAGMENT_NODE
and child.original_node.shadow_root_type
and child.original_node.shadow_root_type.lower() == 'closed'
for child in node.children
)
shadow_prefix = '|SHADOW(closed)|' if has_closed_shadow else '|SHADOW(open)|'
if should_show_scroll and node.interactive_index is None:
# Scrollable container but not clickable
line = f'{depth_str}{shadow_prefix}|SCROLL|<{node.original_node.tag_name}'
elif node.interactive_index is not None:
# Clickable (and possibly scrollable)
new_prefix = '*' if node.is_new else ''
scroll_prefix = '|SCROLL+' if should_show_scroll else '['
line = f'{depth_str}{shadow_prefix}{new_prefix}{scroll_prefix}{node.interactive_index}]<{node.original_node.tag_name}'
elif node.original_node.tag_name.upper() == 'IFRAME':
# Iframe element (not interactive)
line = f'{depth_str}{shadow_prefix}|IFRAME|<{node.original_node.tag_name}'
elif node.original_node.tag_name.upper() == 'FRAME':
# Frame element (not interactive)
line = f'{depth_str}{shadow_prefix}|FRAME|<{node.original_node.tag_name}'
else:
line = f'{depth_str}{shadow_prefix}<{node.original_node.tag_name}'
if attributes_html_str:
line += f' {attributes_html_str}'
line += ' />'
# Add scroll information only when we should show it
if should_show_scroll:
scroll_info_text = node.original_node.get_scroll_info_text()
if scroll_info_text:
line += f' ({scroll_info_text})'
formatted_text.append(line)
elif node.original_node.node_type == NodeType.DOCUMENT_FRAGMENT_NODE:
# Shadow DOM representation - show clearly to LLM
if node.original_node.shadow_root_type and node.original_node.shadow_root_type.lower() == 'closed':
formatted_text.append(f'{depth_str}▼ Shadow Content (Closed)')
else:
formatted_text.append(f'{depth_str}▼ Shadow Content (Open)')
next_depth += 1
# Process shadow DOM children
for child in node.children:
child_text = DOMTreeSerializer.serialize_tree(child, include_attributes, next_depth)
if child_text:
formatted_text.append(child_text)
# Close shadow DOM indicator
if node.children: # Only show close if we had content
formatted_text.append(f'{depth_str}▲ Shadow Content End')
elif node.original_node.node_type == NodeType.TEXT_NODE:
# Include visible text
is_visible = node.original_node.snapshot_node and node.original_node.is_visible
if (
is_visible
and node.original_node.node_value
and node.original_node.node_value.strip()
and len(node.original_node.node_value.strip()) > 1
):
clean_text = node.original_node.node_value.strip()
formatted_text.append(f'{depth_str}{clean_text}')
# Process children (for non-shadow elements)
if node.original_node.node_type != NodeType.DOCUMENT_FRAGMENT_NODE:
for child in node.children:
child_text = DOMTreeSerializer.serialize_tree(child, include_attributes, next_depth)
if child_text:
formatted_text.append(child_text)
return '\n'.join(formatted_text)
@staticmethod
def _build_attributes_string(node: EnhancedDOMTreeNode, include_attributes: list[str], text: str) -> str:
"""Build the attributes string for an element."""
attributes_to_include = {}
# Include HTML attributes
if node.attributes:
attributes_to_include.update(
{
key: str(value).strip()
for key, value in node.attributes.items()
if key in include_attributes and str(value).strip() != ''
}
)
# Include accessibility properties
if node.ax_node and node.ax_node.properties:
for prop in node.ax_node.properties:
try:
if prop.name in include_attributes and prop.value is not None:
# Convert boolean to lowercase string, keep others as-is
if isinstance(prop.value, bool):
attributes_to_include[prop.name] = str(prop.value).lower()
else:
prop_value_str = str(prop.value).strip()
if prop_value_str:
attributes_to_include[prop.name] = prop_value_str
except (AttributeError, ValueError):
continue
if not attributes_to_include:
return ''
# Remove duplicate values
ordered_keys = [key for key in include_attributes if key in attributes_to_include]
if len(ordered_keys) > 1:
keys_to_remove = set()
seen_values = {}
for key in ordered_keys:
value = attributes_to_include[key]
if len(value) > 5:
if value in seen_values:
keys_to_remove.add(key)
else:
seen_values[value] = key
for key in keys_to_remove:
del attributes_to_include[key]
# Remove attributes that duplicate accessibility data
role = node.ax_node.role if node.ax_node else None
if role and node.node_name == role:
attributes_to_include.pop('role', None)
attrs_to_remove_if_text_matches = ['aria-label', 'placeholder', 'title']
for attr in attrs_to_remove_if_text_matches:
if attributes_to_include.get(attr) and attributes_to_include.get(attr, '').strip().lower() == text.strip().lower():
del attributes_to_include[attr]
if attributes_to_include:
return ' '.join(f'{key}={cap_text_length(value, 100)}' for key, value in attributes_to_include.items())
return ''
@@ -0,0 +1,741 @@
import asyncio
import logging
import time
from typing import TYPE_CHECKING
from cdp_use.cdp.accessibility.commands import GetFullAXTreeReturns
from cdp_use.cdp.accessibility.types import AXNode
from cdp_use.cdp.dom.types import Node
from cdp_use.cdp.target import TargetID
from browser_use.dom.enhanced_snapshot import (
REQUIRED_COMPUTED_STYLES,
build_snapshot_lookup,
)
from browser_use.dom.serializer.serializer import DOMTreeSerializer
from browser_use.dom.views import (
CurrentPageTargets,
DOMRect,
EnhancedAXNode,
EnhancedAXProperty,
EnhancedDOMTreeNode,
NodeType,
SerializedDOMState,
TargetAllTrees,
)
from browser_use.observability import observe_debug
if TYPE_CHECKING:
from browser_use.browser.session import BrowserSession
# Note: iframe limits are now configurable via BrowserProfile.max_iframes and BrowserProfile.max_iframe_depth
class DomService:
"""
Service for getting the DOM tree and other DOM-related information.
Either browser or page must be provided.
TODO: currently we start a new websocket connection PER STEP, we should definitely keep this persistent
"""
logger: logging.Logger
def __init__(
self,
browser_session: 'BrowserSession',
logger: logging.Logger | None = None,
cross_origin_iframes: bool = False,
paint_order_filtering: bool = True,
max_iframes: int = 100,
max_iframe_depth: int = 5,
):
self.browser_session = browser_session
self.logger = logger or browser_session.logger
self.cross_origin_iframes = cross_origin_iframes
self.paint_order_filtering = paint_order_filtering
self.max_iframes = max_iframes
self.max_iframe_depth = max_iframe_depth
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc_value, traceback):
pass # no need to cleanup anything, browser_session auto handles cleaning up session cache
async def _get_targets_for_page(self, target_id: TargetID | None = None) -> CurrentPageTargets:
"""Get the target info for a specific page.
Args:
target_id: The target ID to get info for. If None, uses current_target_id.
"""
targets = await self.browser_session.cdp_client.send.Target.getTargets()
# Use provided target_id or fall back to current_target_id
if target_id is None:
target_id = self.browser_session.current_target_id
if not target_id:
raise ValueError('No current target ID set in browser session')
# Find main page target by ID
main_target = next((t for t in targets['targetInfos'] if t['targetId'] == target_id), None)
if not main_target:
raise ValueError(f'No target found for target ID: {target_id}')
# Get all frames using the new method to find iframe targets for this page
all_frames, _ = await self.browser_session.get_all_frames()
# Find iframe targets that are children of this target
iframe_targets = []
for frame_info in all_frames.values():
# Check if this frame is a cross-origin iframe with its own target
if frame_info.get('isCrossOrigin') and frame_info.get('frameTargetId'):
# Check if this frame belongs to our target
parent_target = frame_info.get('parentTargetId', frame_info.get('frameTargetId'))
if parent_target == target_id:
# Find the target info for this iframe
iframe_target = next(
(t for t in targets['targetInfos'] if t['targetId'] == frame_info['frameTargetId']), None
)
if iframe_target:
iframe_targets.append(iframe_target)
return CurrentPageTargets(
page_session=main_target,
iframe_sessions=iframe_targets,
)
def _build_enhanced_ax_node(self, ax_node: AXNode) -> EnhancedAXNode:
properties: list[EnhancedAXProperty] | None = None
if 'properties' in ax_node and ax_node['properties']:
properties = []
for property in ax_node['properties']:
try:
# test whether property name can go into the enum (sometimes Chrome returns some random properties)
properties.append(
EnhancedAXProperty(
name=property['name'],
value=property.get('value', {}).get('value', None),
# related_nodes=[], # TODO: add related nodes
)
)
except ValueError:
pass
enhanced_ax_node = EnhancedAXNode(
ax_node_id=ax_node['nodeId'],
ignored=ax_node['ignored'],
role=ax_node.get('role', {}).get('value', None),
name=ax_node.get('name', {}).get('value', None),
description=ax_node.get('description', {}).get('value', None),
properties=properties,
child_ids=ax_node.get('childIds', []) if ax_node.get('childIds') else None,
)
return enhanced_ax_node
async def _get_viewport_ratio(self, target_id: TargetID) -> float:
"""Get viewport dimensions, device pixel ratio, and scroll position using CDP."""
cdp_session = await self.browser_session.get_or_create_cdp_session(target_id=target_id, focus=True)
try:
# Get the layout metrics which includes the visual viewport
metrics = await cdp_session.cdp_client.send.Page.getLayoutMetrics(session_id=cdp_session.session_id)
visual_viewport = metrics.get('visualViewport', {})
# IMPORTANT: Use CSS viewport instead of device pixel viewport
# This fixes the coordinate mismatch on high-DPI displays
css_visual_viewport = metrics.get('cssVisualViewport', {})
css_layout_viewport = metrics.get('cssLayoutViewport', {})
# Use CSS pixels (what JavaScript sees) instead of device pixels
width = css_visual_viewport.get('clientWidth', css_layout_viewport.get('clientWidth', 1920.0))
# Calculate device pixel ratio
device_width = visual_viewport.get('clientWidth', width)
css_width = css_visual_viewport.get('clientWidth', width)
device_pixel_ratio = device_width / css_width if css_width > 0 else 1.0
return float(device_pixel_ratio)
except Exception as e:
self.logger.debug(f'Viewport size detection failed: {e}')
# Fallback to default viewport size
return 1.0
@classmethod
def is_element_visible_according_to_all_parents(
cls, node: EnhancedDOMTreeNode, html_frames: list[EnhancedDOMTreeNode]
) -> bool:
"""Check if the element is visible according to all its parent HTML frames."""
if not node.snapshot_node:
return False
computed_styles = node.snapshot_node.computed_styles or {}
display = computed_styles.get('display', '').lower()
visibility = computed_styles.get('visibility', '').lower()
opacity = computed_styles.get('opacity', '1')
if display == 'none' or visibility == 'hidden':
return False
try:
if float(opacity) <= 0:
return False
except (ValueError, TypeError):
pass
# Start with the element's local bounds (in its own frame's coordinate system)
current_bounds = node.snapshot_node.bounds
if not current_bounds:
return False # If there are no bounds, the element is not visible
"""
Reverse iterate through the html frames (that can be either iframe or document -> if it's a document frame compare if the current bounds interest with it (taking scroll into account) otherwise move the current bounds by the iframe offset)
"""
for frame in reversed(html_frames):
if (
frame.node_type == NodeType.ELEMENT_NODE
and (frame.node_name.upper() == 'IFRAME' or frame.node_name.upper() == 'FRAME')
and frame.snapshot_node
and frame.snapshot_node.bounds
):
iframe_bounds = frame.snapshot_node.bounds
# negate the values added in `_construct_enhanced_node`
current_bounds.x += iframe_bounds.x
current_bounds.y += iframe_bounds.y
if (
frame.node_type == NodeType.ELEMENT_NODE
and frame.node_name == 'HTML'
and frame.snapshot_node
and frame.snapshot_node.scrollRects
and frame.snapshot_node.clientRects
):
# For iframe content, we need to check visibility within the iframe's viewport
# The scrollRects represent the current scroll position
# The clientRects represent the viewport size
# Elements are visible if they fall within the viewport after accounting for scroll
# The viewport of the frame (what's actually visible)
viewport_left = 0 # Viewport always starts at 0 in frame coordinates
viewport_top = 0
viewport_right = frame.snapshot_node.clientRects.width
viewport_bottom = frame.snapshot_node.clientRects.height
# Adjust element bounds by the scroll offset to get position relative to viewport
# When scrolled down, scrollRects.y is positive, so we subtract it from element's y
adjusted_x = current_bounds.x - frame.snapshot_node.scrollRects.x
adjusted_y = current_bounds.y - frame.snapshot_node.scrollRects.y
frame_intersects = (
adjusted_x < viewport_right
and adjusted_x + current_bounds.width > viewport_left
and adjusted_y < viewport_bottom + 1000
and adjusted_y + current_bounds.height > viewport_top - 1000
)
if not frame_intersects:
return False
# Keep the original coordinate adjustment to maintain consistency
# This adjustment is needed for proper coordinate transformation
current_bounds.x -= frame.snapshot_node.scrollRects.x
current_bounds.y -= frame.snapshot_node.scrollRects.y
# If we reach here, element is visible in main viewport and all containing iframes
return True
async def _get_ax_tree_for_all_frames(self, target_id: TargetID) -> GetFullAXTreeReturns:
"""Recursively collect all frames and merge their accessibility trees into a single array."""
cdp_session = await self.browser_session.get_or_create_cdp_session(target_id=target_id, focus=False)
frame_tree = await cdp_session.cdp_client.send.Page.getFrameTree(session_id=cdp_session.session_id)
def collect_all_frame_ids(frame_tree_node) -> list[str]:
"""Recursively collect all frame IDs from the frame tree."""
frame_ids = [frame_tree_node['frame']['id']]
if 'childFrames' in frame_tree_node and frame_tree_node['childFrames']:
for child_frame in frame_tree_node['childFrames']:
frame_ids.extend(collect_all_frame_ids(child_frame))
return frame_ids
# Collect all frame IDs recursively
all_frame_ids = collect_all_frame_ids(frame_tree['frameTree'])
# Get accessibility tree for each frame
ax_tree_requests = []
for frame_id in all_frame_ids:
ax_tree_request = cdp_session.cdp_client.send.Accessibility.getFullAXTree(
params={'frameId': frame_id}, session_id=cdp_session.session_id
)
ax_tree_requests.append(ax_tree_request)
# Wait for all requests to complete
ax_trees = await asyncio.gather(*ax_tree_requests)
# Merge all AX nodes into a single array
merged_nodes: list[AXNode] = []
for ax_tree in ax_trees:
merged_nodes.extend(ax_tree['nodes'])
return {'nodes': merged_nodes}
async def _get_all_trees(self, target_id: TargetID) -> TargetAllTrees:
cdp_session = await self.browser_session.get_or_create_cdp_session(target_id=target_id, focus=False)
# Wait for the page to be ready first
try:
ready_state = await cdp_session.cdp_client.send.Runtime.evaluate(
params={'expression': 'document.readyState'}, session_id=cdp_session.session_id
)
except Exception as e:
pass # Page might not be ready yet
# DEBUG: Log before capturing snapshot
self.logger.debug(f'🔍 DEBUG: Capturing DOM snapshot for target {target_id}')
# Get actual scroll positions for all iframes before capturing snapshot
iframe_scroll_positions = {}
try:
scroll_result = await cdp_session.cdp_client.send.Runtime.evaluate(
params={
'expression': """
(() => {
const scrollData = {};
const iframes = document.querySelectorAll('iframe');
iframes.forEach((iframe, index) => {
try {
const doc = iframe.contentDocument || iframe.contentWindow.document;
if (doc) {
scrollData[index] = {
scrollTop: doc.documentElement.scrollTop || doc.body.scrollTop || 0,
scrollLeft: doc.documentElement.scrollLeft || doc.body.scrollLeft || 0
};
}
} catch (e) {
// Cross-origin iframe, can't access
}
});
return scrollData;
})()
""",
'returnByValue': True,
},
session_id=cdp_session.session_id,
)
if scroll_result and 'result' in scroll_result and 'value' in scroll_result['result']:
iframe_scroll_positions = scroll_result['result']['value']
for idx, scroll_data in iframe_scroll_positions.items():
self.logger.debug(
f'🔍 DEBUG: Iframe {idx} actual scroll position - scrollTop={scroll_data.get("scrollTop", 0)}, scrollLeft={scroll_data.get("scrollLeft", 0)}'
)
except Exception as e:
self.logger.debug(f'Failed to get iframe scroll positions: {e}')
# Define CDP request factories to avoid duplication
def create_snapshot_request():
return cdp_session.cdp_client.send.DOMSnapshot.captureSnapshot(
params={
'computedStyles': REQUIRED_COMPUTED_STYLES,
'includePaintOrder': True,
'includeDOMRects': True,
'includeBlendedBackgroundColors': False,
'includeTextColorOpacities': False,
},
session_id=cdp_session.session_id,
)
def create_dom_tree_request():
return cdp_session.cdp_client.send.DOM.getDocument(
params={'depth': -1, 'pierce': True}, session_id=cdp_session.session_id
)
start = time.time()
# Create initial tasks
tasks = {
'snapshot': asyncio.create_task(create_snapshot_request()),
'dom_tree': asyncio.create_task(create_dom_tree_request()),
'ax_tree': asyncio.create_task(self._get_ax_tree_for_all_frames(target_id)),
'device_pixel_ratio': asyncio.create_task(self._get_viewport_ratio(target_id)),
}
# Wait for all tasks with timeout
done, pending = await asyncio.wait(tasks.values(), timeout=10.0)
# Retry any failed or timed out tasks
if pending:
for task in pending:
task.cancel()
# Retry mapping for pending tasks
retry_map = {
tasks['snapshot']: lambda: asyncio.create_task(create_snapshot_request()),
tasks['dom_tree']: lambda: asyncio.create_task(create_dom_tree_request()),
tasks['ax_tree']: lambda: asyncio.create_task(self._get_ax_tree_for_all_frames(target_id)),
tasks['device_pixel_ratio']: lambda: asyncio.create_task(self._get_viewport_ratio(target_id)),
}
# Create new tasks only for the ones that didn't complete
for key, task in tasks.items():
if task in pending and task in retry_map:
tasks[key] = retry_map[task]()
# Wait again with shorter timeout
done2, pending2 = await asyncio.wait([t for t in tasks.values() if not t.done()], timeout=2.0)
if pending2:
for task in pending2:
task.cancel()
# Extract results, tracking which ones failed
results = {}
failed = []
for key, task in tasks.items():
if task.done() and not task.cancelled():
try:
results[key] = task.result()
except Exception as e:
self.logger.warning(f'CDP request {key} failed with exception: {e}')
failed.append(key)
else:
self.logger.warning(f'CDP request {key} timed out')
failed.append(key)
# If any required tasks failed, raise an exception
if failed:
raise TimeoutError(f'CDP requests failed or timed out: {", ".join(failed)}')
snapshot = results['snapshot']
dom_tree = results['dom_tree']
ax_tree = results['ax_tree']
device_pixel_ratio = results['device_pixel_ratio']
end = time.time()
cdp_timing = {'cdp_calls_total': end - start}
# DEBUG: Log snapshot info and limit documents to prevent explosion
if snapshot and 'documents' in snapshot:
original_doc_count = len(snapshot['documents'])
# Limit to max_iframes documents to prevent iframe explosion
if original_doc_count > self.max_iframes:
self.logger.warning(
f'⚠️ Limiting processing of {original_doc_count} iframes on page to only first {self.max_iframes} to prevent crashes!'
)
snapshot['documents'] = snapshot['documents'][: self.max_iframes]
total_nodes = sum(len(doc.get('nodes', [])) for doc in snapshot['documents'])
self.logger.debug(f'🔍 DEBUG: Snapshot contains {len(snapshot["documents"])} frames with {total_nodes} total nodes')
# Log iframe-specific info
for doc_idx, doc in enumerate(snapshot['documents']):
if doc_idx > 0: # Not the main document
self.logger.debug(
f'🔍 DEBUG: Iframe #{doc_idx} {doc.get("frameId", "no-frame-id")} {doc.get("url", "no-url")} has {len(doc.get("nodes", []))} nodes'
)
return TargetAllTrees(
snapshot=snapshot,
dom_tree=dom_tree,
ax_tree=ax_tree,
device_pixel_ratio=device_pixel_ratio,
cdp_timing=cdp_timing,
)
@observe_debug(ignore_input=True, ignore_output=True, name='get_dom_tree')
async def get_dom_tree(
self,
target_id: TargetID,
initial_html_frames: list[EnhancedDOMTreeNode] | None = None,
initial_total_frame_offset: DOMRect | None = None,
iframe_depth: int = 0,
) -> EnhancedDOMTreeNode:
"""Get the DOM tree for a specific target.
Args:
target_id: Target ID of the page to get the DOM tree for.
initial_html_frames: List of HTML frame nodes encountered so far
initial_total_frame_offset: Accumulated coordinate offset
iframe_depth: Current depth of iframe nesting to prevent infinite recursion
"""
trees = await self._get_all_trees(target_id)
dom_tree = trees.dom_tree
ax_tree = trees.ax_tree
snapshot = trees.snapshot
device_pixel_ratio = trees.device_pixel_ratio
ax_tree_lookup: dict[int, AXNode] = {
ax_node['backendDOMNodeId']: ax_node for ax_node in ax_tree['nodes'] if 'backendDOMNodeId' in ax_node
}
enhanced_dom_tree_node_lookup: dict[int, EnhancedDOMTreeNode] = {}
""" NodeId (NOT backend node id) -> enhanced dom tree node""" # way to get the parent/content node
# Parse snapshot data with everything calculated upfront
snapshot_lookup = build_snapshot_lookup(snapshot, device_pixel_ratio)
async def _construct_enhanced_node(
node: Node, html_frames: list[EnhancedDOMTreeNode] | None, total_frame_offset: DOMRect | None
) -> EnhancedDOMTreeNode:
"""
Recursively construct enhanced DOM tree nodes.
Args:
node: The DOM node to construct
html_frames: List of HTML frame nodes encountered so far
accumulated_iframe_offset: Accumulated coordinate translation from parent iframes (includes scroll corrections)
"""
# Initialize lists if not provided
if html_frames is None:
html_frames = []
# to get rid of the pointer references
if total_frame_offset is None:
total_frame_offset = DOMRect(x=0.0, y=0.0, width=0.0, height=0.0)
else:
total_frame_offset = DOMRect(
total_frame_offset.x, total_frame_offset.y, total_frame_offset.width, total_frame_offset.height
)
# memoize the mf (I don't know if some nodes are duplicated)
if node['nodeId'] in enhanced_dom_tree_node_lookup:
return enhanced_dom_tree_node_lookup[node['nodeId']]
ax_node = ax_tree_lookup.get(node['backendNodeId'])
if ax_node:
enhanced_ax_node = self._build_enhanced_ax_node(ax_node)
else:
enhanced_ax_node = None
# To make attributes more readable
attributes: dict[str, str] | None = None
if 'attributes' in node and node['attributes']:
attributes = {}
for i in range(0, len(node['attributes']), 2):
attributes[node['attributes'][i]] = node['attributes'][i + 1]
shadow_root_type = None
if 'shadowRootType' in node and node['shadowRootType']:
try:
shadow_root_type = node['shadowRootType']
except ValueError:
pass
# Get snapshot data and calculate absolute position
snapshot_data = snapshot_lookup.get(node['backendNodeId'], None)
absolute_position = None
if snapshot_data and snapshot_data.bounds:
absolute_position = DOMRect(
x=snapshot_data.bounds.x + total_frame_offset.x,
y=snapshot_data.bounds.y + total_frame_offset.y,
width=snapshot_data.bounds.width,
height=snapshot_data.bounds.height,
)
dom_tree_node = EnhancedDOMTreeNode(
node_id=node['nodeId'],
backend_node_id=node['backendNodeId'],
node_type=NodeType(node['nodeType']),
node_name=node['nodeName'],
node_value=node['nodeValue'],
attributes=attributes or {},
is_scrollable=node.get('isScrollable', None),
frame_id=node.get('frameId', None),
session_id=self.browser_session.agent_focus.session_id if self.browser_session.agent_focus else None,
target_id=target_id,
content_document=None,
shadow_root_type=shadow_root_type,
shadow_roots=None,
parent_node=None,
children_nodes=None,
ax_node=enhanced_ax_node,
snapshot_node=snapshot_data,
is_visible=None,
absolute_position=absolute_position,
element_index=None,
)
enhanced_dom_tree_node_lookup[node['nodeId']] = dom_tree_node
if 'parentId' in node and node['parentId']:
dom_tree_node.parent_node = enhanced_dom_tree_node_lookup[
node['parentId']
] # parents should always be in the lookup
# Check if this is an HTML frame node and add it to the list
updated_html_frames = html_frames.copy()
if node['nodeType'] == NodeType.ELEMENT_NODE.value and node['nodeName'] == 'HTML' and node.get('frameId') is not None:
updated_html_frames.append(dom_tree_node)
# and adjust the total frame offset by scroll
if snapshot_data and snapshot_data.scrollRects:
total_frame_offset.x -= snapshot_data.scrollRects.x
total_frame_offset.y -= snapshot_data.scrollRects.y
# DEBUG: Log iframe scroll information
self.logger.debug(
f'🔍 DEBUG: HTML frame scroll - scrollY={snapshot_data.scrollRects.y}, scrollX={snapshot_data.scrollRects.x}, frameId={node.get("frameId")}, nodeId={node["nodeId"]}'
)
# Calculate new iframe offset for content documents, accounting for iframe scroll
if (
(node['nodeName'].upper() == 'IFRAME' or node['nodeName'].upper() == 'FRAME')
and snapshot_data
and snapshot_data.bounds
):
if snapshot_data.bounds:
updated_html_frames.append(dom_tree_node)
total_frame_offset.x += snapshot_data.bounds.x
total_frame_offset.y += snapshot_data.bounds.y
if 'contentDocument' in node and node['contentDocument']:
dom_tree_node.content_document = await _construct_enhanced_node(
node['contentDocument'], updated_html_frames, total_frame_offset
)
dom_tree_node.content_document.parent_node = dom_tree_node
# forcefully set the parent node to the content document node (helps traverse the tree)
if 'shadowRoots' in node and node['shadowRoots']:
dom_tree_node.shadow_roots = []
for shadow_root in node['shadowRoots']:
shadow_root_node = await _construct_enhanced_node(shadow_root, updated_html_frames, total_frame_offset)
# forcefully set the parent node to the shadow root node (helps traverse the tree)
shadow_root_node.parent_node = dom_tree_node
dom_tree_node.shadow_roots.append(shadow_root_node)
if 'children' in node and node['children']:
dom_tree_node.children_nodes = []
for child in node['children']:
dom_tree_node.children_nodes.append(
await _construct_enhanced_node(child, updated_html_frames, total_frame_offset)
)
# Set visibility using the collected HTML frames
dom_tree_node.is_visible = self.is_element_visible_according_to_all_parents(dom_tree_node, updated_html_frames)
# DEBUG: Log visibility info for form elements in iframes
if dom_tree_node.tag_name and dom_tree_node.tag_name.upper() in ['INPUT', 'SELECT', 'TEXTAREA', 'LABEL']:
attrs = dom_tree_node.attributes or {}
elem_id = attrs.get('id', '')
elem_name = attrs.get('name', '')
if (
'city' in elem_id.lower()
or 'city' in elem_name.lower()
or 'state' in elem_id.lower()
or 'state' in elem_name.lower()
or 'zip' in elem_id.lower()
or 'zip' in elem_name.lower()
):
self.logger.debug(
f"🔍 DEBUG: Form element {dom_tree_node.tag_name} id='{elem_id}' name='{elem_name}' - visible={dom_tree_node.is_visible}, bounds={dom_tree_node.snapshot_node.bounds if dom_tree_node.snapshot_node else 'NO_SNAPSHOT'}"
)
# handle cross origin iframe (just recursively call the main function with the proper target if it exists in iframes)
# only do this if the iframe is visible (otherwise it's not worth it)
if (
# TODO: hacky way to disable cross origin iframes for now
self.cross_origin_iframes and node['nodeName'].upper() == 'IFRAME' and node.get('contentDocument', None) is None
): # None meaning there is no content
# Check iframe depth to prevent infinite recursion
if iframe_depth >= self.max_iframe_depth:
self.logger.debug(
f'Skipping iframe at depth {iframe_depth} to prevent infinite recursion (max depth: {self.max_iframe_depth})'
)
else:
# Check if iframe is visible and large enough (>= 200px in both dimensions)
should_process_iframe = False
# First check if the iframe element itself is visible
if dom_tree_node.is_visible:
# Check iframe dimensions
if dom_tree_node.snapshot_node and dom_tree_node.snapshot_node.bounds:
bounds = dom_tree_node.snapshot_node.bounds
width = bounds.width
height = bounds.height
# Only process if iframe is at least 200px in both dimensions
if width >= 200 and height >= 200:
should_process_iframe = True
self.logger.debug(f'Processing cross-origin iframe: visible=True, width={width}, height={height}')
else:
self.logger.debug(
f'Skipping small cross-origin iframe: width={width}, height={height} (needs >= 200px)'
)
else:
self.logger.debug('Skipping cross-origin iframe: no bounds available')
else:
self.logger.debug('Skipping invisible cross-origin iframe')
if should_process_iframe:
# Use get_all_frames to find the iframe's target
frame_id = node.get('frameId', None)
if frame_id:
all_frames, _ = await self.browser_session.get_all_frames()
frame_info = all_frames.get(frame_id)
iframe_document_target = None
if frame_info and frame_info.get('frameTargetId'):
# Get the target info for this iframe
targets = await self.browser_session.cdp_client.send.Target.getTargets()
iframe_document_target = next(
(t for t in targets['targetInfos'] if t['targetId'] == frame_info['frameTargetId']), None
)
else:
iframe_document_target = None
# if target actually exists in one of the frames, just recursively build the dom tree for it
if iframe_document_target:
self.logger.debug(
f'Getting content document for iframe {node.get("frameId", None)} at depth {iframe_depth + 1}'
)
content_document = await self.get_dom_tree(
target_id=iframe_document_target.get('targetId'),
# TODO: experiment with this values -> not sure whether the whole cross origin iframe should be ALWAYS included as soon as some part of it is visible or not.
# Current config: if the cross origin iframe is AT ALL visible, then just include everything inside of it!
# initial_html_frames=updated_html_frames,
initial_total_frame_offset=total_frame_offset,
iframe_depth=iframe_depth + 1,
)
dom_tree_node.content_document = content_document
dom_tree_node.content_document.parent_node = dom_tree_node
return dom_tree_node
enhanced_dom_tree_node = await _construct_enhanced_node(dom_tree['root'], initial_html_frames, initial_total_frame_offset)
return enhanced_dom_tree_node
@observe_debug(ignore_input=True, ignore_output=True, name='get_serialized_dom_tree')
async def get_serialized_dom_tree(
self, previous_cached_state: SerializedDOMState | None = None
) -> tuple[SerializedDOMState, EnhancedDOMTreeNode, dict[str, float]]:
"""Get the serialized DOM tree representation for LLM consumption.
Returns:
Tuple of (serialized_dom_state, enhanced_dom_tree_root, timing_info)
"""
# Use current target (None means use current)
assert self.browser_session.current_target_id is not None
enhanced_dom_tree = await self.get_dom_tree(target_id=self.browser_session.current_target_id)
start = time.time()
serialized_dom_state, serializer_timing = DOMTreeSerializer(
enhanced_dom_tree, previous_cached_state, paint_order_filtering=self.paint_order_filtering
).serialize_accessible_elements()
end = time.time()
serialize_total_timing = {'serialize_dom_tree_total': end - start}
# Combine all timing info
all_timing = {**serializer_timing, **serialize_total_timing}
return serialized_dom_state, enhanced_dom_tree, all_timing
@@ -0,0 +1,38 @@
"""
Test suite locking out TypeError in _build_dom_tree
when layout dictionary contains None values for array properties.
"""
import os
import sys
sys.path.insert(0, os.path.abspath(os.path.dirname(__file__)))
def test_layout_index_map_handles_null_layout_arrays():
"""
Ensure layout array checks tolerate None for bounds, styles, paintOrders, etc.
"""
layout = {
'bounds': None,
'styles': None,
'paintOrders': None,
'clientRects': None,
'scrollRects': None,
'stackingContexts': None
}
layout_idx = 0
bounds_len = len(layout.get('bounds') or [])
styles_len = len(layout.get('styles') or [])
paint_len = len(layout.get('paintOrders') or [])
client_len = len(layout.get('clientRects') or [])
scroll_len = len(layout.get('scrollRects') or [])
stacking_len = len(layout.get('stackingContexts') or [])
assert bounds_len == 0
assert styles_len == 0
assert paint_len == 0
assert client_len == 0
assert scroll_len == 0
assert stacking_len == 0

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