From 69922bcd96d9dab14772cc9762644963d5f378be Mon Sep 17 00:00:00 2001 From: adamlu123 Date: Sun, 4 Oct 2026 20:41:03 -0700 Subject: [PATCH 1/8] Rename Webwright to CUAWright and add OSWorld desktop runtime --- .claude-plugin/marketplace.json | 16 +- .claude-plugin/plugin.json | 16 +- .codex-plugin/plugin.json | 23 +- .github/workflows/runtime-tests.yml | 18 + .github/workflows/skills-tests.yml | 6 +- .gitignore | 7 + NOTICE | 5 + README.md | 140 ++- assets/cuawright_logo.svg | 52 + docs/desktop.md | 69 + docs/skill_factory/manual.md | 18 +- docs/skill_factory/reference.md | 20 +- licenses/OSWorld-runtime-APACHE-2.0.txt | 201 +++ pyproject.toml | 25 +- release/osworld/tests/test_failures.py | 353 ++++++ release/osworld/tests/test_release.py | 1111 +++++++++++++++++ setup.py | 32 + skills/{webwright => cuawright-web}/SKILL.md | 23 +- .../commands/craft.md | 2 +- .../commands/run.md | 2 +- .../reference/cli_tool_mode.md | 8 +- .../reference/playwright_patterns.md | 0 .../reference/workflow.md | 4 +- src/cuawright/__init__.py | 3 + src/cuawright/desktop/__init__.py | 3 + src/cuawright/desktop/__main__.py | 3 + .../desktop/agents}/__init__.py | 0 src/cuawright/desktop/agents/default.py | 345 +++++ .../desktop/config}/__init__.py | 0 src/cuawright/desktop/config/prompts.py | 124 ++ .../desktop/environments}/__init__.py | 0 .../desktop/environments/osworld/__init__.py | 0 .../environments/osworld/compatibility.py | 218 ++++ .../osworld/control_clients/__init__.py | 1 + .../osworld/control_clients/ask_user.py | 22 + .../osworld/control_clients/image_show.py | 25 + .../osworld/control_clients/submit.py | 6 + .../environments/osworld/guest_tools.py | 363 ++++++ .../desktop/environments/osworld/source.py | 115 ++ src/cuawright/desktop/exceptions.py | 2 + src/cuawright/desktop/models/__init__.py | 0 .../desktop/models/openai_response_model.py | 9 + .../desktop/models/utils/__init__.py | 0 .../models/utils/actions_toolcall_response.py | 90 ++ src/cuawright/desktop/run/__init__.py | 0 .../desktop/run/benchmarks/__init__.py | 0 .../desktop/run/benchmarks/osworld.py | 412 ++++++ src/cuawright/desktop/run/cli.py | 46 + src/cuawright/desktop/utils/__init__.py | 0 src/cuawright/desktop/utils/artifacts.py | 147 +++ src/cuawright/webwright/__init__.py | 83 ++ .../webwright/agents/__init__.py | 4 +- .../webwright/agents/default.py | 14 +- .../webwright/config/__init__.py | 2 +- .../webwright/config/base.yaml | 20 +- ...est_default_judge_json_persistent_cli.yaml | 16 +- .../webwright/config/crafted_cli.yaml | 20 +- .../webwright/config/local_browser.yaml | 4 +- .../webwright/config/model_claude.yaml | 2 +- .../webwright/config/model_openai.yaml | 2 +- .../webwright/config/model_openrouter.yaml | 4 +- .../webwright/config/persistent_browser.yaml | 42 +- .../webwright/config/task_showcase.yaml | 22 +- .../webwright/environments/__init__.py | 6 +- .../webwright/environments/local_browser.py | 0 .../webwright/environments/local_workspace.py | 9 +- src/{ => cuawright}/webwright/exceptions.py | 0 .../webwright/models/__init__.py | 8 +- .../webwright/models/anthropic_model.py | 2 +- src/{ => cuawright}/webwright/models/base.py | 8 +- .../webwright/models/openai_model.py | 4 +- .../webwright/models/openrouter_model.py | 2 +- .../webwright/models/tool_calls.py | 0 src/cuawright/webwright/run/__init__.py | 0 src/{ => cuawright}/webwright/run/cli.py | 12 +- src/{ => cuawright}/webwright/run/doctor.py | 0 .../webwright/run/run_command.sh | 2 +- .../webwright/skill_factory/README.md | 18 +- .../webwright/skill_factory/__init__.py | 6 +- .../webwright/skill_factory/__main__.py | 14 +- .../webwright/skill_factory/build.py | 4 +- .../webwright/skill_factory/decide.py | 0 .../webwright/skill_factory/entry_shim.py | 0 .../skill_factory/examples/README.md | 6 +- .../skill_factory/examples/batch.example.json | 0 .../skill_factory/examples/flights.skill.yaml | 2 +- .../meta.json | 0 .../replays.json | 0 .../skill.py | 0 .../examples/model_gateway.example.yaml | 0 .../examples/solve_with_library.sh | 6 +- .../skill_factory/examples/tasks.example.json | 0 .../examples/trajectories/README.md | 6 +- .../trajectories/lax_ord/agent_response.json | 0 .../trajectories/lax_ord/final_script.py | 0 .../examples/trajectories/lax_ord/task.json | 5 + .../trajectories/sea_jfk/agent_response.json | 0 .../trajectories/sea_jfk/final_script.py | 0 .../examples/trajectories/sea_jfk/task.json | 5 + .../trajectories/sfo_bos/agent_response.json | 0 .../trajectories/sfo_bos/final_script.py | 0 .../examples/trajectories/sfo_bos/task.json | 5 + .../webwright/skill_factory/execute.py | 0 .../webwright/skill_factory/fill.py | 0 .../webwright/skill_factory/gate.py | 0 .../webwright/skill_factory/init.py | 6 +- .../webwright/skill_factory/learn.py | 4 +- .../webwright/skill_factory/library.py | 0 .../webwright/skill_factory/llm.py | 4 +- .../webwright/skill_factory/prompt.py | 2 +- .../webwright/skill_factory/retrieve.py | 0 .../webwright/skill_factory/route.py | 10 +- .../webwright/skill_factory/update.py | 2 +- src/cuawright/webwright/tools/__init__.py | 0 .../webwright/tools/_model_config.py | 2 +- .../webwright/tools/browser_session.py | 16 +- .../webwright/tools/image_qa.py | 4 +- .../webwright/tools/image_read.py | 0 .../tools/persistent_local_browser.py | 8 +- .../webwright/tools/self_reflection.py | 8 +- .../webwright/tools/skill_use.py | 10 +- src/cuawright/webwright/utils/__init__.py | 0 .../webwright/utils/browser_evidence.py | 0 .../webwright/utils/logging.py | 0 .../webwright/utils/runtime.py | 0 .../webwright/utils/serialize.py | 0 src/webwright/__init__.py | 107 +- .../examples/trajectories/lax_ord/task.json | 5 - .../examples/trajectories/sea_jfk/task.json | 5 - .../examples/trajectories/sfo_bos/task.json | 5 - tests/skill_factory/test_build_init.py | 10 +- tests/skill_factory/test_entry_shim.py | 2 +- tests/skill_factory/test_evolve.py | 34 +- tests/skill_factory/test_execute.py | 2 +- tests/skill_factory/test_fill.py | 2 +- tests/skill_factory/test_gate.py | 2 +- tests/skill_factory/test_learn.py | 8 +- tests/skill_factory/test_learned_example.py | 4 +- tests/skill_factory/test_library.py | 2 +- tests/skill_factory/test_llm_env.py | 2 +- tests/skill_factory/test_recommend.py | 12 +- tests/skill_factory/test_retrieve_decide.py | 16 +- tests/skill_factory/test_route.py | 2 +- tests/unit/test_browser_evidence.py | 2 +- tests/unit/test_browser_session_cli.py | 6 +- tests/unit/test_cuawright_entrypoints.py | 55 + tests/unit/test_doctor.py | 2 +- tests/unit/test_image_read.py | 10 +- tests/unit/test_run_command_tool.py | 14 +- .../unit/test_self_reflection_reliability.py | 10 +- tests/unit/test_tool_model_routing.py | 6 +- 151 files changed, 4410 insertions(+), 441 deletions(-) create mode 100644 .github/workflows/runtime-tests.yml create mode 100644 NOTICE create mode 100644 assets/cuawright_logo.svg create mode 100644 docs/desktop.md create mode 100644 licenses/OSWorld-runtime-APACHE-2.0.txt create mode 100644 release/osworld/tests/test_failures.py create mode 100644 release/osworld/tests/test_release.py create mode 100644 setup.py rename skills/{webwright => cuawright-web}/SKILL.md (92%) rename skills/{webwright => cuawright-web}/commands/craft.md (97%) rename skills/{webwright => cuawright-web}/commands/run.md (94%) rename skills/{webwright => cuawright-web}/reference/cli_tool_mode.md (96%) rename skills/{webwright => cuawright-web}/reference/playwright_patterns.md (100%) rename skills/{webwright => cuawright-web}/reference/workflow.md (95%) create mode 100644 src/cuawright/__init__.py create mode 100644 src/cuawright/desktop/__init__.py create mode 100644 src/cuawright/desktop/__main__.py rename src/{webwright/run => cuawright/desktop/agents}/__init__.py (100%) create mode 100644 src/cuawright/desktop/agents/default.py rename src/{webwright/tools => cuawright/desktop/config}/__init__.py (100%) create mode 100644 src/cuawright/desktop/config/prompts.py rename src/{webwright/utils => cuawright/desktop/environments}/__init__.py (100%) create mode 100644 src/cuawright/desktop/environments/osworld/__init__.py create mode 100644 src/cuawright/desktop/environments/osworld/compatibility.py create mode 100644 src/cuawright/desktop/environments/osworld/control_clients/__init__.py create mode 100644 src/cuawright/desktop/environments/osworld/control_clients/ask_user.py create mode 100644 src/cuawright/desktop/environments/osworld/control_clients/image_show.py create mode 100644 src/cuawright/desktop/environments/osworld/control_clients/submit.py create mode 100644 src/cuawright/desktop/environments/osworld/guest_tools.py create mode 100644 src/cuawright/desktop/environments/osworld/source.py create mode 100644 src/cuawright/desktop/exceptions.py create mode 100644 src/cuawright/desktop/models/__init__.py create mode 100644 src/cuawright/desktop/models/openai_response_model.py create mode 100644 src/cuawright/desktop/models/utils/__init__.py create mode 100644 src/cuawright/desktop/models/utils/actions_toolcall_response.py create mode 100644 src/cuawright/desktop/run/__init__.py create mode 100644 src/cuawright/desktop/run/benchmarks/__init__.py create mode 100644 src/cuawright/desktop/run/benchmarks/osworld.py create mode 100644 src/cuawright/desktop/run/cli.py create mode 100644 src/cuawright/desktop/utils/__init__.py create mode 100644 src/cuawright/desktop/utils/artifacts.py create mode 100644 src/cuawright/webwright/__init__.py rename src/{ => cuawright}/webwright/agents/__init__.py (82%) rename src/{ => cuawright}/webwright/agents/default.py (97%) rename src/{ => cuawright}/webwright/config/__init__.py (98%) rename src/{ => cuawright}/webwright/config/base.yaml (90%) rename src/{ => cuawright}/webwright/config/best_default_judge_json_persistent_cli.yaml (89%) rename src/{ => cuawright}/webwright/config/crafted_cli.yaml (90%) rename src/{ => cuawright}/webwright/config/local_browser.yaml (98%) rename src/{ => cuawright}/webwright/config/model_claude.yaml (80%) rename src/{ => cuawright}/webwright/config/model_openai.yaml (83%) rename src/{ => cuawright}/webwright/config/model_openrouter.yaml (59%) rename src/{ => cuawright}/webwright/config/persistent_browser.yaml (83%) rename src/{ => cuawright}/webwright/config/task_showcase.yaml (92%) rename src/{ => cuawright}/webwright/environments/__init__.py (71%) rename src/{ => cuawright}/webwright/environments/local_browser.py (100%) rename src/{ => cuawright}/webwright/environments/local_workspace.py (97%) rename src/{ => cuawright}/webwright/exceptions.py (100%) rename src/{ => cuawright}/webwright/models/__init__.py (67%) rename src/{ => cuawright}/webwright/models/anthropic_model.py (99%) rename src/{ => cuawright}/webwright/models/base.py (98%) rename src/{ => cuawright}/webwright/models/openai_model.py (98%) rename src/{ => cuawright}/webwright/models/openrouter_model.py (99%) rename src/{ => cuawright}/webwright/models/tool_calls.py (100%) create mode 100644 src/cuawright/webwright/run/__init__.py rename src/{ => cuawright}/webwright/run/cli.py (93%) rename src/{ => cuawright}/webwright/run/doctor.py (100%) rename src/{ => cuawright}/webwright/run/run_command.sh (84%) rename src/{ => cuawright}/webwright/skill_factory/README.md (96%) rename src/{ => cuawright}/webwright/skill_factory/__init__.py (79%) rename src/{ => cuawright}/webwright/skill_factory/__main__.py (60%) rename src/{ => cuawright}/webwright/skill_factory/build.py (98%) rename src/{ => cuawright}/webwright/skill_factory/decide.py (100%) rename src/{ => cuawright}/webwright/skill_factory/entry_shim.py (100%) rename src/{ => cuawright}/webwright/skill_factory/examples/README.md (93%) rename src/{ => cuawright}/webwright/skill_factory/examples/batch.example.json (100%) rename src/{ => cuawright}/webwright/skill_factory/examples/flights.skill.yaml (93%) rename src/{ => cuawright}/webwright/skill_factory/examples/learned_library/what_is_the_earliest_nonstop_flight_from_2c8dab1/meta.json (100%) rename src/{ => cuawright}/webwright/skill_factory/examples/learned_library/what_is_the_earliest_nonstop_flight_from_2c8dab1/replays.json (100%) rename src/{ => cuawright}/webwright/skill_factory/examples/learned_library/what_is_the_earliest_nonstop_flight_from_2c8dab1/skill.py (100%) rename src/{ => cuawright}/webwright/skill_factory/examples/model_gateway.example.yaml (100%) rename src/{ => cuawright}/webwright/skill_factory/examples/solve_with_library.sh (71%) rename src/{ => cuawright}/webwright/skill_factory/examples/tasks.example.json (100%) rename src/{ => cuawright}/webwright/skill_factory/examples/trajectories/README.md (93%) rename src/{ => cuawright}/webwright/skill_factory/examples/trajectories/lax_ord/agent_response.json (100%) rename src/{ => cuawright}/webwright/skill_factory/examples/trajectories/lax_ord/final_script.py (100%) create mode 100644 src/cuawright/webwright/skill_factory/examples/trajectories/lax_ord/task.json rename src/{ => cuawright}/webwright/skill_factory/examples/trajectories/sea_jfk/agent_response.json (100%) rename src/{ => cuawright}/webwright/skill_factory/examples/trajectories/sea_jfk/final_script.py (100%) create mode 100644 src/cuawright/webwright/skill_factory/examples/trajectories/sea_jfk/task.json rename src/{ => cuawright}/webwright/skill_factory/examples/trajectories/sfo_bos/agent_response.json (100%) rename src/{ => cuawright}/webwright/skill_factory/examples/trajectories/sfo_bos/final_script.py (100%) create mode 100644 src/cuawright/webwright/skill_factory/examples/trajectories/sfo_bos/task.json rename src/{ => cuawright}/webwright/skill_factory/execute.py (100%) rename src/{ => cuawright}/webwright/skill_factory/fill.py (100%) rename src/{ => cuawright}/webwright/skill_factory/gate.py (100%) rename src/{ => cuawright}/webwright/skill_factory/init.py (96%) rename src/{ => cuawright}/webwright/skill_factory/learn.py (98%) rename src/{ => cuawright}/webwright/skill_factory/library.py (100%) rename src/{ => cuawright}/webwright/skill_factory/llm.py (96%) rename src/{ => cuawright}/webwright/skill_factory/prompt.py (97%) rename src/{ => cuawright}/webwright/skill_factory/retrieve.py (100%) rename src/{ => cuawright}/webwright/skill_factory/route.py (96%) rename src/{ => cuawright}/webwright/skill_factory/update.py (99%) create mode 100644 src/cuawright/webwright/tools/__init__.py rename src/{ => cuawright}/webwright/tools/_model_config.py (98%) rename src/{ => cuawright}/webwright/tools/browser_session.py (98%) rename src/{ => cuawright}/webwright/tools/image_qa.py (97%) rename src/{ => cuawright}/webwright/tools/image_read.py (100%) rename src/{ => cuawright}/webwright/tools/persistent_local_browser.py (95%) rename src/{ => cuawright}/webwright/tools/self_reflection.py (98%) rename src/{ => cuawright}/webwright/tools/skill_use.py (95%) create mode 100644 src/cuawright/webwright/utils/__init__.py rename src/{ => cuawright}/webwright/utils/browser_evidence.py (100%) rename src/{ => cuawright}/webwright/utils/logging.py (100%) rename src/{ => cuawright}/webwright/utils/runtime.py (100%) rename src/{ => cuawright}/webwright/utils/serialize.py (100%) delete mode 100644 src/webwright/skill_factory/examples/trajectories/lax_ord/task.json delete mode 100644 src/webwright/skill_factory/examples/trajectories/sea_jfk/task.json delete mode 100644 src/webwright/skill_factory/examples/trajectories/sfo_bos/task.json create mode 100644 tests/unit/test_cuawright_entrypoints.py diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 88ba107a..b8d7e1bd 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -1,20 +1,26 @@ { - "name": "webwright", + "name": "cuawright", "owner": { "name": "Microsoft Research", "url": "https://github.com/microsoft/Webwright" }, "plugins": [ { - "name": "webwright", + "name": "cuawright", "source": { "source": "github", "repo": "microsoft/Webwright" }, - "description": "Turn your coding agent into a SOTA browser agent. Drives a local Playwright workspace via one bash command at a time, saving screenshots and an action log into final_runs/run_/, and visually self-verifies the result.", - "version": "0.1.0", + "description": "CUAWright browser automation skill; desktop runtime available through cuawright-desktop.", + "version": "0.2.0", "category": "automation", - "keywords": ["browser", "playwright", "web-agent", "automation", "rpa"] + "keywords": [ + "browser", + "playwright", + "web-agent", + "automation", + "rpa" + ] } ] } diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 350eb33f..5d6a365b 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { - "name": "webwright", - "version": "0.1.0", - "description": "Turn your coding agent into a SOTA browser agent. Drives a local Playwright workspace via one bash command at a time, saving screenshots and an action log into final_runs/run_/, and visually self-verifies the result.", + "name": "cuawright", + "version": "0.2.0", + "description": "CUAWright browser automation skill. The Python package also includes OSWorld desktop support through cuawright-desktop.", "author": { "name": "Microsoft Research", "url": "https://github.com/microsoft/Webwright" @@ -9,7 +9,13 @@ "homepage": "https://github.com/microsoft/Webwright", "repository": "https://github.com/microsoft/Webwright", "license": "MIT", - "keywords": ["browser", "playwright", "web-agent", "automation", "rpa"], - "commands": "./skills/webwright/commands", + "keywords": [ + "browser", + "playwright", + "web-agent", + "automation", + "rpa" + ], + "commands": "./skills/cuawright-web/commands", "skills": "./skills" } diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 8783217f..400891d3 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,7 +1,7 @@ { - "name": "webwright", - "version": "0.1.0", - "description": "Turn your coding agent into a SOTA browser agent. Drives a local Playwright workspace via one bash command at a time, saving screenshots and an action log into final_runs/run_/, and visually self-verifies the result.", + "name": "cuawright", + "version": "0.2.0", + "description": "CUAWright browser automation skill. The Python package also includes OSWorld desktop support through cuawright-desktop.", "author": { "name": "Microsoft Research", "url": "https://github.com/microsoft/Webwright" @@ -9,15 +9,24 @@ "homepage": "https://github.com/microsoft/Webwright", "repository": "https://github.com/microsoft/Webwright", "license": "MIT", - "keywords": ["browser", "playwright", "web-agent", "automation", "rpa"], + "keywords": [ + "browser", + "playwright", + "web-agent", + "automation", + "rpa" + ], "skills": "./skills/", "interface": { - "displayName": "Webwright", - "shortDescription": "SOTA browser agent driven by your coding agent", + "displayName": "CUAWright", + "shortDescription": "Browser automation with CUAWright", "longDescription": "Drives a local Playwright workspace via one bash command at a time, saving screenshots and an action log into final_runs/run_/, and visually self-verifies the result.", "developerName": "Microsoft Research", "category": "Productivity", - "capabilities": ["Read", "Write"], + "capabilities": [ + "Read", + "Write" + ], "websiteURL": "https://github.com/microsoft/Webwright" } } diff --git a/.github/workflows/runtime-tests.yml b/.github/workflows/runtime-tests.yml new file mode 100644 index 00000000..b32a7a21 --- /dev/null +++ b/.github/workflows/runtime-tests.yml @@ -0,0 +1,18 @@ +name: runtime-tests +on: + push: + pull_request: +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install -e ".[desktop,test,build]" + - run: playwright install --with-deps chromium + - run: pytest -q + - run: python -m compileall -q src + - run: git diff --check + - run: python -m build diff --git a/.github/workflows/skills-tests.yml b/.github/workflows/skills-tests.yml index 811d7386..882d38e0 100644 --- a/.github/workflows/skills-tests.yml +++ b/.github/workflows/skills-tests.yml @@ -1,9 +1,9 @@ name: skills-tests on: push: - paths: ["src/webwright/skill_factory/**", "src/webwright/tools/skill_use.py", "tests/skill_factory/**"] + paths: ["src/cuawright/webwright/skill_factory/**", "src/cuawright/webwright/tools/skill_use.py", "tests/skill_factory/**"] pull_request: - paths: ["src/webwright/skill_factory/**", "src/webwright/tools/skill_use.py", "tests/skill_factory/**"] + paths: ["src/cuawright/webwright/skill_factory/**", "src/cuawright/webwright/tools/skill_use.py", "tests/skill_factory/**"] jobs: unit: runs-on: ubuntu-latest @@ -21,5 +21,5 @@ jobs: - name: wrapper usage check (F6) run: | set +e - bash src/webwright/skill_factory/examples/solve_with_library.sh > /dev/null 2>&1 + bash src/cuawright/webwright/skill_factory/examples/solve_with_library.sh > /dev/null 2>&1 [ $? -eq 1 ] && echo "usage-exit OK" || { echo "wrapper must exit 1 on missing args"; exit 1; } diff --git a/.gitignore b/.gitignore index 97c9694d..aa4286bf 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,10 @@ outputs/ venv/ runs/ /library/ + +build/ +dist/ +release/osworld/.scratch/ +release/osworld/dist/ + +.wheel-env/ diff --git a/NOTICE b/NOTICE new file mode 100644 index 00000000..06c8e72a --- /dev/null +++ b/NOTICE @@ -0,0 +1,5 @@ +CUAWright includes the Webwright browser runtime (MIT; see LICENSE) and +the CUAWright OSWorld release runtime imported from commit +47984a49 of the standalone CUAWright OSWorld release. +The imported desktop runtime is licensed under Apache-2.0; see +licenses/OSWorld-runtime-APACHE-2.0.txt. diff --git a/README.md b/README.md index 59c78695..fe885b1b 100644 --- a/README.md +++ b/README.md @@ -1,48 +1,83 @@ -# Webwright +# CUAWright + +> **Webwright is now CUAWright.** The project has expanded from browser automation +> to browser and desktop agents. Webwright remains the browser subsystem, now at +> `cuawright.webwright`; OSWorld 2.0 desktop support lives at `cuawright.desktop`. +> Use `cuawright-web` for browser tasks and `cuawright-desktop` for desktop tasks. +> Existing `webwright` commands and Python imports remain supported. +> The separate browser and desktop runtimes are temporary to preserve backward +> compatibility; we plan to merge them into one unified runtime soon.

- Webwright logo + CUAWright logo

-

Turn Your Coding Models to Be State-of-the-art Browser Agents

+

Turn coding models into browser and desktop agents

Python Playwright Backends - Footprint

- πŸ“ **Blog:** [Webwright: A Terminal Is All You Need For Web Agents](https://www.microsoft.com/en-us/research/articles/webwright-a-terminal-is-all-you-need-for-web-agents/) - 🌐 **Project Page:** [microsoft.github.io/Webwright](https://microsoft.github.io/Webwright/) +CUAWright combines **Webwright browser automation** and a **persistent desktop Actor** for official OSWorld-V2 tasks. + +The browser and desktop runtimes are currently separate to preserve backward +compatibility during the transition. We plan to merge them into one unified +runtime soon. + +| Command | Runtime | +| --- | --- | +| `cuawright-web` | Browser tasks with Playwright and the Webwright harness | +| `cuawright-desktop` | One official OSWorld-V2 Linux task on Ubuntu via Docker | +| `webwright` | Compatibility alias for `cuawright-web` | + +```bash +pip install -e . +playwright install chromium +cuawright-web -c base.yaml -c model_openai.yaml -t "" --start-url "" +``` + +For desktop support, use Python 3.12+ and `pip install -e ".[desktop]"`. +See [desktop setup and execution](docs/desktop.md) for external OSWorld provisioning. +The Python modules are `cuawright.webwright` and `cuawright.desktop`; existing +`webwright` imports and module entrypoints remain available for compatibility. +Browser configuration and profile locations remain compatible with Webwright. + +The browser sections below describe the inherited Webwright capabilities and results. + Webwright gives LLM a terminal where it can launch multiple browser sessions to inspect the page and complete a web task. It captures and inspects page screenshots/states only when needed. It enforces each web task to be completed end-to-end within a re-runnable Python script, i.e. your web agent browsing history is a single code file. No multi-agent system, no graph engine, no plugin layer, no hidden orchestration β€” just a terminal, a browser, and a model. For incremental browsing with native OpenAI `run_command` tool calls, use: ```bash -webwright -c best_default_judge_json_persistent_cli.yaml -c model_openai.yaml \ +cuawright-web -c best_default_judge_json_persistent_cli.yaml -c model_openai.yaml \ -t "" --start-url "" --task-id example -o outputs/example ``` This config keeps a Browserbase cloud session across commands (requires `BROWSERBASE_API_KEY` and `BROWSERBASE_PROJECT_ID`), attaches saved images -through `python -m webwright.tools.image_read --path /absolute/workspace/image.png`, +through `python -m cuawright.webwright.tools.image_read --path /absolute/workspace/image.png`, and uses `self_reflection --scope trajectory` to judge all task screenshots. Each turn is one shell tool call or a plain text completion answer. Reasoning and tool results are replayed across turns; history compaction disables tool execution. Existing script-based configs remain available. Self-reflection rejects incomplete image judgments and retries malformed final verdicts. -Already got your favorite agents, and wonder how to make Claude Code, Codex, Hermes, OpenClaw more capable in browser tasks? Consider adding [Webwright plugin/skills](#-use-as-a-claude-code-skill)! +Already got your favorite agents, and wonder how to make Claude Code, Codex, Hermes, OpenClaw more capable in browser tasks? Consider adding [CUAWright browser plugin/skill](#-use-as-a-plugin)! --- ## πŸ“° News -- **2026-09-01** β€” We did several optmization (add persistent step by step solve mode, remove explict output thinking block, and use run_command tool call etc) further boost performance: OM2W -> 88.1% and Odssey --> 77.5% + +- **2026-10-03** β€” **Webwright β†’ CUAWright:** renamed the project to bring browser and desktop agents together. The Webwright browser runtime is now `cuawright.webwright`, with `cuawright-web` as its command. Added `cuawright-desktop` for official OSWorld-V2 Linux tasks on Ubuntu via Docker. Existing `webwright` commands and imports remain compatible. The separate browser and desktop runtimes are a temporary compatibility arrangement; a unified runtime is planned soon. See [desktop setup](docs/desktop.md). +- **2026-09-01** β€” Persistent step-by-step browsing and native `run_command` tool calls improve performance to **88.1% on Online-Mind2Web** and **77.5% on Odysseys**. - **2026-07-21** β€” Skill Factory: every solve leaves a script behind, distilled into reusable, verified, parameterized code skills that rerun standalone with no model (~40 s, zero tokens). On WebArena, reuse lifts held-out accuracy 55% β†’ 70% (+15 pp). See [Skill Factory](#-skill-factory-turn-solved-tasks-into-runnable-code-skills). - **2026-05-11** β€” Support Task2UI mode: Webwright completes the task and renders task results into an HTML-based web app you can easily view and reuse. -- **2026-05-06** β€” Codex and Claude Code plugin manifests added; install via `/plugin install webwright@webwright`. OpenClaw and Hermes Agent integrations shipped; the same `skills/webwright/` folder now loads across Claude Code, Codex, OpenClaw, and Hermes. +- **2026-05-06** β€” Codex and Claude Code plugin manifests added; install via `/plugin install cuawright@cuawright`. OpenClaw and Hermes Agent integrations shipped; the same `skills/cuawright-web/` folder now loads across Claude Code, Codex, OpenClaw, and Hermes. - **2026-05-04** β€” Initial public release: ~1.5k LoC, OpenAI / Anthropic / OpenRouter backends, Playwright environment. --- @@ -68,7 +103,7 @@ Webwright takes a different stance: **separate the agent from the browser**, and Most web agent frameworks bury the actual agent loop under layers of abstractions. Webwright takes the opposite stance: -- πŸͺΆ **Lightweight by design** β€” core agent loop in a single ~450-line file, Playwright environment in ~570 lines, CLI in ~150 lines. +- πŸͺΆ **Readable core** β€” browser agent loop, environments, models, and CLI are separate modules under `src/cuawright/webwright/`. - 🧩 **Pluggable model backends** β€” OpenAI, Anthropic, and OpenRouter, each ~150–200 lines. - πŸ” **Zero hidden frameworks** β€” just `httpx`, `pydantic`, `playwright`, and `typer`. - πŸ” **Flat prompt β†’ observe β†’ execute script loop** β€” readable end-to-end, easy to debug, easy to fork. @@ -120,23 +155,23 @@ State-of-the-art on two real-website benchmarks with a 100-step budget β€” see t ## πŸ—ΊοΈ Project Map ``` -webwright/ -β”œβ”€β”€ pyproject.toml # package: webwright -β”œβ”€β”€ src/webwright/ -β”‚ β”œβ”€β”€ run/cli.py # CLI entrypoint (`webwright`) -β”‚ β”œβ”€β”€ agents/default.py # core agent loop -β”‚ β”œβ”€β”€ environments/ # Playwright browser workspace -β”‚ β”œβ”€β”€ tools/ # image_qa, self_reflection -β”‚ β”œβ”€β”€ models/ # openai_model, anthropic_model, base -β”‚ β”œβ”€β”€ config/ # base.yaml, model_openai.yaml, model_claude.yaml -β”‚ └── utils/ -β”œβ”€β”€ assets/ -β”‚ └── task_showcase/ # tiny Flask dashboard for repeatable runs -β”‚ β”œβ”€β”€ app.py -β”‚ β”œβ”€β”€ templates/ # dashboard.html, task.html -β”‚ └── tasks// # task.json + report.json per task -β”œβ”€β”€ tests/ -└── outputs/ # run artifacts (trajectories, screenshots) +cuawright/ +β”œβ”€β”€ pyproject.toml # package: cuawright +β”œβ”€β”€ src/cuawright/ +β”‚ β”œβ”€β”€ webwright/ # Webwright browser runtime +β”‚ β”‚ β”œβ”€β”€ run/cli.py # cuawright-web +β”‚ β”‚ β”œβ”€β”€ agents/, models/, environments/, config/, tools/ +β”‚ β”‚ └── skill_factory/ +β”‚ └── desktop/ # persistent desktop Actor +β”‚ β”œβ”€β”€ run/cli.py # cuawright-desktop +β”‚ β”œβ”€β”€ run/benchmarks/osworld.py +β”‚ └── agents/, models/, environments/, config/, utils/ +β”œβ”€β”€ src/webwright/ # legacy browser import compatibility +β”œβ”€β”€ skills/cuawright-web/ # browser plugin skill +β”œβ”€β”€ docs/desktop.md # external OSWorld setup and execution +β”œβ”€β”€ release/osworld/tests/ # desktop contract and failure tests +β”œβ”€β”€ tests/ # browser and integration tests +└── assets/ # showcase and trajectory viewer ``` --- @@ -160,7 +195,7 @@ To have Webwright produce a renderer-ready task folder at runtime, stack the Task Showcase overlay: ```bash -python -m webwright.run.cli \ +python -m cuawright.webwright.run.cli \ -c base.yaml -c model_openai.yaml -c task_showcase.yaml \ -t "" \ --task-id my_repeatable_task \ @@ -185,7 +220,7 @@ python assets/task_showcase/app.py \ ## 🧠 Skill Factory (turn solved tasks into runnable code skills) **Most agent skills are context the model reads. Ours are programs.** -[`webwright.skill_factory`](src/webwright/skill_factory/) distills the script every solve leaves +[`cuawright.webwright.skill_factory`](src/cuawright/webwright/skill_factory/) distills the script every solve leaves behind into a growing library of **reusable, verified, parameterized skills** β€” code you can run without a model and compose into the next task instead of re-exploring the site. Plugs in with **no change to the agent loop**: @@ -194,7 +229,7 @@ without a model and compose into the next task instead of re-exploring the site. `route` either runs a matching skill directly (no model) or injects it into the prompt as a prior (`{verdict: run|adapt|skip, skill_id, source_path}`); the agent reuses the hint without ever querying the library itself. -- **Grow** β€” afterwards, `python -m webwright.skill_factory learn outputs/ --library ./library` +- **Grow** β€” afterwards, `python -m cuawright.webwright.skill_factory learn outputs/ --library ./library` groups solves of the same template and distills one parameterized skill (`build` does solveβ†’learn in one shot; `update` is manual-manifest mode). @@ -204,7 +239,7 @@ poison the library. New solves widen a skill in place, regression-replayed so ol Once learned, a skill **runs standalone in ~40 s with zero tokens**. On WebArena (10 retrieve-type templates, 3 self-hosted sites, gpt-5.4) reuse lifts held-out accuracy **55% β†’ 70% (+15 pp)** while -cutting steps. See [`src/webwright/skill_factory/README.md`](src/webwright/skill_factory/README.md). +cutting steps. See [`src/cuawright/webwright/skill_factory/README.md`](src/cuawright/webwright/skill_factory/README.md). --- @@ -231,7 +266,7 @@ with `model_openai.yaml` or `ANTHROPIC_API_KEY` with `model_claude.yaml`). The so an Anthropic run does not require an OpenAI key. Then: ```bash -python -m webwright.run.cli \ +python -m cuawright.webwright.run.cli \ -c base.yaml -c model_openai.yaml \ -t "Search for flights from SEA to JFK on 2026-08-15 to 2026-08-20" \ --start-url https://www.google.com/flights \ @@ -243,7 +278,7 @@ python -m webwright.run.cli \ | Flag | Description | |------|-------------| -| `-c` | Config file(s) from `src/webwright/config/` (stackable). | +| `-c` | Config file(s) from `src/cuawright/webwright/config/` (stackable). | | `-t` | Task instruction. | | `--start-url` | Initial page. | | `--task-id` | Output subfolder name. | @@ -253,7 +288,10 @@ python -m webwright.run.cli \ ## πŸ”Œ Use as a Plugin -Webwright ships plugin manifests for both [Claude Code](https://docs.claude.com/en/docs/claude-code/plugins) ([`.claude-plugin/plugin.json`](.claude-plugin/plugin.json)) and [OpenAI Codex](https://developers.openai.com/codex/plugins) ([`.codex-plugin/plugin.json`](.codex-plugin/plugin.json)), with the shared skill at [`skills/webwright/`](skills/webwright/) and slash commands at [`skills/webwright/commands/`](skills/webwright/commands/). The host agent drives the Webwright loop natively β€” no extra LLM API key or cost beyond your host subscription. Hosts that read PNG screenshots natively skip the `image_qa` / `self_reflection` tools. +CUAWright ships plugin manifests for both [Claude Code](https://docs.claude.com/en/docs/claude-code/plugins) ([`.claude-plugin/plugin.json`](.claude-plugin/plugin.json)) and [OpenAI Codex](https://developers.openai.com/codex/plugins) ([`.codex-plugin/plugin.json`](.codex-plugin/plugin.json)), with the shared skill at [`skills/cuawright-web/`](skills/cuawright-web/) and slash commands at [`skills/cuawright-web/commands/`](skills/cuawright-web/commands/). The host agent drives the Webwright loop natively β€” no extra LLM API key or cost beyond your host subscription. Hosts that read PNG screenshots natively skip the `image_qa` / `self_reflection` tools. + +The bundled `cuawright-web` skill handles browser tasks. Desktop tasks use +`cuawright-desktop`; see [desktop setup](docs/desktop.md). Common runtime deps (install once after either path): @@ -274,14 +312,14 @@ Install through the bundled marketplace inside Claude Code: /plugin marketplace add microsoft/Webwright # 2. Install the plugin from that marketplace -/plugin install webwright@webwright +/plugin install cuawright@cuawright ``` Prefer a local checkout? Point the marketplace command at the cloned repo instead: ```text /plugin marketplace add /absolute/path/to/Webwright -/plugin install webwright@webwright +/plugin install cuawright@cuawright ``` ### Use @@ -291,12 +329,12 @@ Prefer a local checkout? Point the marketplace command at the cloned repo instea You can either ask Claude Code in plain English (the skill auto-activates from its description), or use one of the slash commands: ``` -/webwright:run search Google Flights for flights from SEA to JFK on 2026-08-15 to 2026-08-20 -/webwright:craft search a ticket on Google Flights from LAX to SFO depart June 7 return June 14 +/cuawright:run search Google Flights for flights from SEA to JFK on 2026-08-15 to 2026-08-20 +/cuawright:craft search a ticket on Google Flights from LAX to SFO depart June 7 return June 14 ``` -- `/webwright:run` (or any plain prompt) produces a **one-shot** `final_script.py` for the literal task values. -- `/webwright:craft` produces a **reusable CLI tool**: `final_script.py` becomes one parameterized function with a Google-style `Args:` docstring and an `argparse` wrapper whose flags default to the concrete task values, so you can rerun it later with different arguments β€” e.g. `python final_script.py --origin JFK --destination LAX --depart-date 2026-07-01`. +- `/cuawright:run` (or any plain prompt) produces a **one-shot** `final_script.py` for the literal task values. +- `/cuawright:craft` produces a **reusable CLI tool**: `final_script.py` becomes one parameterized function with a Google-style `Args:` docstring and an `argparse` wrapper whose flags default to the concrete task values, so you can rerun it later with different arguments β€” e.g. `python final_script.py --origin JFK --destination LAX --depart-date 2026-07-01`. In both modes Claude Code scaffolds a workspace with `plan.md`, runs instrumented Playwright scripts under `final_runs/run_/`, and visually self-verifies each critical point against the saved screenshots. @@ -313,7 +351,7 @@ Codex reads Claude-style marketplaces, so the same repo works as a Codex plugin # 1. Add this repo as a Codex plugin marketplace codex plugin marketplace add microsoft/Webwright -# 2. Open the plugin browser and install Webwright +# 2. Open the plugin browser and install CUAWright codex /plugins ``` @@ -328,10 +366,10 @@ Then restart Codex so the new marketplace and plugin are picked up. ### Use -In a new Codex thread, either ask in plain English (the skill auto-activates from its description) or invoke the bundled skill explicitly with `@webwright`: +In a new Codex thread, either ask in plain English (the skill auto-activates from its description) or invoke the bundled skill explicitly with `@cuawright-web`: ``` -@webwright search Google Flights for flights from SEA to JFK on 2026-08-15 to 2026-08-20 +@cuawright-web search Google Flights for flights from SEA to JFK on 2026-08-15 to 2026-08-20 ``` Codex scaffolds a workspace with `plan.md`, runs instrumented Playwright scripts under `final_runs/run_/`, and visually self-verifies each critical point against the saved screenshots. @@ -355,15 +393,15 @@ openclaw gateway restart # reload so the plugin and skill are picked up Verify: ```bash -openclaw plugins list | grep webwright -openclaw skills list | grep webwright # should show "βœ“ ready" +openclaw plugins list | grep cuawright +openclaw skills list | grep cuawright # should show "βœ“ ready" ``` ### Use -The `webwright` skill is now available to any OpenClaw agent surface (CLI, Telegram, etc.) β€” invoke it by asking the agent in natural language, or via the slash commands shipped under [`skills/webwright/commands/`](skills/webwright/commands/), e.g. `/webwright run `. +The `cuawright-web` skill is now available to any OpenClaw agent surface (CLI, Telegram, etc.) β€” invoke it by asking the agent in natural language, or via the slash commands shipped under [`skills/cuawright-web/commands/`](skills/cuawright-web/commands/), e.g. `/cuawright:run `. -To uninstall: `openclaw plugins uninstall webwright`. +To uninstall: `openclaw plugins uninstall cuawright`. @@ -372,20 +410,20 @@ To uninstall: `openclaw plugins uninstall webwright`. ### Install -[Hermes Agent](https://github.com/NousResearch/hermes-agent) is a [skills-compatible client](https://agentskills.io), so the same `skills/webwright/` folder loads as a Hermes skill. Symlink it into your Hermes user-skills directory: +[Hermes Agent](https://github.com/NousResearch/hermes-agent) is a [skills-compatible client](https://agentskills.io), so the same `skills/cuawright-web/` folder loads as a Hermes skill. Symlink it into your Hermes user-skills directory: ```bash mkdir -p ~/.hermes/skills -ln -sfn /absolute/path/to/Webwright/skills/webwright ~/.hermes/skills/webwright +ln -sfn /absolute/path/to/Webwright/skills/cuawright-web ~/.hermes/skills/cuawright-web ``` No Hermes-specific manifest is needed; only `SKILL.md` is loaded. ### Use -Start Hermes (`hermes`) and ask it to drive a web task in natural language β€” the skill auto-activates from its description. You can also invoke it explicitly with `/webwright`. +Start Hermes (`hermes`) and ask it to drive a web task in natural language β€” the skill auto-activates from its description. You can also invoke it explicitly with `/cuawright-web`. -Note: the named subcommands shipped under [`skills/webwright/commands/`](skills/webwright/commands/) (`/webwright:run`, `/webwright:craft`) are a Claude Code / Codex convention and are inert in Hermes; the skill itself still works end-to-end. +Note: the named subcommands shipped under [`skills/cuawright-web/commands/`](skills/cuawright-web/commands/) (`/cuawright:run`, `/cuawright:craft`) are a Claude Code / Codex convention and are inert in Hermes; the skill itself still works end-to-end. diff --git a/assets/cuawright_logo.svg b/assets/cuawright_logo.svg new file mode 100644 index 00000000..cf130a20 --- /dev/null +++ b/assets/cuawright_logo.svg @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + CUAWright + + + + + diff --git a/docs/desktop.md b/docs/desktop.md new file mode 100644 index 00000000..f16914f1 --- /dev/null +++ b/docs/desktop.md @@ -0,0 +1,69 @@ +# CUAWright desktop + +`cuawright-desktop` runs one official OSWorld-V2 Linux task using a persistent +terminal Actor in an Ubuntu Docker VM. It preserves the imported release's +prompts, standard Responses API requests, submission controls, shared call budget, +compaction, proxy handling, evaluation, and multi-phase lifecycle. + +## Install + +Use Python 3.12 or newer: + +```bash +pip install -e ".[desktop]" +cuawright-desktop --help +``` + +Provision OSWorld externally. Supported source: **OSWorld-V2 v2026.08.08**, commit +`d578d2d4e0dc82b43e270fdaa7fa89d9708cd154`. The source checkout must be clean. +The interpreter running CUAWright must also have the official OSWorld dependencies +installed. Docker, the VM, Linux V2 task classes, task assets, websites, and any +required proxy must already be provisioned; execution does not download or repair them. + +The API credential file must contain one key, belong to the invoking user, and +have mode `0600`. Keep it outside the source, task, asset, and result directories. +Use a new result directory whose parent already exists. + +## Run + +```bash +cuawright-desktop \ + --source /absolute/path/to/OSWorld-V2 \ + --tasks /absolute/path/to/tasks \ + --assets /absolute/path/to/assets \ + --vm /absolute/path/to/Ubuntu.qcow2 \ + --credentials /absolute/path/to/api-key \ + --results /absolute/path/to/results/task-001 \ + --task-id 001 --model YOUR_MODEL --reasoning high +``` + +For task-requested proxies, supply `--proxy-config /absolute/path/to/proxy.json`. +`--base-url` selects a user-supplied Responses-compatible endpoint. +Only standard Responses API fields are sent. The default budget is 300 model calls; compaction +occurs every 40 calls. One invocation owns one task; batch scheduling stays external. + +Commands execute inside the guest VM. Screenshot, simulated-user, and submission +controls remain under `/opt/cuawright-tools/`. Submission triggers official +evaluation; a plain final answer does not substitute for explicit submission. +Task 069's phases share one model-call budget. + +## Artifacts and validation + +Each run writes `manifest.json`, `trace.jsonl`, and `result.json`, plus screenshot +artifacts. Setup, transport, evaluation, persistence, and cleanup failures are explicit. +Evaluator payloads, hidden task state, and credentials are excluded from diagnostics. + +```bash +pip install -e ".[desktop,test,build]" +pytest -q +python -m build --wheel +``` + +Wheels include desktop runtime provenance, generated by `setup.py` without editing +the source tree. The combined package retains the MIT browser license and the +Apache-2.0 imported desktop license; see `NOTICE` and `licenses/`. + +The supported external OSWorld source is available at +. API keys and custom endpoint URLs must +be supplied locally; credentials, proxy configuration paths, and endpoint URLs +are excluded from saved public settings. diff --git a/docs/skill_factory/manual.md b/docs/skill_factory/manual.md index 3b759bf7..a96fac90 100644 --- a/docs/skill_factory/manual.md +++ b/docs/skill_factory/manual.md @@ -1,6 +1,6 @@ # Manual mode β€” manifests, gold gates, full control -[← back to the module README](../../src/webwright/skill_factory/README.md) +[← back to the module README](../../src/cuawright/webwright/skill_factory/README.md) ## When to use this mode @@ -38,7 +38,7 @@ instruction to the task so it does (the manifest in step 2 reads it): ANSWER_SPEC='Additionally, write the final answer into $WORKSPACE_DIR/agent_response.json as {"retrieved_data": }.' -python -m webwright.run.cli main \ +python -m cuawright.webwright.run.cli main \ -t "How many commits did kilian make to a11yproject on 3/1/2023? $ANSWER_SPEC" \ --task-id t132_a --start-url http://gitlab.example.com -o outputs \ -c base.yaml -c model_openai.yaml @@ -90,7 +90,7 @@ Assembling it programmatically from a gold set is shown end-to-end in step 6. ```bash export OPENAI_API_KEY=... -python -m webwright.skill_factory.update --manifest batch.json --library ./library --verify strict +python -m cuawright.webwright.skill_factory.update --manifest batch.json --library ./library --verify strict ``` `update` defaults to `--verify off` (a skill would land `unverified`), so pass `--verify strict` @@ -104,9 +104,9 @@ batches any time; batches may mix templates. > skip this. It's the manual wiring for when you drive Webwright runs yourself. ```python -from webwright.skill_factory import with_skill_hint +from cuawright.webwright.skill_factory import with_skill_hint prompt = with_skill_hint(prompt, task=task_text, library="/abs/path/to/library") -# then: python -m webwright.run.cli main -t "$prompt" ... +# then: python -m cuawright.webwright.run.cli main -t "$prompt" ... ``` `with_skill_hint` resolves the library lookup out of the agent loop (the `recommend` decision: @@ -143,7 +143,7 @@ as {"retrieved_data": }.' # 1) solve every instance (add xargs -P N or & to parallelize) jq -c '.[]' tasks.json | while read -r row; do - python -m webwright.run.cli main -t "$(jq -r .task <<<"$row") $ANSWER_SPEC" \ + python -m cuawright.webwright.run.cli main -t "$(jq -r .task <<<"$row") $ANSWER_SPEC" \ --task-id "$(jq -r .id <<<"$row")" --start-url "$START_URL" -o outputs \ -c base.yaml -c model_openai.yaml done @@ -166,13 +166,13 @@ print(sum(r["admit"] for r in runs), "of", len(runs), "admitted") PY # 3) evolve the library (strict replay so skills land executable) -python -m webwright.skill_factory.update --manifest batch.json --library ./library --verify strict +python -m cuawright.webwright.skill_factory.update --manifest batch.json --library ./library --verify strict # 4) solve a NEW instance WITH the library TASK="How many commits did byte make to empathy-prompts on 4/2/2023?" -PROMPT=$(python -c 'import sys; from webwright.skill_factory import with_skill_hint +PROMPT=$(python -c 'import sys; from cuawright.webwright.skill_factory import with_skill_hint print(with_skill_hint(sys.argv[1], task=sys.argv[1], library="./library"))' "$TASK") -python -m webwright.run.cli main -t "$PROMPT" \ +python -m cuawright.webwright.run.cli main -t "$PROMPT" \ --task-id t132_new --start-url "$START_URL" -o outputs -c base.yaml -c model_openai.yaml ``` diff --git a/docs/skill_factory/reference.md b/docs/skill_factory/reference.md index 974f52b5..929b7a90 100644 --- a/docs/skill_factory/reference.md +++ b/docs/skill_factory/reference.md @@ -1,6 +1,6 @@ # Reference β€” verification, parameters, components -[← back to the module README](../../src/webwright/skill_factory/README.md) +[← back to the module README](../../src/cuawright/webwright/skill_factory/README.md) ## Verification and grades @@ -54,9 +54,9 @@ Why code even at `reference` grade, versus a natural-language note: the selector shapes are verbatim-copyable into the agent's next script, individual primitives often still run when the whole skill doesn't, and a reference skill is one repair away from executable. And a prior alone pulls its weight: the WebArena numbers in -[Results](../../src/webwright/skill_factory/README.md#-results) come from a library the agent read +[Results](../../src/cuawright/webwright/skill_factory/README.md#-results) come from a library the agent read exactly this way. The flights skill in the -[Quick Start](../../src/webwright/skill_factory/README.md#-quick-start), by contrast, reruns an +[Quick Start](../../src/cuawright/webwright/skill_factory/README.md#-quick-start), by contrast, reruns an unseen route standalone, which is what `executable` buys. ## All parameters @@ -68,7 +68,7 @@ solve for you. **Manual mode** is `update`: you hand it a manifest and state all ### Quick mode -#### `python -m webwright.skill_factory init ""` +#### `python -m cuawright.webwright.skill_factory init ""` | flag | default | meaning | |---|---|---| @@ -78,7 +78,7 @@ solve for you. **Manual mode** is `update`: you hand it a manifest and state all One LLM call. Drafts the template, the `start_url` (a guess, check it), and the verify mode it judges the task needs. Never the values. -#### `python -m webwright.skill_factory build ` +#### `python -m cuawright.webwright.skill_factory build ` Solves the spec's instances, then hands them to `learn`. Everything in the spec's `build:` block can be overridden here; machine-specific things are flags only, so the spec stays committable. @@ -97,7 +97,7 @@ On `--jobs`: the ceiling is the site, not the flag. Too many browsers from one I throttled, which reads as your solves failing; 3 to 5 is safe. With N > 1 each solve goes to `build_outputs/solve_NN.log` and progress ticks every 30 s. -#### `python -m webwright.skill_factory learn ` +#### `python -m cuawright.webwright.skill_factory learn ` | flag | default | meaning | |---|---|---| @@ -112,7 +112,7 @@ throttled, which reads as your solves failing; 3 to 5 is safe. With N > 1 each s ### Manual mode -#### `python -m webwright.skill_factory.update` +#### `python -m cuawright.webwright.skill_factory.update` The manifest-driven path (see [manual.md](manual.md)). Use it when you're assembling batches by hand rather than from a spec. @@ -125,7 +125,7 @@ hand rather than from a spec. ### Solve time -#### `python -m webwright.skill_factory route` +#### `python -m cuawright.webwright.skill_factory route` Route a task out of the agent loop: `recommend` decides `run`/`adapt`/`skip`, then `route` acts on it β€” runs a matching executable skill directly (no model), or hands the task to the agent with the @@ -141,7 +141,7 @@ Without `--start-url` it only prints the decision (and still runs a directly-run | `-o` / `--out`, `--task-id` | `.` / `route_task` | output dir and task id for a launched solve | | `--json` | off | print the raw outcome as JSON | -#### `python -m webwright.tools.skill_use` +#### `python -m cuawright.webwright.tools.skill_use` The `recommend` decision on its own β€” retrieve + judge β†’ `{verdict, skill_id, source_path, how_to_reuse}`, doing nothing else. Ranking is local; the verdict is one LLM round trip on the @@ -174,7 +174,7 @@ else already reads `OPENAI_*`; if you don't care, set only `OPENAI_*`. Set neither of those and you get that class's own fallbacks, `gpt-4o` at `https://api.openai.com/v1/responses`, and a line on stderr saying so. Those are inherited defaults, not suggestions: the -[Results](../../src/webwright/skill_factory/README.md#-results) ran on a much newer model, and +[Results](../../src/cuawright/webwright/skill_factory/README.md#-results) ran on a much newer model, and every skill in your library is written by whichever one you leave it on. Name it. **The agent's model reads none of these vars**; nothing outside `llm.py` does. On a custom diff --git a/licenses/OSWorld-runtime-APACHE-2.0.txt b/licenses/OSWorld-runtime-APACHE-2.0.txt new file mode 100644 index 00000000..60d0edcb --- /dev/null +++ b/licenses/OSWorld-runtime-APACHE-2.0.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2024 XLANG NLP Lab + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/pyproject.toml b/pyproject.toml index fb4d113b..1d0db803 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,12 +1,14 @@ [build-system] -requires = ["setuptools>=61", "wheel"] +requires = ["setuptools>=77", "wheel"] build-backend = "setuptools.build_meta" [project] -name = "webwright" -version = "0.1.0" -description = "Webwright: tiny SWE-style web agent harness" +name = "cuawright" +version = "0.2.0" +description = "CUAWright: terminal-driven browser and desktop agents" requires-python = ">=3.10" +license = "MIT AND Apache-2.0" +license-files = ["LICENSE", "NOTICE", "licenses/*.txt"] dependencies = [ "httpx>=0.27", "jinja2>=3.1", @@ -19,11 +21,22 @@ dependencies = [ "platformdirs>=4.0", ] +[project.optional-dependencies] +desktop = ["openai>=2.0,<3; python_version >= '3.12'"] +test = ["pytest>=8", "black==25.1.0"] +build = ["build>=1", "wheel"] + [project.scripts] -webwright = "webwright.run.cli:app" +cuawright-web = "cuawright.webwright.run.cli:app" +cuawright-desktop = "cuawright.desktop.run.cli:main" +webwright = "cuawright.webwright.run.cli:app" [tool.setuptools.packages.find] where = ["src"] [tool.setuptools.package-data] -webwright = ["config/*.yaml", "config/**/*.yaml"] +"cuawright.webwright" = ["config/*.yaml", "config/**/*.yaml"] +"cuawright.desktop" = ["build_provenance.json"] + +[tool.pytest.ini_options] +testpaths = ["tests", "release/osworld/tests"] diff --git a/release/osworld/tests/test_failures.py b/release/osworld/tests/test_failures.py new file mode 100644 index 00000000..6550a8e4 --- /dev/null +++ b/release/osworld/tests/test_failures.py @@ -0,0 +1,353 @@ +import contextlib +import hashlib +import io +import json +import os +import shlex +import shutil +import subprocess +import sys +import time +import types +import urllib.error +import uuid +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[3] +if os.environ.get("CUAWRIGHT_TEST_INSTALLED") != "1": + sys.path.insert(0, str(ROOT / "src")) + +from cuawright.desktop import exceptions +from cuawright.desktop.agents import default as actor +from cuawright.desktop.config import prompts +from cuawright.desktop.environments.osworld import guest_tools as guest +from cuawright.desktop.models.utils import ( + actions_toolcall_response as protocol, +) +from cuawright.desktop.run.benchmarks import osworld as runner +from cuawright.desktop.utils import artifacts as storage + + +@pytest.fixture +def workspace(): + path = ROOT / "release/osworld/.scratch" / uuid.uuid4().hex + path.mkdir(parents=True) + try: + yield path + finally: + shutil.rmtree(path) + + +def env(): + return types.SimpleNamespace( + controller=types.SimpleNamespace(http_server="http://localhost:5000"), + user_simulator=None, + is_environment_used=False, + ) + + +def test_live_local_command_timeout_terminates_process_group(): + def local_post(payload, timeout, limit): + value = subprocess.run( + payload["command"], capture_output=True, text=True, timeout=timeout + ) + return { + "returncode": value.returncode, + "output": value.stdout, + "error": value.stderr, + } + + bridge = guest.Guest(env(), post=local_post) + start = time.monotonic() + result = bridge.run("sleep 30 & wait", 1) + assert result.exit_code == 124 + assert time.monotonic() - start < 6 + assert "sending signal TERM" in result.stderr + + +@pytest.mark.parametrize( + "body", + [ + b"bad JSON", + b"[]", + b'{"returncode":true,"output":"","error":""}', + b'{"returncode":0,"output":1,"error":""}', + ], +) +def test_invalid_guest_transport_responses_fail_explicitly(monkeypatch, body): + class Response: + def __enter__(self): + return self + + def __exit__(self, *_): + pass + + def read(self, limit): + return body + + monkeypatch.setattr(guest.urllib.request, "urlopen", lambda *_args, **_: Response()) + result = guest.Guest(env()).run("true", 1) + assert result.exit_code == -1 + assert "guest" in result.stderr.lower() + + +def test_http_error_diagnostics_cannot_leak_secret(monkeypatch): + def failed(*_args, **_kwargs): + raise urllib.error.URLError("PRIVATE-CREDENTIAL") + + monkeypatch.setattr(guest.urllib.request, "urlopen", failed) + result = guest.Guest(env()).run("true", 1) + assert result.exit_code == -1 + assert "PRIVATE-CREDENTIAL" not in result.stderr + + +def test_executed_client_script_hash_and_symlink_protection(workspace): + root = workspace / "tools" + root.mkdir() + path = root / "submit.py" + path.write_bytes(guest.CLIENTS["submit"]) + script = guest.EXECUTE_CLIENT.replace( + 'Path("/opt/cuawright-tools")', f"Path({str(root)!r})" + ).replace("info.st_uid != 0", "info.st_uid != os.getuid()") + payload = {"name": "submit", "args": [], "sha256": guest.CLIENT_HASHES["submit"]} + + def execute(): + return subprocess.run( + [sys.executable, "-c", guest.materialize(script, payload)], + capture_output=True, + text=True, + ) + + result = execute() + assert result.returncode == 0 + assert json.loads(result.stdout)["operation"] == "submit" + path.write_text("print('forged')") + assert execute().returncode != 0 + path.unlink() + other = workspace / "other.py" + other.write_bytes(guest.CLIENTS["submit"]) + path.symlink_to(other) + assert execute().returncode != 0 + path.unlink() + path.write_bytes(guest.CLIENTS["submit"]) + path.chmod(0o666) + assert execute().returncode != 0 + path.chmod(0o644) + root.chmod(0o777) + assert execute().returncode != 0 + root.chmod(0o755) + + +def test_no_user_simulator_is_explicit_control_error(): + bridge = guest.Guest(env()) + bridge.python = lambda *_: protocol.Result( + stdout='{"cuawright_control":1,"operation":"ask_user","question":"Why?"}' + ) + result = bridge.run('python /opt/cuawright-tools/ask_user.py --question "Why?"', 1) + assert result.exit_code == 2 + assert "No official user simulator" in result.stderr + + +def test_provisioning_failure_is_not_silently_ignored(): + bridge = guest.Guest(env()) + bridge.execute = lambda *_: protocol.Result(1, stderr="PRIVATE-CREDENTIAL") + with pytest.raises(exceptions.ReleaseError, match="provisioning") as error: + bridge.provision() + assert "PRIVATE-CREDENTIAL" not in str(error.value) + + +def test_control_provisioning_preserves_pre_reset_usage_state(): + environment = env() + environment.is_environment_used = False + bridge = guest.Guest(environment) + bridge.post = lambda *_: { + "returncode": 0, + "output": "installed\n", + "error": "", + } + bridge.provision_controls() + assert environment.is_environment_used is False + + +@pytest.mark.parametrize("password", [None, "private' $(not-executed) \""]) +def test_provisioning_uses_only_configured_administrative_credential(password): + environment = env() + environment.client_password = password + bridge = guest.Guest(environment) + commands = [] + + def execute(argv, timeout): + commands.append(argv) + if len(commands) == 1: + assert timeout == 90 + return protocol.Result(stdout="installed\n") + if len(commands) == 2: + assert timeout == 240 + return protocol.Result() + assert timeout == 30 + return protocol.Result(stdout="6000000\n") + + bridge.execute = execute + bridge.provision() + payload = { + name: guest.base64.b64encode(source).decode() + for name, source in guest.CLIENTS.items() + } + script = guest.materialize(guest.INSTALL, payload) + if password is None: + assert commands[0] == ["sudo", "-n", "/usr/bin/python3", "-c", script] + else: + assert commands[0][:2] == ["/bin/bash", "-c"] + assert shlex.split(commands[0][2]) == [ + "printf", + "%s\\n", + password, + "|", + "sudo", + "-S", + "-p", + "", + "/usr/bin/python3", + "-c", + script, + ] + + +@pytest.mark.parametrize("password", ["", 1, "PRIVATE\nCREDENTIAL"]) +def test_invalid_administrative_credential_fails_before_guest_execution(password): + environment = env() + environment.client_password = password + bridge = guest.Guest(environment) + bridge.execute = lambda *_: pytest.fail("invalid credential reached guest") + with pytest.raises( + exceptions.ReleaseError, match="administrative credential" + ) as error: + bridge.provision() + assert str(error.value) == "invalid guest administrative credential" + + +@pytest.mark.parametrize("reasoning", ["high", "xhigh", "max"]) +def test_real_openai_sdk_request_uses_standard_fields(workspace, reasoning): + import httpx + from openai import OpenAI + + captured = [] + + def respond(request): + captured.append(json.loads(request.content)) + command = ( + "echo first" + if len(captured) == 1 + else "python /opt/cuawright-tools/submit.py" + ) + return httpx.Response( + 200, + json={ + "id": "response-id", + "object": "response", + "created_at": 0, + "model": "test-model", + "status": "completed", + "output": [ + { + "id": "function-id", + "type": "function_call", + "call_id": "call-id", + "name": "run_command", + "arguments": json.dumps({"command": command, "timeout": 1}), + } + ], + }, + ) + + client = OpenAI( + api_key="FAKE-TEST-KEY", + base_url="https://local.invalid/v1", + max_retries=0, + http_client=httpx.Client(transport=httpx.MockTransport(respond)), + ) + bridge = guest.Guest(env()) + bridge.post = lambda *_: {"returncode": 0, "output": "ordinary", "error": ""} + bridge.python = lambda *_: protocol.Result( + stdout='{"cuawright_control":1,"operation":"submit"}' + ) + settings = types.SimpleNamespace( + steps=2, + compact_every=0, + model="test-model", + reasoning=reasoning, + max_output_tokens=32768, + ) + try: + runtime = actor.Actor( + client, bridge, storage.Store(workspace / "results"), "Do X.", settings + ) + assert runtime.run()["stop_reason"] == "submitted" + finally: + client.close() + assert len(captured) == 2 + assert set(captured[0]) == { + "model", + "input", + "tools", + "parallel_tool_calls", + "reasoning", + "include", + "store", + "max_output_tokens", + } + assert captured[0]["tools"] == protocol.TOOLS + assert captured[0]["reasoning"]["effort"] == reasoning + assert captured[0]["input"][0] == actor.message("system", prompts.ACTOR) + previous_call = next( + item for item in captured[1]["input"] if item.get("type") == "function_call" + ) + assert "status" not in previous_call + assert all(value is not None for value in previous_call.values()) + + +def test_retained_prompt_fragments_match_baseline_without_legacy_imports(): + # The minimal reliability condition restores explicit browser capability + # guidance while retaining the compact Actor and compaction contract. + expected = { + "ACTOR": "3943ef31e92bcaa7068e6f74a10695fb896ce92826bd5034a674dac46bff0e90", + "COMPACTION": "e899c5d37d1f46a5193614b40a32686031ffa7b8f5a8e2f5e124ca73b6b9d739", + } + for name, golden in expected.items(): + assert hashlib.sha256(getattr(prompts, name).encode()).hexdigest() == golden + + +def test_store_atomic_failure_and_trace_failure_are_explicit(workspace, monkeypatch): + store = storage.Store(workspace / "results") + store.write("result.json", {"status": "old"}) + original = storage.os.replace + + def failed(*_): + raise OSError("disk full") + + monkeypatch.setattr(storage.os, "replace", failed) + with pytest.raises(exceptions.ReleaseError, match="persistence"): + store.write("result.json", {"status": "new"}) + assert json.loads((store.root / "result.json").read_text())["status"] == "old" + monkeypatch.setattr(storage.os, "replace", original) + store.trace.symlink_to(workspace / "outside") + with pytest.raises(exceptions.ReleaseError, match="trace"): + store.event("unsafe", {}) + + +def test_quiet_external_suppresses_stdout_stderr_and_restores_logging(): + import logging + + previous = logging.root.manager.disable + out = io.StringIO() + err = io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + with pytest.raises(ValueError): + with runner.quiet_external(): + print("HIDDEN") + print("HIDDEN", file=sys.stderr) + raise ValueError("HIDDEN") + assert not out.getvalue() and not err.getvalue() + assert logging.root.manager.disable == previous diff --git a/release/osworld/tests/test_release.py b/release/osworld/tests/test_release.py new file mode 100644 index 00000000..188f0415 --- /dev/null +++ b/release/osworld/tests/test_release.py @@ -0,0 +1,1111 @@ +import base64 +import copy +import hashlib +import json +import os +import shutil +import subprocess +import sys +import types +import uuid +from dataclasses import replace +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[3] +if os.environ.get("CUAWRIGHT_TEST_INSTALLED") != "1": + sys.path.insert(0, str(ROOT / "src")) + +from cuawright.desktop import exceptions +from cuawright.desktop.agents import default as actor +from cuawright.desktop.config import prompts +from cuawright.desktop.environments.osworld import guest_tools as guest +from cuawright.desktop.environments.osworld import source +from cuawright.desktop.models.utils import ( + actions_toolcall_response as protocol, +) +from cuawright.desktop.run.benchmarks import osworld as runner +from cuawright.desktop.utils import artifacts as storage + + +@pytest.fixture +def workspace(): + path = ROOT / "release/osworld/.scratch" / uuid.uuid4().hex + path.mkdir(parents=True) + try: + yield path + finally: + shutil.rmtree(path) + + +def settings(path, **kwargs): + for name in ("source", "tasks", "assets"): + (path / name).mkdir(exist_ok=True) + (path / "vm.qcow2").write_bytes(b"fake-vm") + (path / "key").write_text("SECRET-NOT-FOR-LOGS") + (path / "key").chmod(0o600) + return runner.Settings( + source=path / "source", + tasks=path / "tasks", + assets=path / "assets", + vm=path / "vm.qcow2", + credentials=path / "key", + results=path / "results", + task_id="001", + model="test-model", + **kwargs, + ) + + +def function(command, call_id="c1", timeout=30): + return { + "type": "function_call", + "call_id": call_id, + "name": "run_command", + "arguments": json.dumps({"command": command, "timeout": timeout}), + } + + +def text(value): + return { + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": value}], + } + + +class FakeClient: + def __init__(self, responses): + self.responses = self + self.queue = list(responses) + self.requests = [] + self.closed = False + + def create(self, **request): + self.requests.append(copy.deepcopy(request)) + value = self.queue.pop(0) + if isinstance(value, Exception): + raise value + if isinstance(value, list): + value = {"status": "completed", "output": value} + return types.SimpleNamespace(model_dump=lambda **_: value) + + def close(self): + self.closed = True + + +class FakeEnv: + instances = [] + initialization_error = False + setup_error = False + evaluation_error = False + cleanup_error = False + evaluation_value = {"score": 1.0, "hidden_answer": "HIDDEN-EVALUATOR-ANSWER"} + + def __init__(self, **kwargs): + self.instances.append(self) + self.kwargs = kwargs + self.closed = False + self.user_simulator = types.SimpleNamespace(respond=lambda _: "Official reply") + self.controller = types.SimpleNamespace(http_server="http://localhost:5000") + self.setup_controller = types.SimpleNamespace() + self.is_environment_used = False + self.enable_proxy = kwargs.get("enable_proxy", False) + self.action_history = [] + self._step_no = 0 + self._traj_no = 0 + if self.initialization_error: + raise RuntimeError("HIDDEN-EVALUATOR-ANSWER") + + def reset(self, task_config): + self.task = task_config + if self.setup_error: + raise RuntimeError("HIDDEN-EVALUATOR-ANSWER") + + def evaluate(self): + self.evaluated = True + print("HIDDEN-EVALUATOR-ANSWER") + if self.evaluation_error: + raise RuntimeError("HIDDEN-EVALUATOR-ANSWER") + return self.evaluation_value + + def close(self): + self.closed = True + if self.cleanup_error: + raise RuntimeError("SECRET-NOT-FOR-LOGS") + + +class FakeGuest(guest.Guest): + def __init__(self, env): + super().__init__(env, post=self.fake_post) + self.payloads = [] + + def fake_post(self, payload, timeout, limit): + self.payloads.append(payload) + argv = payload["command"][5:] + if argv[0] == "sudo": + return {"returncode": 0, "output": "installed\n", "error": ""} + if argv[0] == "/usr/bin/python3": + script = argv[2] + encoded = script.split("base64.b64decode(", 1)[1].split(")", 1)[0] + data = json.loads(base64.b64decode(__import__("ast").literal_eval(encoded))) + if 'stream.read(payload["limit"] + 1)' in script: + output = base64.b64encode(b"\x89PNG\r\n\x1a\nfake").decode() + else: + request = {"cuawright_control": 1, "operation": data["name"]} + if data["name"] == "image_show": + request["path"] = data["args"][1] + if data["name"] == "ask_user": + request["question"] = data["args"][1] + output = json.dumps(request) + return {"returncode": 0, "output": output, "error": ""} + return {"returncode": 0, "output": "ordinary output", "error": ""} + + +@pytest.fixture +def official(monkeypatch): + class BaseTask(dict): + def evaluate(self, env): + raise NotImplementedError + + class Task(BaseTask): + def evaluate(self, env): + return 1.0 + + task = Task( + id="001", + instruction="Save the requested document.", + platform="linux", + evaluator={"hidden_answer": "HIDDEN-EVALUATOR-ANSWER"}, + user_simulator={"private_facts": "HIDDEN-EVALUATOR-ANSWER"}, + ) + for flag in ( + "initialization_error", + "setup_error", + "evaluation_error", + "cleanup_error", + ): + monkeypatch.setattr(FakeEnv, flag, False) + monkeypatch.setattr( + FakeEnv, + "evaluation_value", + {"score": 1.0, "hidden_answer": "HIDDEN-EVALUATOR-ANSWER"}, + ) + FakeEnv.instances.clear() + monkeypatch.setattr(runner, "install_compatibility", lambda: None) + modules = { + "desktop_env.desktop_env": types.SimpleNamespace(DesktopEnv=FakeEnv), + "desktop_env.task_base": types.SimpleNamespace(BaseTask=BaseTask), + "task_loader": types.SimpleNamespace( + find_task_class_path=lambda task_id, tasks, *_: str( + Path(tasks) / f"task_{task_id}.py" + ), + load_task_from_file=lambda _: task, + ), + } + for name, module in modules.items(): + monkeypatch.setitem(sys.modules, name, module) + monkeypatch.setattr( + runner, + "activate", + lambda _: {"commit": source.COMMIT, "release": source.RELEASE}, + ) + return task + + +def prepare_task(options): + (options.tasks / "task_001.py").write_text("# trusted fake official task\n") + (options.assets / "asset").write_bytes(b"asset") + + +def test_end_to_end_setup_image_question_compaction_submission_cleanup( + workspace, official, capsys +): + options = settings(workspace, steps=8, compact_every=2) + prepare_task(options) + client = FakeClient( + [ + [function("echo first", "c1"), function("echo second", "c2")], + [ + function( + "python /opt/cuawright-tools/image_show.py --path /home/user/a.png" + ) + ], + [text("Current stage:\nVerify and submit.")], + [ + function( + 'python /opt/cuawright-tools/ask_user.py --question "Which file?"' + ) + ], + [function("python /opt/cuawright-tools/submit.py")], + ] + ) + previous = os.environ.get("OPENAI_API_KEY") + outcome = runner.run( + options, client_factory=lambda *_: client, guest_factory=FakeGuest + ) + assert outcome == { + "stop_reason": "submitted", + "model_calls": 5, + "commands": 5, + "compactions": 1, + "score": 1.0, + } + env = FakeEnv.instances[-1] + assert env.closed and env.evaluated and client.closed + assert env.task is official + assert env.kwargs["provider_name"] == "docker" + assert os.environ.get("OPENAI_API_KEY") == previous + assert client.requests[2]["tools"] == [] + assert "tool_choice" not in client.requests[2] + compacted = client.requests[3]["input"] + assert "Compacted handoff" in compacted[2]["content"][0]["text"] + assert not any(item.get("type") == "function_call_output" for item in compacted) + before_compaction = client.requests[2]["input"] + image = next( + item + for item in before_compaction + if item.get("role") == "user" and len(item.get("content", [])) == 2 + ) + assert image["content"][1]["detail"] == "auto" + assert image["content"][1]["image_url"].startswith("data:image/png;base64,") + recorded = "\n".join(path.read_text() for path in options.results.glob("*.json*")) + assert "HIDDEN-EVALUATOR-ANSWER" not in recorded + assert "SECRET-NOT-FOR-LOGS" not in recorded + assert ( + "credentials" + not in json.loads((options.results / "manifest.json").read_text())["settings"] + ) + assert "HIDDEN-EVALUATOR-ANSWER" not in capsys.readouterr().out + + +def test_golden_request_exact_schema_flags_messages_budget(workspace): + options = settings(workspace, steps=1, compact_every=0) + client = FakeClient([[function("python /opt/cuawright-tools/submit.py")]]) + runtime = actor.Actor( + client, + FakeGuest(FakeEnv()), + storage.Store(options.results), + "Do exactly X.", + options, + ) + assert runtime.run()["model_calls"] == 1 + request = client.requests[0] + assert request == { + "model": "test-model", + "input": [ + { + "role": "system", + "content": [{"type": "input_text", "text": prompts.ACTOR}], + }, + { + "role": "user", + "content": [ + { + "type": "input_text", + "text": "Original task instruction:\nDo exactly X.\n\n" + "You are the persistent Actor. Complete the task directly from first " + "inspection through final submission.", + } + ], + }, + { + "role": "user", + "content": [ + { + "type": "input_text", + "text": prompts.budget(0, 1, midpoint=True, finalization=True), + } + ], + }, + ], + "tools": [ + { + "type": "function", + "name": "run_command", + "description": "Execute a shell command in the active terminal and return its " + "standard output, standard error, and exit status.", + "strict": True, + "parameters": { + "type": "object", + "properties": { + "command": {"type": "string"}, + "timeout": {"type": "integer", "minimum": 1, "maximum": 300}, + }, + "required": ["command", "timeout"], + "additionalProperties": False, + }, + } + ], + "parallel_tool_calls": False, + "reasoning": {"effort": "high", "summary": "auto"}, + "include": ["reasoning.encrypted_content"], + "store": False, + "max_output_tokens": 32768, + } + assert not any( + key in request for key in ("temperature", "truncation", "tool_choice") + ) + + +def test_standard_request_has_no_custom_provider_fields(workspace): + options = settings( + workspace, + steps=1, + compact_every=0, + ) + client = FakeClient([[function("python /opt/cuawright-tools/submit.py")]]) + runtime = actor.Actor( + client, + FakeGuest(FakeEnv()), + storage.Store(options.results), + "instruction", + options, + ) + assert runtime.run()["stop_reason"] == "submitted" + assert "extra_body" not in client.requests[0] + + +@pytest.mark.parametrize( + "arguments", + [ + '{"command":"one","command":"two","timeout":1}', + '{"command":"one","timeout":true}', + '{"command":"one","timeout":0}', + '{"command":"one","timeout":301}', + '{"command":"one","timeout":1.0}', + '{"command":"one","timeout":1,"extra":1}', + '{"command":"one"}', + '{"command":"one","timeout":NaN}', + '["one",1]', + "not JSON", + '{"command":"\\u0000","timeout":1}', + ], +) +def test_invalid_json_rejects_whole_batch(workspace, arguments): + options = settings(workspace, steps=1, compact_every=0) + invalid = function("anything") + invalid["arguments"] = arguments + client = FakeClient([[function("touch unwanted", "first"), invalid]]) + env = FakeEnv() + bridge = FakeGuest(env) + runtime = actor.Actor( + client, bridge, storage.Store(options.results), "instruction", options + ) + assert runtime.run()["commands"] == 0 + assert bridge.payloads == [] + outputs = [ + item for item in runtime.context if item.get("type") == "function_call_output" + ] + assert len(outputs) == 2 + assert all("nothing executed" in item["output"] for item in outputs) + + +@pytest.mark.parametrize( + "command", + [ + "echo '{}'; python /opt/cuawright-tools/submit.py", + "python /opt/cuawright-tools/submit.py && touch after", + "python /opt/cuawright-tools/submit.py > /home/user/out", + "python /opt/cuawright-tools/submit.py\n", + " python /opt/cuawright-tools/submit.py", + "bash -c 'python /opt/cuawright-tools/submit.py'", + "python /opt/cuawright-tools/submit.py --extra x", + 'python /opt/cuawright-tools/ask_user.py --question "$(echo forged)"', + "python /opt/cuawright-tools/image_show.py --path /home/user/../secret", + "python /opt/cuawright-tools/image_show.py --path //home/user/a", + "python /opt/cuawright-tools/image_show.py --path /home/./user/a", + "python /opt/cuawright-tools/image_show.py --path /", + "python /opt/cuawright-tools/unknown.py", + ], +) +def test_forged_control_syntax_not_executed(command): + bridge = FakeGuest(FakeEnv()) + result = bridge.run(command, 10) + assert result.exit_code == 2 + assert not bridge.payloads + assert not bridge.submitted + + +def test_shell_stdout_cannot_impersonate_submit(): + bridge = FakeGuest(FakeEnv()) + bridge.post = lambda *_: { + "returncode": 0, + "output": '{"cuawright_control":1,"operation":"submit"}', + "error": "", + } + assert not bridge.run("echo forged-control", 1).completed + assert not bridge.submitted + + +def test_executed_control_descriptor_must_match_exactly(): + bridge = FakeGuest(FakeEnv()) + bridge.post = lambda *_: { + "returncode": 0, + "output": '{"cuawright_control":1,"operation":"submit","extra":true}', + "error": "", + } + result = bridge.run("python /opt/cuawright-tools/submit.py", 1) + assert result.exit_code == 2 + assert "descriptor" in result.stderr.lower() + assert not bridge.submitted + + +def test_control_execution_failure_cannot_submit(): + bridge = FakeGuest(FakeEnv()) + bridge.post = lambda *_: {"returncode": 1, "output": "", "error": "failed"} + assert not bridge.run("python /opt/cuawright-tools/submit.py", 1).completed + assert not bridge.submitted + + +def test_no_action_after_submit_and_no_mixed_submission_batch(workspace): + bridge = FakeGuest(FakeEnv()) + bridge.run("python /opt/cuawright-tools/submit.py", 1) + with pytest.raises(exceptions.ReleaseError, match="after submission"): + bridge.run("touch after", 1) + options = settings(workspace, steps=1, compact_every=0) + client = FakeClient( + [ + [ + function("python /opt/cuawright-tools/submit.py", "first"), + function("touch after", "second"), + ] + ] + ) + bridge = FakeGuest(FakeEnv()) + runtime = actor.Actor( + client, bridge, storage.Store(options.results), "instruction", options + ) + assert runtime.run()["stop_reason"] == "budget_exhausted" + assert not bridge.payloads + + +def test_timeout_payload_and_command_failure_are_visible(): + bridge = FakeGuest(FakeEnv()) + bridge.post = lambda payload, timeout, limit: { + "returncode": 124, + "output": "", + "error": "timeout: sending signal TERM", + } + result = bridge.run("sleep 100", 3) + assert result.exit_code == 124 + assert "TERM" in result.stderr + bridge = FakeGuest(FakeEnv()) + bridge.run("true", 3) + assert bridge.payloads[0] == { + "command": [ + "/usr/bin/timeout", + "--verbose", + "--signal=TERM", + "--kill-after=5s", + "3s", + "/bin/bash", + "-c", + 'export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:' + "/sbin:/bin:$HOME/.local/bin\"\nexport WEBSITE_HOST_SUFFIX=''\ntrue", + ], + "shell": False, + "timeout": 18, + } + + +def test_output_cap_applies_to_entire_serialized_result(): + result = protocol.Result(stdout="A" * 40000, stderr="B" * 40000) + value = result.text() + assert len(value) <= protocol.OUTPUT_LIMIT + assert "OUTPUT TRUNCATED" in value + assert value.startswith('{"exit_code":0') + assert value.endswith('"completed":false}') + + +def test_budget_continuation_and_compaction_use_shared_calls(workspace): + options = settings(workspace, steps=5, compact_every=2) + client = FakeClient( + [ + { + "status": "incomplete", + "output": [], + "incomplete_details": {"reason": "max_output_tokens"}, + }, + [function("echo work")], + [text("handoff")], + [function("echo last")], + [function("python /opt/cuawright-tools/submit.py")], + ] + ) + runtime = actor.Actor( + client, + FakeGuest(FakeEnv()), + storage.Store(options.results), + "instruction", + options, + ) + assert runtime.run() == { + "stop_reason": "submitted", + "model_calls": 5, + "commands": 3, + "compactions": 1, + } + assert client.requests[2]["tools"] == [] + assert ( + "2/5 used; 3 remain" in client.requests[3]["input"][-1]["content"][0]["text"] + or "3/5 used; 2 remain" in client.requests[3]["input"][-1]["content"][0]["text"] + ) + + +@pytest.mark.parametrize("stage", ["setup", "evaluation", "cleanup", "initialization"]) +def test_official_failures_persist_without_hidden_state_and_cleanup( + workspace, official, monkeypatch, stage +): + options = settings(workspace, steps=1, compact_every=0) + prepare_task(options) + monkeypatch.setattr(FakeEnv, f"{stage}_error", True) + client = FakeClient([[function("python /opt/cuawright-tools/submit.py")]]) + with pytest.raises(exceptions.ReleaseError): + runner.run(options, client_factory=lambda *_: client, guest_factory=FakeGuest) + assert FakeEnv.instances[-1].closed + payload = json.loads((options.results / "result.json").read_text()) + assert payload["status"] == "failed" + assert "HIDDEN-EVALUATOR-ANSWER" not in json.dumps(payload) + if stage not in ("setup", "initialization"): + assert client.closed + + +def test_provider_error_is_explicit_and_closes_every_resource( + workspace, official, monkeypatch +): + options = settings(workspace, steps=1, compact_every=0) + prepare_task(options) + monkeypatch.setattr(actor.time, "sleep", lambda _: None) + client = FakeClient([RuntimeError("SECRET-NOT-FOR-LOGS")] * 10) + with pytest.raises(exceptions.ReleaseError, match="actor"): + runner.run(options, client_factory=lambda *_: client, guest_factory=FakeGuest) + assert client.closed and FakeEnv.instances[-1].closed + assert len(client.requests) == 10 + assert all(request == client.requests[0] for request in client.requests) + result = (options.results / "result.json").read_text() + assert "SECRET-NOT-FOR-LOGS" not in result + assert json.loads(result)["actor"]["model_calls"] == 0 + + +def test_provider_retry_resends_identical_request(workspace, monkeypatch): + options = settings(workspace, steps=1, compact_every=0) + monkeypatch.setattr(actor.time, "sleep", lambda _: None) + client = FakeClient( + [ + RuntimeError("temporary"), + [function("python /opt/cuawright-tools/submit.py")], + ] + ) + runtime = actor.Actor( + client, + FakeGuest(FakeEnv()), + storage.Store(options.results), + "instruction", + options, + ) + assert runtime.run()["stop_reason"] == "submitted" + assert client.requests[0] == client.requests[1] + events = [ + json.loads(line) + for line in (options.results / "trace.jsonl").read_text().splitlines() + ] + assert [event["kind"] for event in events].count("transport_retry") == 1 + + +def test_policy_retry_removes_rejected_images(workspace, monkeypatch): + options = settings(workspace, steps=1, compact_every=0) + monkeypatch.setattr(actor.time, "sleep", lambda _: None) + client = FakeClient( + [ + RuntimeError("content_policy_violation"), + [function("python /opt/cuawright-tools/submit.py")], + ] + ) + runtime = actor.Actor( + client, + FakeGuest(FakeEnv()), + storage.Store(options.results), + "instruction", + options, + ) + runtime.context.append( + { + "role": "user", + "content": [ + {"type": "input_text", "text": "image"}, + { + "type": "input_image", + "image_url": "data:image/png;base64,eA==", + }, + ], + } + ) + assert runtime.run()["stop_reason"] == "submitted" + assert any( + part.get("type") == "input_image" + for item in client.requests[0]["input"] + for part in item.get("content", []) + if isinstance(part, dict) + ) + assert not any( + part.get("type") == "input_image" + for item in client.requests[1]["input"] + for part in item.get("content", []) + if isinstance(part, dict) + ) + events = [ + json.loads(line) + for line in (options.results / "trace.jsonl").read_text().splitlines() + ] + retry = next(event for event in events if event["kind"] == "transport_retry") + assert retry["data"]["removed_images"] == 1 + + +def test_evaluation_retries_status_errors(workspace, monkeypatch): + class RetryableError(RuntimeError): + status_code = 400 + + attempts = iter((RetryableError("blocked"), {"score": 0.75})) + + def callback(): + value = next(attempts) + if isinstance(value, Exception): + raise value + return value + + monkeypatch.setattr(runner.time, "sleep", lambda _: None) + store = storage.Store(workspace / "results") + assert runner.evaluate(callback, store) == 0.75 + events = [ + json.loads(line) + for line in (workspace / "results/trace.jsonl").read_text().splitlines() + ] + assert [event["kind"] for event in events] == ["evaluation_retry"] + + +def test_incomplete_tool_call_is_returned_without_execution(workspace): + options = settings(workspace, steps=2, compact_every=0) + partial = function("touch /tmp/should-not-run") + client = FakeClient( + [ + { + "status": "incomplete", + "output": [partial], + "incomplete_details": {"reason": "max_output_tokens"}, + }, + [function("python /opt/cuawright-tools/submit.py")], + ] + ) + bridge = FakeGuest(FakeEnv()) + runtime = actor.Actor( + client, + bridge, + storage.Store(options.results), + "instruction", + options, + ) + assert runtime.run()["stop_reason"] == "submitted" + assert not any( + "should-not-run" in json.dumps(payload) for payload in bridge.payloads + ) + output = next( + item + for item in runtime.context + if item.get("type") == "function_call_output" + and item.get("call_id") == partial["call_id"] + ) + assert "output-token limit" in output["output"] + + +def test_persistence_failure_still_cleans_up(workspace, official, monkeypatch): + options = settings(workspace, steps=1, compact_every=0) + prepare_task(options) + original = storage.Store.write + + def failing(self, name, payload): + if name == "result.json": + raise OSError("disk full") + return original(self, name, payload) + + monkeypatch.setattr(storage.Store, "write", failing) + client = FakeClient([[function("python /opt/cuawright-tools/submit.py")]]) + with pytest.raises(exceptions.ReleaseError, match="persistence"): + runner.run(options, client_factory=lambda *_: client, guest_factory=FakeGuest) + assert client.closed and FakeEnv.instances[-1].closed + + +@pytest.mark.parametrize("score", [float("nan"), float("inf"), True, -1, 1.1, None]) +def test_invalid_evaluation_scores_fail(workspace, official, monkeypatch, score): + options = settings(workspace, steps=1, compact_every=0) + prepare_task(options) + monkeypatch.setattr(FakeEnv, "evaluation_value", {"score": score}) + client = FakeClient([[function("python /opt/cuawright-tools/submit.py")]]) + with pytest.raises(exceptions.ReleaseError, match="evaluation"): + runner.run(options, client_factory=lambda *_: client, guest_factory=FakeGuest) + assert FakeEnv.instances[-1].closed and client.closed + + +def test_source_pin_missing_dirty_and_import_activation_order(workspace, monkeypatch): + root = workspace / "source" + root.mkdir() + with pytest.raises(exceptions.ReleaseError, match="Git"): + source.activate(root) + (root / ".git").mkdir() + monkeypatch.setattr(source, "git", lambda *_: "different") + with pytest.raises(exceptions.ReleaseError, match="pin"): + source.activate(root) + monkeypatch.setattr( + source, + "git", + lambda _, *args: source.COMMIT if args == ("rev-parse", "HEAD") else "dirty", + ) + with pytest.raises(exceptions.ReleaseError, match="clean"): + source.activate(root) + (root / "desktop_env").mkdir() + (root / "desktop_env/desktop_env.py").write_text("") + (root / "task_loader.py").write_text("") + monkeypatch.setattr( + source, + "git", + lambda _, *args: source.COMMIT if args == ("rev-parse", "HEAD") else "", + ) + monkeypatch.setitem( + sys.modules, + "desktop_env", + types.SimpleNamespace(__file__="/outside/desktop_env.py"), + ) + with pytest.raises(exceptions.ReleaseError, match="before source activation"): + source.activate(root) + + +def test_task_selection_and_config_are_host_only(workspace, official): + options = settings(workspace) + prepare_task(options) + task, instruction, provenance = source.load_task(options.tasks, "001") + assert task is official + assert instruction == official["instruction"] + assert set(provenance) == {"id", "class_sha256", "source_tree", "config_sha256"} + assert "HIDDEN-EVALUATOR-ANSWER" not in json.dumps(provenance) + official["evaluator"]["hidden_answer"] = "CHANGED-HIDDEN-ANSWER" + _, _, changed = source.load_task(options.tasks, "001") + assert changed["config_sha256"] != provenance["config_sha256"] + assert "CHANGED-HIDDEN-ANSWER" not in json.dumps(changed) + with pytest.raises(exceptions.ReleaseError, match="identity"): + source.load_task(options.tasks, "../001") + official["proxy"] = True + task, _, _ = source.load_task(options.tasks, "001") + assert task["proxy"] is True + + +@pytest.mark.parametrize( + "unsupported", ["proxy", "windows", "phases", "legacy", "json"] +) +def test_unsupported_task_surfaces_fail_before_vm( + workspace, official, monkeypatch, unsupported +): + options = settings(workspace) + prepare_task(options) + if unsupported == "proxy": + official["proxy"] = True + elif unsupported == "windows": + official["platform"] = "windows" + elif unsupported == "phases": + official.get_phases = lambda: [] + elif unsupported == "legacy": + official_type = type(official) + official_type.evaluate = official_type.__bases__[0].evaluate + else: + monkeypatch.setattr( + sys.modules["task_loader"], "find_task_class_path", lambda *_: None + ) + with pytest.raises(exceptions.ReleaseError): + runner.run( + options, client_factory=lambda *_: pytest.fail("model must not start") + ) + assert not FakeEnv.instances + + +def test_proxy_task_uses_external_config(workspace, official, monkeypatch): + options = settings(workspace) + prepare_task(options) + proxy = workspace / "proxy.json" + proxy.write_text("{}") + proxy.chmod(0o600) + official["proxy"] = True + client = FakeClient([[function("python /opt/cuawright-tools/submit.py")]]) + previous = os.environ.get("PROXY_CONFIG_FILE") + outcome = runner.run( + replace(options, proxy_config=proxy), + client_factory=lambda *_: client, + guest_factory=FakeGuest, + ) + assert outcome["score"] == 1.0 + assert FakeEnv.instances[-1].kwargs["enable_proxy"] is True + assert os.environ.get("PROXY_CONFIG_FILE") == previous + manifest = json.loads((options.results / "manifest.json").read_text()) + assert manifest["proxy"]["requested"] is True + assert manifest["proxy"]["config_sha256"] == storage.file_hash(proxy) + assert "proxy_config" not in manifest["settings"] + + +def test_multiphase_task_preserves_actor_and_budget(workspace, official): + options = settings(workspace, steps=4, compact_every=0) + prepare_task(options) + setup_calls = [] + + def phases(): + return [ + { + "name": "One", + "instruction": "Complete phase one.", + "setup": lambda *_args, **_kwargs: None, + "evaluate": lambda _env: 0.4, + "weight": 0.4, + "gate_min_score": 0.4, + }, + { + "name": "Two", + "instruction": "Complete phase two.", + "setup": lambda *_args, **_kwargs: setup_calls.append("two"), + "evaluate": lambda _env: 0.6, + "weight": 0.6, + }, + ] + + official.get_phases = phases + client = FakeClient( + [ + [function("python /opt/cuawright-tools/submit.py", "phase-one")], + [function("python /opt/cuawright-tools/submit.py", "phase-two")], + ] + ) + outcome = runner.run( + options, + client_factory=lambda *_: client, + guest_factory=FakeGuest, + ) + assert outcome["score"] == 1.0 + assert outcome["model_calls"] == 2 + assert len(outcome["phases"]) == 2 + assert setup_calls == ["two"] + assert FakeEnv.instances[-1]._traj_no == 1 + assert any( + "Complete phase two." in item["content"][0]["text"] + for item in client.requests[1]["input"] + if item.get("role") == "user" + ) + + +def test_credentials_permissions_symlinks_and_safe_settings(workspace): + options = settings(workspace) + assert storage.credentials(options.credentials) == "SECRET-NOT-FOR-LOGS" + options.credentials.chmod(0o644) + with pytest.raises(exceptions.ReleaseError, match="0600"): + storage.credentials(options.credentials) + link = workspace / "key-link" + link.symlink_to(options.credentials) + with pytest.raises(OSError): + storage.credentials(link) + with pytest.raises(exceptions.ReleaseError, match="credentials"): + replace(options, base_url="https://secret@example.com/v1").validate() + with pytest.raises(exceptions.ReleaseError, match="credentials"): + replace(options, base_url="https://example.com/v1?key=secret").validate() + options.results.mkdir() + with pytest.raises(exceptions.ReleaseError, match="already exist"): + options.validate() + + +def test_guest_image_script_rejects_symlink_file_and_parent(workspace): + real = workspace / "real" + real.mkdir() + image = real / "a.png" + image.write_bytes(b"\x89PNG\r\n\x1a\nfake") + linked_file = real / "link.png" + linked_file.symlink_to(image) + linked_dir = workspace / "linked" + linked_dir.symlink_to(real, target_is_directory=True) + for path in (image, linked_file, linked_dir / "a.png"): + result = subprocess.run( + [ + sys.executable, + "-c", + guest.materialize(guest.READ_IMAGE, {"path": str(path), "limit": 100}), + ], + capture_output=True, + check=False, + ) + assert (result.returncode == 0) == (path == image) + + +def test_guest_read_bridge_rejects_special_and_oversized_files(workspace): + large = workspace / "large.png" + large.write_bytes(b"x" * 101) + fifo = workspace / "fifo" + os.mkfifo(fifo) + for path in (large, fifo): + result = subprocess.run( + [ + sys.executable, + "-c", + guest.materialize(guest.READ_IMAGE, {"path": str(path), "limit": 100}), + ], + capture_output=True, + timeout=5, + check=False, + ) + assert result.returncode != 0 + + +def test_guest_client_sources_execute_and_exactly_match_commands(): + for name, arguments, extra in [ + ("submit", [], {}), + ("image_show", ["--path", "/home/user/a.png"], {"path": "/home/user/a.png"}), + ("ask_user", ["--question", "Which?"], {"question": "Which?"}), + ]: + result = subprocess.run( + [ + sys.executable, + str(Path(guest.__file__).parent / "control_clients" / f"{name}.py"), + *arguments, + ], + capture_output=True, + text=True, + check=True, + ) + assert json.loads(result.stdout) == { + "cuawright_control": 1, + "operation": name, + **extra, + } + + +def test_runtime_closure_line_threshold_and_no_legacy_imports(): + import ast + + package = ROOT / "src/cuawright/desktop" + files = sorted(package.rglob("*.py")) + counts = { + str(path.relative_to(package)): len(path.read_bytes().splitlines()) + for path in files + } + assert sum(counts.values()) <= 3000, counts + assert len(files) >= 13 + for path in files: + tree = ast.parse(path.read_text()) + for node in ast.walk(tree): + if isinstance(node, ast.Import): + assert all( + not item.name.startswith("cuawright.") for item in node.names + ) + elif isinstance(node, ast.ImportFrom): + if node.level: + assert node.level <= len(path.relative_to(package).parts) + else: + assert not (node.module or "").startswith("cuawright.") + print("Runtime physical lines:", sum(counts.values()), counts) + + +def test_help_does_not_import_osworld_or_openai(): + command = ( + "import sys; from cuawright.desktop.run.cli import main; " + "assert 'desktop_env' not in sys.modules; assert 'openai' not in sys.modules; " + "main(['--help'])" + ) + result = subprocess.run( + [ + sys.executable, + *(["-I"] if os.environ.get("CUAWRIGHT_TEST_INSTALLED") == "1" else []), + "-c", + command, + ], + env={**os.environ, "PYTHONPATH": str(ROOT / "src")}, + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0 + assert "--credentials" in result.stdout + assert "xhigh" in result.stdout + + +def test_installed_wheel_origin_and_contents_when_available(): + import zipfile + + if os.environ.get("CUAWRIGHT_TEST_INSTALLED") == "1": + assert ".wheel-env" in str(Path(runner.__file__).resolve()) + assert storage.harness_provenance()["runtime"] == storage.runtime_inventory( + ROOT / "src/cuawright/desktop" + ) + for name in ("agents", "config", "environments", "models", "run", "cli"): + module = f"cuawright.{name}" + assert module not in sys.modules + assert __import__("importlib").util.find_spec(module) is None + wheels = list((ROOT / "release/osworld/dist").glob("*.whl")) + if not wheels: + assert os.environ.get("CUAWRIGHT_TEST_INSTALLED") != "1" + pytest.skip("build wheel before distribution inspection") + with zipfile.ZipFile(wheels[0]) as wheel: + python_files = [name for name in wheel.namelist() if name.endswith(".py")] + expected = { + path.relative_to(ROOT / "src").as_posix() + for path in (ROOT / "src").rglob("*.py") + } + assert set(python_files) == expected + assert all( + name.startswith(("cuawright/", "webwright/")) or ".dist-info/" in name + for name in wheel.namelist() + ) + for name in python_files: + assert wheel.read(name) == (ROOT / "src" / name).read_bytes() + provenance = json.loads(wheel.read("cuawright/desktop/build_provenance.json")) + assert provenance["runtime"] == storage.runtime_inventory( + ROOT / "src/cuawright/desktop" + ) + + +def test_root_metadata_includes_web_and_desktop(): + import tomllib + + metadata = tomllib.loads((ROOT / "pyproject.toml").read_text()) + project = metadata["project"] + assert project["name"] == "cuawright" + assert project["scripts"] == { + "cuawright-web": "cuawright.webwright.run.cli:app", + "cuawright-desktop": "cuawright.desktop.run.cli:main", + "webwright": "cuawright.webwright.run.cli:app", + } + assert project["optional-dependencies"]["desktop"] == [ + "openai>=2.0,<3; python_version >= '3.12'" + ] + assert metadata["tool"]["pytest"]["ini_options"]["testpaths"] == [ + "tests", + "release/osworld/tests", + ] + assert (ROOT / "src/cuawright/webwright/run/cli.py").is_file() + assert (ROOT / "licenses/OSWorld-runtime-APACHE-2.0.txt").is_file() + + +def test_custom_endpoint_and_credentials_are_not_public_settings(workspace): + options = settings(workspace, base_url="https://private-provider.invalid/v1") + public = options.public() + assert "base_url" not in public + assert "credentials" not in public + assert "proxy_config" not in public + assert "private-provider.invalid" not in json.dumps(public) + + +def test_desktop_source_contains_only_public_or_loopback_urls(): + import ast + import re + from urllib.parse import urlsplit + + allowed_hosts = {"api.openai.com", "github.com", "127.0.0.1"} + for path in (ROOT / "src/cuawright/desktop").rglob("*.py"): + text = path.read_text() + for url in re.findall(r"https?://[^\s\"'<>`]+", text): + assert urlsplit(url).hostname in allowed_hosts, path + for node in ast.walk(ast.parse(text)): + if isinstance(node, ast.keyword) and node.arg == "api_key": + assert not isinstance(node.value, ast.Constant), path diff --git a/setup.py b/setup.py new file mode 100644 index 00000000..68c9b1ef --- /dev/null +++ b/setup.py @@ -0,0 +1,32 @@ +"""Record immutable runtime provenance in wheels, without modifying source.""" + +import json +import sys +from pathlib import Path + +from setuptools import setup +from setuptools.command.build_py import build_py +from setuptools.command.sdist import sdist + +ROOT = Path(__file__).resolve().parent +sys.path.insert(0, str(ROOT / "src")) +from cuawright.desktop.utils.artifacts import harness_provenance + + +class ProvenanceBuild(build_py): + def run(self): + provenance = harness_provenance() + super().run() + metadata = Path(self.build_lib) / "cuawright/desktop/build_provenance.json" + metadata.write_text(json.dumps(provenance, indent=2) + "\n", encoding="utf-8") + + +class ProvenanceSdist(sdist): + def make_release_tree(self, base_dir, files): + provenance = harness_provenance() + super().make_release_tree(base_dir, files) + metadata = Path(base_dir) / "src/cuawright/desktop/build_provenance.json" + metadata.write_text(json.dumps(provenance, indent=2) + "\n", encoding="utf-8") + + +setup(cmdclass={"build_py": ProvenanceBuild, "sdist": ProvenanceSdist}) diff --git a/skills/webwright/SKILL.md b/skills/cuawright-web/SKILL.md similarity index 92% rename from skills/webwright/SKILL.md rename to skills/cuawright-web/SKILL.md index c70e23cf..5674598a 100644 --- a/skills/webwright/SKILL.md +++ b/skills/cuawright-web/SKILL.md @@ -1,16 +1,20 @@ --- -name: webwright +name: cuawright-web description: Solve a user-specified web task code-as-action style by driving a local Playwright browser through one bash command at a time, saving screenshots and an action log into `final_runs/run_/`, and visually verifying the result. Use when the user asks to automate a web task (search, filter, form-fill, multi-step flow, data extraction) and wants reusable scripts plus screenshot evidence rather than a one-shot answer. allowed-tools: Bash, Read, Write, Edit, bash, read_file, write_file --- -# Webwright (Claude Code adaptation) +# CUAWright browser skill + +This skill drives the Webwright browser subsystem in `cuawright.webwright`. +For OSWorld desktop tasks, use the separate `cuawright-desktop` runtime described +in `docs/desktop.md` at the repository root. You are the Webwright agent. Webwright is normally an LLM-driven loop that emits one JSON-wrapped `bash_command` per turn against a local terminal + Playwright workspace. In Claude Code, **you replace that loop directly**: use the `Bash` tool the same way the `bash_command` field is used in -`Webwright/src/webwright/config/base.yaml`. You do NOT need to wrap your +`src/cuawright/webwright/config/base.yaml`. You do NOT need to wrap your output in JSON β€” that constraint only existed because the original harness parsed model output. @@ -24,19 +28,20 @@ own native abilities**: you read PNGs with `Read` and verify success against - **Default (one-shot).** `final_script.py` solves the task for the literal values the user provided. Triggered by a plain prompt or by - `/webwright:run `. + `/cuawright:run `. - **CLI tool (parameterized).** `final_script.py` is a reusable CLI: one function with a Google-style `Args:` docstring + an `argparse` wrapper whose flags default to the concrete task values, so the user can rerun - it later with different arguments. Triggered by `/webwright:craft ` + it later with different arguments. Triggered by `/cuawright:craft ` or when the user asks to "parameterize", "make it reusable", "turn this into a CLI", etc. See `reference/cli_tool_mode.md`. ## Prerequisites (one-time) -From the Webwright repo root: +From the CUAWright repo root: ```bash +pip install -e . playwright install firefox ``` @@ -95,7 +100,7 @@ Mirror what `base.yaml`'s `instance_template` requires: 4. **Execute** the final script once. Capture stdout/stderr. -5. **Self-verify** (this replaces `webwright.tools.self_reflection`). Walk +5. **Self-verify** (this replaces `cuawright.webwright.tools.self_reflection`). Walk `plan.md`: - For each CP, identify a screenshot path AND/OR a log line that proves it. `Read` each cited PNG and confirm the evidence is unambiguous (the @@ -154,8 +159,8 @@ Mirror what `base.yaml`'s `instance_template` requires: Optional shortcuts under `commands/`: -- `/webwright:run ` β€” default one-shot mode. -- `/webwright:craft ` β€” CLI tool mode. +- `/cuawright:run ` β€” default one-shot mode. +- `/cuawright:craft ` β€” CLI tool mode. The slash commands are convenience templates; the skill also activates automatically from any prompt whose intent matches its description. diff --git a/skills/webwright/commands/craft.md b/skills/cuawright-web/commands/craft.md similarity index 97% rename from skills/webwright/commands/craft.md rename to skills/cuawright-web/commands/craft.md index 82f2a821..d541020f 100644 --- a/skills/webwright/commands/craft.md +++ b/skills/cuawright-web/commands/craft.md @@ -4,7 +4,7 @@ argument-hint: --- You are operating as the Webwright agent in **CLI tool mode**. First read -the `SKILL.md` of the `webwright` skill (the parent directory of this +the `SKILL.md` of the `cuawright-web` skill (the parent directory of this `commands/` folder) and the `reference/cli_tool_mode.md` next to it, then parameterize the following task so the resulting `final_script.py` can be re-run later with different argument values: diff --git a/skills/webwright/commands/run.md b/skills/cuawright-web/commands/run.md similarity index 94% rename from skills/webwright/commands/run.md rename to skills/cuawright-web/commands/run.md index 515f16b0..eea797b0 100644 --- a/skills/webwright/commands/run.md +++ b/skills/cuawright-web/commands/run.md @@ -13,7 +13,7 @@ Task: $ARGUMENTS For the full operating contract, first read the `SKILL.md` of the -`webwright` skill (the parent directory of this `commands/` folder). +`cuawright-web` skill (the parent directory of this `commands/` folder). Then follow the standard Webwright workflow: 1. Pick a `WORKSPACE_DIR` and write `plan.md` with a numbered list of diff --git a/skills/webwright/reference/cli_tool_mode.md b/skills/cuawright-web/reference/cli_tool_mode.md similarity index 96% rename from skills/webwright/reference/cli_tool_mode.md rename to skills/cuawright-web/reference/cli_tool_mode.md index 3d64cad2..7a065f37 100644 --- a/skills/webwright/reference/cli_tool_mode.md +++ b/skills/cuawright-web/reference/cli_tool_mode.md @@ -1,12 +1,12 @@ # CLI Tool Mode -Default Webwright runs (`/webwright:run`, plain prompt) produce a one-shot +Default Webwright runs (`/cuawright:run`, plain prompt) produce a one-shot `final_script.py` that solves the task for the literal values the user -provided. **CLI tool mode** (`/webwright:craft`) instead produces a +provided. **CLI tool mode** (`/cuawright:craft`) instead produces a **reusable, parameterized CLI tool**: the same script can be re-run later with different argument values to perform the same kind of task. -This mode is adapted from `webwright/src/webwright/config/crafted_cli.yaml`'s +This mode is adapted from `src/cuawright/webwright/config/crafted_cli.yaml`'s "Final-Script Shape (CLI Tool, MANDATORY)" contract. The OpenAI-backed `self_reflection` gate is replaced by your own self-verification against `plan.md`. @@ -15,7 +15,7 @@ This mode is adapted from `webwright/src/webwright/config/crafted_cli.yaml`'s Trigger CLI tool mode when: -- the user invokes `/webwright:craft …`, or +- the user invokes `/cuawright:craft …`, or - the user says "make it reusable", "parameterize", "turn this into a CLI", "I want to call this again with different X", or similar. diff --git a/skills/webwright/reference/playwright_patterns.md b/skills/cuawright-web/reference/playwright_patterns.md similarity index 100% rename from skills/webwright/reference/playwright_patterns.md rename to skills/cuawright-web/reference/playwright_patterns.md diff --git a/skills/webwright/reference/workflow.md b/skills/cuawright-web/reference/workflow.md similarity index 95% rename from skills/webwright/reference/workflow.md rename to skills/cuawright-web/reference/workflow.md index b4be97d6..50fd005b 100644 --- a/skills/webwright/reference/workflow.md +++ b/skills/cuawright-web/reference/workflow.md @@ -1,8 +1,8 @@ # Workflow Detailed expansion of the six-step Webwright loop, adapted for Claude Code. -The original loop relied on `webwright.tools.image_qa` for visual QA and -`webwright.tools.self_reflection` for the final verdict. Both are replaced +The original loop relied on `cuawright.webwright.tools.image_qa` for visual QA and +`cuawright.webwright.tools.self_reflection` for the final verdict. Both are replaced here by your native abilities (`Read` on PNG files + reasoning against `plan.md`). No `OPENAI_API_KEY` is required. diff --git a/src/cuawright/__init__.py b/src/cuawright/__init__.py new file mode 100644 index 00000000..f52c1210 --- /dev/null +++ b/src/cuawright/__init__.py @@ -0,0 +1,3 @@ +"""CUAWright: browser and desktop agents.""" + +__version__ = "0.2.0" diff --git a/src/cuawright/desktop/__init__.py b/src/cuawright/desktop/__init__.py new file mode 100644 index 00000000..88148ff9 --- /dev/null +++ b/src/cuawright/desktop/__init__.py @@ -0,0 +1,3 @@ +"""CUAWright desktop runtime with official OSWorld-V2 support.""" + +__version__ = "0.2.0" diff --git a/src/cuawright/desktop/__main__.py b/src/cuawright/desktop/__main__.py new file mode 100644 index 00000000..740a6f0b --- /dev/null +++ b/src/cuawright/desktop/__main__.py @@ -0,0 +1,3 @@ +from .run.cli import main + +raise SystemExit(main()) diff --git a/src/webwright/run/__init__.py b/src/cuawright/desktop/agents/__init__.py similarity index 100% rename from src/webwright/run/__init__.py rename to src/cuawright/desktop/agents/__init__.py diff --git a/src/cuawright/desktop/agents/default.py b/src/cuawright/desktop/agents/default.py new file mode 100644 index 00000000..2362c423 --- /dev/null +++ b/src/cuawright/desktop/agents/default.py @@ -0,0 +1,345 @@ +import base64 +import copy +import hashlib +import time + +from ..config import prompts +from ..environments.osworld.guest_tools import control +from ..exceptions import ReleaseError +from ..models.utils.actions_toolcall_response import ( + TOOLS, + Result, + command_arguments, +) + +RETRY_DELAYS = (2, 4, 8, 16, 30, 45, 60, 90, 120) + + +def policy_error(exc): + text = str(exc).lower() + return any( + marker in text + for marker in ( + "content_policy_violation", + "cyber_policy", + "invalid_prompt", + "usage policy", + "cybersecurity risk", + ) + ) + + +def message(role, text): + return {"role": role, "content": [{"type": "input_text", "text": text}]} + + +def output_text(output): + pieces = [] + for item in output: + if item.get("type") == "message": + for content in item.get("content", []): + if content.get("type") == "output_text": + pieces.append(content["text"]) + return "\n".join(pieces) + + +def normalize_output(output): + normalized = [] + for item in output: + kind = item.get("type") + if kind == "reasoning": + normalized.append( + { + key: value + for key, value in item.items() + if key in ("type", "id", "summary", "encrypted_content") + } + ) + elif kind == "function_call": + normalized.append( + { + key: value + for key, value in item.items() + if key in ("type", "id", "call_id", "name", "arguments", "status") + } + ) + elif kind == "message": + normalized.append( + { + key: value + for key, value in item.items() + if key in ("type", "id", "role", "content", "status") + } + ) + else: + raise ReleaseError("unsupported Responses output type") + return normalized + + +class Actor: + def __init__(self, client, guest, store, instruction, settings): + self.client = client + self.guest = guest + self.store = store + self.settings = settings + self.base_start = [ + message("system", prompts.ACTOR), + message("user", prompts.initial(instruction)), + ] + self.start = copy.deepcopy(self.base_start) + self.context = copy.deepcopy(self.start) + self.used = 0 + self.epoch = 0 + self.commands = 0 + self.compactions = 0 + self.midpoint_sent = False + self.finalization_sent = False + + @property + def remaining(self): + return self.settings.steps - self.used + + def set_phase(self, text): + phase = message("user", text) + self.start = copy.deepcopy(self.base_start) + [copy.deepcopy(phase)] + self.context.append(phase) + + def drop_images(self): + removed = 0 + context = [] + for item in self.context: + content = item.get("content") if isinstance(item, dict) else None + if not isinstance(content, list): + context.append(item) + continue + kept = [] + for part in content: + if isinstance(part, dict) and part.get("type") == "input_image": + removed += 1 + else: + kept.append(part) + context.append({**item, "content": kept}) + if removed: + context.append( + message( + "user", + "A prior image attachment was rejected by the provider. " + "Continue with narrower screenshots, OCR, or metadata.", + ) + ) + self.context = context + return removed + + def call(self, compact=False): + if not self.remaining: + raise ReleaseError("model-call budget exhausted") + request = { + "model": self.settings.model, + "input": copy.deepcopy(self.context), + "tools": [] if compact else copy.deepcopy(TOOLS), + "parallel_tool_calls": False, + "reasoning": {"effort": self.settings.reasoning, "summary": "auto"}, + "include": ["reasoning.encrypted_content"], + "store": False, + "max_output_tokens": self.settings.max_output_tokens, + } + response = None + policy_attempt = 0 + for attempt in range(10): + self.store.event("request", request) + delay = 0 if attempt == 0 else RETRY_DELAYS[attempt - 1] + if delay: + time.sleep(delay) + try: + response = self.client.responses.create(**copy.deepcopy(request)) + break + except Exception as exc: + payload = { + "attempt": attempt + 1, + "exception_type": type(exc).__name__, + "status_code": getattr(exc, "status_code", None), + } + if policy_error(exc): + policy_attempt += 1 + payload["removed_images"] = self.drop_images() + self.context.append( + message("user", prompts.policy_recovery(policy_attempt, 10)) + ) + request["input"] = copy.deepcopy(self.context) + payload["policy_feedback"] = True + if attempt == 9: + self.store.event("transport_failure", payload) + raise ReleaseError("OpenAI Responses transport failed") from None + payload["next_delay_seconds"] = RETRY_DELAYS[attempt] + self.store.event("transport_retry", payload) + assert response is not None + self.used += 1 + self.epoch += 1 + try: + value = response.model_dump(mode="json", exclude_none=True) + except Exception: + raise ReleaseError("invalid Responses response") from None + if not isinstance(value, dict) or not isinstance(value.get("output"), list): + raise ReleaseError("invalid Responses response") + status = value.get("status") + if status not in ("completed", "incomplete"): + raise ReleaseError("Responses request did not complete") + output = normalize_output(value["output"]) + calls = [item for item in output if item.get("type") == "function_call"] + identifiers = [item.get("call_id") for item in calls] + if any(not isinstance(item, str) or not item for item in identifiers) or len( + identifiers + ) != len(set(identifiers)): + raise ReleaseError("invalid or duplicate tool call identities") + self.store.event( + "response", + { + "step": self.used, + "output": output, + "status": status, + "usage": value.get("usage"), + }, + ) + self.context.extend(output) + if status == "incomplete": + details = value.get("incomplete_details") or {} + if details.get("reason") != "max_output_tokens": + raise ReleaseError("unsupported incomplete Responses response") + return output, calls, status + + def add_result(self, call, result): + text = result.text() + self.context.append( + { + "type": "function_call_output", + "call_id": call["call_id"], + "output": text, + } + ) + metadata = {"call_id": call["call_id"], "output": text} + if result.image is not None: + metadata["image"] = { + "sha256": hashlib.sha256(result.image).hexdigest(), + "size_bytes": len(result.image), + "media_type": result.media_type, + "path": result.path, + } + self.store.event("command_result", metadata) + if result.image is not None: + url = ( + f"data:{result.media_type};base64," + + base64.b64encode(result.image).decode() + ) + self.context.append( + { + "role": "user", + "content": [ + { + "type": "input_text", + "text": f"Image read from VM path: {result.path}", + }, + {"type": "input_image", "image_url": url, "detail": "auto"}, + ], + } + ) + + def compact(self): + self.context.append(message("user", prompts.COMPACTION)) + while self.remaining: + output, calls, status = self.call(compact=True) + if calls: + raise ReleaseError("compaction returned a tool call") + if status == "incomplete" and not calls: + continue + summary = output_text(output) + if not summary.strip(): + raise ReleaseError("compaction returned no handoff") + self.context = copy.deepcopy(self.start) + self.context.append(message("user", prompts.resume(summary))) + self.epoch = 0 + self.compactions += 1 + return + raise ReleaseError("compaction exhausted shared model-call budget") + + def run(self): + while self.remaining: + midpoint = not self.midpoint_sent and self.used >= round( + self.settings.steps * 0.5 + ) + finalization = not self.finalization_sent and self.remaining <= 10 + self.context.append( + message( + "user", + prompts.budget( + self.used, self.settings.steps, midpoint, finalization + ), + ) + ) + self.midpoint_sent |= midpoint + self.finalization_sent |= finalization + output, calls, status = self.call() + if status == "incomplete": + for call in calls: + self.add_result( + call, + Result( + 2, + stderr=( + "The model response ended at the output-token limit; " + "the incomplete command was not executed. Retry with " + "a shorter command." + ), + ), + ) + continue + if not calls: + self.context.append( + message( + "user", + "Your previous response contained no run_command call. " + "Issue the next terminal command now. If required information " + "is genuinely user-controlled, use " + "`python /opt/cuawright-tools/ask_user.py " + '--question "TEXT"` in a standalone run_command.', + ) + ) + else: + try: + commands = [command_arguments(call) for call in calls] + requests = [control(command) for command, _ in commands] + if len(calls) > 1 and any( + request and request[0] == "submit" for request in requests + ): + raise ValueError("submit must be the only command in the batch") + except (ValueError, KeyError, TypeError): + for call in calls: + self.add_result( + call, + Result( + 2, + stderr="Invalid run_command batch; nothing executed. " + "Use exact JSON and standalone control clients.", + ), + ) + else: + for call, (command, timeout) in zip(calls, commands): + result = self.guest.run(command, timeout) + self.commands += 1 + self.add_result(call, result) + if result.completed: + return self.outcome("submitted") + if ( + self.settings.compact_every + and self.epoch >= self.settings.compact_every + and self.remaining > 1 + ): + self.compact() + return self.outcome("budget_exhausted") + + def outcome(self, reason): + return { + "stop_reason": reason, + "model_calls": self.used, + "commands": self.commands, + "compactions": self.compactions, + } diff --git a/src/webwright/tools/__init__.py b/src/cuawright/desktop/config/__init__.py similarity index 100% rename from src/webwright/tools/__init__.py rename to src/cuawright/desktop/config/__init__.py diff --git a/src/cuawright/desktop/config/prompts.py b/src/cuawright/desktop/config/prompts.py new file mode 100644 index 00000000..aa63152f --- /dev/null +++ b/src/cuawright/desktop/config/prompts.py @@ -0,0 +1,124 @@ +ACTOR = """You are Actor in an Ubuntu desktop VM. Complete the task, verify the result, and submit. + +Treat the task instruction as the central, top-level requirement. Follow its exact wording, named application, requested result, and restrictions. + +First, re-read the instruction. As a general guide, inventory whichever parts of the task-relevant environment are useful, such as open applications, documents, browser tabs, accounts, and files in locations like Desktop, Downloads, and Documents. Let the task context determine the method and scope. Inspect any source or reference that must be matched. If a required file or fact is missing, ask the user one concise question. + +Use run_command for commands, automation, application control, and inspection. + +- Reuse the application, browser instance, profile, document, and state opened by setup. +- Use the application and output format named in the task. +- Complete every explicit requirement and avoid unrelated changes. +- Prefer minimal edits and exact typed values. +- Save, export, send, upload, or download to the exact requested destination. +- For GIMP, do not save or export unless explicitly requested. + +Verify the final result directly: +- visual result: inspect a full-size screenshot; +- exact value or property: read it back; +- file or document: reopen the saved artifact; +- web action: reload and confirm the resulting record; +- behavior: exercise it and confirm the state change. + +If verification finds a defect, repair it and verify again. Stop when the task is complete. + +Tools: +- Image: `python /opt/cuawright-tools/image_show.py --path ABSOLUTE_PATH` +- Ask user: `python /opt/cuawright-tools/ask_user.py --question "TEXT"` +- Submit: `python /opt/cuawright-tools/submit.py` + +Run a `/opt/cuawright-tools/*.py` tool as the only command in its run_command call. + +Browser: +- Use the existing Chrome instance and profile. +- Connect at `http://127.0.0.1:1337` with `/home/user/.local/share/osw/venv/bin/python`. +- Prefer `Accessibility.getFullAXTree` to inspect controls and state. +- Use CDP, xdotool, screenshots, keyboard, and mouse to operate user-facing controls. + +When complete and verified, run submit.py.""" + +COMPACTION = """Do not call run_command. Produce a concise +handoff for a fresh context of the same Actor using exactly these headings: + +Current stage: +Completed persistent work: +Canonical artifacts/state and readback: +Current blocker or failed assumptions: +Remaining work: +Next terminal command: + +Preserve exact task facts, paths, identifiers, unresolved risks, and direct +saved-state evidence. Do not include large raw command output or claim +completion without direct proof.""" + + +def initial(instruction): + return ( + f"Original task instruction:\n{instruction}\n\n" + "You are the persistent Actor. Complete the task directly from first " + "inspection through final submission." + ) + + +def resume(summary): + return ( + "Resume the same task after context compaction. The original instruction " + "above is unchanged.\n\nCompacted handoff:\n" + + summary + + "\n\nChoose and run the next terminal command." + ) + + +def phase(instruction, phase_id, index, count, previous_evaluated): + transition = ( + "The previous phase has been evaluated; its state persists." + if previous_evaluated + else "This is the first phase." + ) + return ( + f"Phase ID: {phase_id}\n\n" + f"Phase {index}/{count}. {transition}\n\n" + "One shared model-call budget covers every phase and never resets.\n\n" + f"Current phase instruction:\n{instruction}\n\n" + "Continue directly as the same Actor. Submitting evaluates this phase " + "and advances the next setup." + ) + + +def policy_recovery(attempt, maximum): + return ( + "The provider rejected the previous request with a policy error " + f"(recovery {attempt}/{maximum}). Do not repeat or disguise the blocked " + "action. Continue only with clearly benign user-visible application " + "operations. Use narrower screenshots, OCR, or metadata if an image was " + "blocked; stop rather than evade the safeguard if no compliant path exists." + ) + + +def budget(used, total, midpoint=False, finalization=False): + text = ( + f"[Shared budget] Global model-call budget: {used}/{total} used; " + f"{total - used} remain." + ) + if midpoint: + text += ( + "\nThe midpoint has been reached: ensure a real persistent " + "evaluator-facing result exists now." + ) + if finalization: + text += ( + "\nFinalization window: stop optional exploration. Ensure the " + "canonical result is saved, reloaded, and directly checked " + "against every task requirement, then run " + "`python /opt/cuawright-tools/submit.py`." + ) + if total - used == 1: + text += ( + "\nThis is the final allocated model call. Use it to save any " + "remaining essential change, or run " + "`python /opt/cuawright-tools/submit.py` if the result is " + "already saved. Keep submit standalone; do not combine it " + "with a write command. Budget exhaustion retains the current " + "persistent result for evaluation." + ) + return text diff --git a/src/webwright/utils/__init__.py b/src/cuawright/desktop/environments/__init__.py similarity index 100% rename from src/webwright/utils/__init__.py rename to src/cuawright/desktop/environments/__init__.py diff --git a/src/cuawright/desktop/environments/osworld/__init__.py b/src/cuawright/desktop/environments/osworld/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/cuawright/desktop/environments/osworld/compatibility.py b/src/cuawright/desktop/environments/osworld/compatibility.py new file mode 100644 index 00000000..183cb83b --- /dev/null +++ b/src/cuawright/desktop/environments/osworld/compatibility.py @@ -0,0 +1,218 @@ +import os +import re +import shlex +import sys +import tempfile +import time +from functools import wraps +from pathlib import Path + + +GUI = { + "blender", + "eog", + "evince", + "firefox", + "gimp", + "google-chrome", + "libreoffice", + "shotcut", + "thunderbird", + "vlc", + "wps", + "wpp", + "et", +} + + +def install(): + patch_setup() + patch_ports() + + +def patch_task(task): + if str(task.get("id")) != "075": + return None + module = sys.modules[type(task).__module__] + original = module.generate_text + if getattr(original, "_cuawright_bounded_images", False): + return 4096 + + @wraps(original) + def generate_text(*args, **kwargs): + paths = kwargs.get("image_paths") + if not paths: + return original(*args, **kwargs) + from PIL import Image + + Image.MAX_IMAGE_PIXELS = None + with tempfile.TemporaryDirectory(prefix="cuawright-eval-images-") as directory: + bounded = [] + for index, value in enumerate(paths): + path = Path(value) + with Image.open(path) as image: + if max(image.size) <= 4096: + bounded.append(str(path)) + continue + image.thumbnail((4096, 4096), Image.Resampling.LANCZOS) + output = Path(directory) / f"{index}.png" + image.save(output, "PNG") + bounded.append(str(output)) + kwargs["image_paths"] = bounded + return original(*args, **kwargs) + + generate_text._cuawright_bounded_images = True + module.generate_text = generate_text + return 4096 + + +def patch_ports(): + from docker.errors import DockerException + from desktop_env.providers.docker.provider import DockerProvider + + if getattr(DockerProvider, "_cuawright_dynamic_ports", False): + return + + def clear(self): + self.container = self.server_port = self.vnc_port = None + self.chromium_port = self.vlc_port = None + + def published(self, port): + deadline = time.monotonic() + 30 + while time.monotonic() < deadline: + self.container.reload() + value = self.container.attrs["NetworkSettings"]["Ports"].get(f"{port}/tcp") + if value: + return int(value[0]["HostPort"]) + time.sleep(0.1) + raise TimeoutError(f"Docker did not publish port {port}") + + def start(self, path_to_vm, headless, os_type): + del headless, os_type + try: + devices = ["/dev/kvm"] if os.path.exists("/dev/kvm") else [] + if not devices: + self.environment["KVM"] = "N" + self.container = self.client.containers.run( + "happysixd/osworld-docker", + environment=self.environment, + cap_add=["NET_ADMIN"], + devices=devices, + volumes={ + os.path.abspath(path_to_vm): { + "bind": "/System.qcow2", + "mode": "ro", + } + }, + ports={8006: None, 5000: None, 9222: None, 8080: None}, + detach=True, + ) + self.vnc_port, self.server_port, self.chromium_port, self.vlc_port = ( + published(self, port) for port in (8006, 5000, 9222, 8080) + ) + self._wait_for_vm_ready() + except (DockerException, OSError, RuntimeError, TimeoutError): + if self.container is not None: + try: + self.container.remove(force=True, v=True) + except DockerException: + pass + clear(self) + raise + + DockerProvider._clear_container_state = clear + DockerProvider.start_emulator = start + DockerProvider._cuawright_dynamic_ports = True + + +def background(script): + lexer = shlex.shlex(script, posix=True, punctuation_chars=";&|()<>") + lexer.whitespace_split = True + lexer.commenters = "" + try: + tokens = list(lexer) + except ValueError: + return False + return ( + script == "cd /home/user/ds2019-request/ds2019-request && npm start" + or "nohup" in tokens + or "&" in tokens + ) + + +def patch_setup(): + from desktop_env.controllers.setup import SetupController + + if not getattr(SetupController, "_cuawright_execute", False): + original = SetupController.execute + + @wraps(original) + def execute( + self, + command, + stdout="", + stderr="", + shell=False, + until=None, + quiet=False, + timeout=120, + ): + if ( + isinstance(command, list) + and command + and not shell + and not stdout + and not stderr + and not until + ): + executable = Path(command[0]).name + args = set(command[1:]) + if executable in GUI and not ( + args & {"--headless", "--convert-to", "--version"} + ): + return self.launch(command) + if executable in {"bash", "sh"} and "-c" in command: + script = command[command.index("-c") + 1] + if background(script): + result = self.launch(command) + match = re.search(r"\bsleep\s+(\d+(?:\.\d+)?)", script) + if match: + time.sleep(min(float(match.group(1)), 10)) + return result + return original( + self, + command, + stdout=stdout, + stderr=stderr, + shell=shell, + until=until, + quiet=quiet, + timeout=timeout, + ) + + SetupController.execute = execute + SetupController._cuawright_execute = True + + if not getattr(SetupController, "_cuawright_proxy_bypass", False): + original_launch = SetupController._launch_setup + + @wraps(original_launch) + def launch(self, command, shell=False): + if ( + isinstance(command, list) + and command + and Path(command[0]).name == "google-chrome" + and self.use_proxy + ): + command = list(command) + suffix = os.environ.get("WEBSITE_HOST_SUFFIX", "").strip() + if suffix and not any( + str(value).startswith("--proxy-bypass-list=") for value in command + ): + command.append( + f"--proxy-bypass-list=localhost;127.0.0.1;*.{suffix}" + ) + return original_launch(self, command, shell=shell) + + SetupController._launch_setup = launch + SetupController._cuawright_proxy_bypass = True diff --git a/src/cuawright/desktop/environments/osworld/control_clients/__init__.py b/src/cuawright/desktop/environments/osworld/control_clients/__init__.py new file mode 100644 index 00000000..fa3247ba --- /dev/null +++ b/src/cuawright/desktop/environments/osworld/control_clients/__init__.py @@ -0,0 +1 @@ +"""Guest executable sources; all are counted and shipped in the wheel.""" diff --git a/src/cuawright/desktop/environments/osworld/control_clients/ask_user.py b/src/cuawright/desktop/environments/osworld/control_clients/ask_user.py new file mode 100644 index 00000000..1c8f23e4 --- /dev/null +++ b/src/cuawright/desktop/environments/osworld/control_clients/ask_user.py @@ -0,0 +1,22 @@ +import argparse +import json + + +def question(value): + if not value.strip() or len(value) > 2000: + raise argparse.ArgumentTypeError("question must contain 1..2000 characters") + return value + + +parser = argparse.ArgumentParser() +parser.add_argument("--question", required=True, type=question) +args = parser.parse_args() +print( + json.dumps( + { + "cuawright_control": 1, + "operation": "ask_user", + "question": args.question, + } + ) +) diff --git a/src/cuawright/desktop/environments/osworld/control_clients/image_show.py b/src/cuawright/desktop/environments/osworld/control_clients/image_show.py new file mode 100644 index 00000000..3f1e3ee4 --- /dev/null +++ b/src/cuawright/desktop/environments/osworld/control_clients/image_show.py @@ -0,0 +1,25 @@ +import argparse +import json +from pathlib import PurePosixPath + + +def absolute_path(value): + path = PurePosixPath(value) + if ( + not path.is_absolute() + or path.as_posix() != value + or "\\" in value + or "\x00" in value + or value.startswith("//") + or ".." in path.parts + ): + raise argparse.ArgumentTypeError("must be a canonical absolute POSIX path") + return value + + +parser = argparse.ArgumentParser() +parser.add_argument("--path", required=True, type=absolute_path) +args = parser.parse_args() +print( + json.dumps({"cuawright_control": 1, "operation": "image_show", "path": args.path}) +) diff --git a/src/cuawright/desktop/environments/osworld/control_clients/submit.py b/src/cuawright/desktop/environments/osworld/control_clients/submit.py new file mode 100644 index 00000000..28369397 --- /dev/null +++ b/src/cuawright/desktop/environments/osworld/control_clients/submit.py @@ -0,0 +1,6 @@ +import argparse +import json + +parser = argparse.ArgumentParser() +parser.parse_args() +print(json.dumps({"cuawright_control": 1, "operation": "submit"})) diff --git a/src/cuawright/desktop/environments/osworld/guest_tools.py b/src/cuawright/desktop/environments/osworld/guest_tools.py new file mode 100644 index 00000000..f43666a0 --- /dev/null +++ b/src/cuawright/desktop/environments/osworld/guest_tools.py @@ -0,0 +1,363 @@ +import base64 +import hashlib +import json +import shlex +import urllib.error +import urllib.request +from pathlib import Path, PurePosixPath + +from ...exceptions import ReleaseError +from ...models.utils.actions_toolcall_response import Result, exact_json + +ROOT = "/opt/cuawright-tools" +CLIENTS = { + name: (Path(__file__).parent / "control_clients" / f"{name}.py").read_bytes() + for name in ("submit", "image_show", "ask_user") +} +CLIENT_HASHES = { + name: hashlib.sha256(source).hexdigest() for name, source in CLIENTS.items() +} +IMAGE_LIMIT = 20 * 1024 * 1024 +EXECUTION_LIMIT = 64 * 1024 * 1024 + +# These scripts are executable release code and included in the physical count. +INSTALL = """ +import base64, json, os, stat +from pathlib import Path +payload = json.loads(base64.b64decode(PAYLOAD)) +root = Path("/opt/cuawright-tools") +def secure_directory(path): + info = path.lstat() + if not stat.S_ISDIR(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o022: + raise RuntimeError("insecure client directory") +secure_directory(root.parent) +if not root.exists(): + root.mkdir(mode=0o755) +secure_directory(root) +for name, encoded in payload.items(): + path = root / (name + ".py") + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_NOFOLLOW, 0o644) + with os.fdopen(fd, "wb") as stream: + info = os.fstat(stream.fileno()) + if not stat.S_ISREG(info.st_mode) or info.st_nlink != 1 or info.st_uid != 0: + raise RuntimeError("insecure client file") + os.fchmod(stream.fileno(), 0o644) + stream.truncate(0) + stream.write(base64.b64decode(encoded)) + stream.flush() + os.fsync(stream.fileno()) +print("installed") +""" + +EXECUTE_CLIENT = """ +import base64, hashlib, json, os, stat, sys +from pathlib import Path +payload = json.loads(base64.b64decode(PAYLOAD)) +root = Path("/opt/cuawright-tools") +for path in (root.parent, root): + info = path.lstat() + if not stat.S_ISDIR(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o022: + raise RuntimeError("insecure client directory") +path = root / (payload["name"] + ".py") +fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW) +with os.fdopen(fd, "rb") as stream: + info = os.fstat(stream.fileno()) + if not stat.S_ISREG(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o022: + raise RuntimeError("insecure client file") + source = stream.read(20000) +if hashlib.sha256(source).hexdigest() != payload["sha256"]: + raise RuntimeError("client hash mismatch") +sys.argv = [str(path)] + payload["args"] +exec(compile(source, str(path), "exec"), {"__name__": "__main__"}) +""" + +READ_IMAGE = """ +import base64, json, os, stat +payload = json.loads(base64.b64decode(PAYLOAD)) +parts = payload["path"].split("/")[1:] +fd = os.open("/", os.O_RDONLY | os.O_DIRECTORY) +try: + for part in parts[:-1]: + next_fd = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=fd) + os.close(fd) + fd = next_fd + file_fd = os.open(parts[-1], os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=fd) + with os.fdopen(file_fd, "rb") as stream: + info = os.fstat(stream.fileno()) + if not stat.S_ISREG(info.st_mode) or info.st_size > payload["limit"]: + raise RuntimeError("not a bounded regular image") + data = stream.read(payload["limit"] + 1) + if len(data) > payload["limit"]: + raise RuntimeError("image exceeded limit") +finally: + os.close(fd) +print(base64.b64encode(data).decode("ascii")) +""" + +SUPPORT = """ +set -e +root="$HOME/.local/share/osw" +if [ ! -x "$root/venv/bin/python" ]; then python3 -m venv "$root/venv"; fi +if ! "$root/venv/bin/python" -c 'import playwright' >/dev/null 2>&1; then + "$root/venv/bin/pip" install --quiet \ + --trusted-host pypi.org --trusted-host files.pythonhosted.org playwright +fi +if ! python3 -c 'import openpyxl, docx, pypdf, PIL, requests, bs4, lxml' >/dev/null 2>&1; then + python3 -m pip install --user --quiet \ + --trusted-host pypi.org --trusted-host files.pythonhosted.org \ + openpyxl python-docx pypdf pillow requests beautifulsoup4 lxml +fi +""" + +VERIFY = """ +set -e +available=$(df -Pk / | awk 'NR==2 {print $4}') +[ "$available" -ge 5242880 ] +probe="$HOME/.cuawright-write-probe-$$" +: > "$probe" +rm -f "$probe" +printf '%s\\n' "$available" +""" + + +def materialize(script, payload): + encoded = base64.b64encode(json.dumps(payload).encode()).decode() + return script.replace("PAYLOAD", repr(encoded)) + + +def control(command): + """Only an exact standalone client invocation can request host dispatch.""" + if ROOT not in command: + return None + lexer = shlex.shlex(command, posix=True, punctuation_chars=True) + lexer.whitespace_split = True + lexer.commenters = "" + try: + words = list(lexer) + except ValueError: + raise ValueError("invalid control syntax") from None + names = {f"{ROOT}/{name}.py": name for name in CLIENTS} + if ( + command != command.strip() + or len(words) < 2 + or words[0] != "python" + or words[1] not in names + or any(char in command for char in ("\n", "\r", "`", "$", "\\")) + or any( + word in (";", "|", "||", "&", "&&", ">", "<", "(", ")") for word in words + ) + ): + raise ValueError("control clients must be exact standalone commands") + name = names[words[1]] + args = words[2:] + expected = {"cuawright_control": 1, "operation": name} + if name == "submit": + if args: + raise ValueError("submit takes no arguments") + elif name == "image_show": + if len(args) != 2 or args[0] != "--path": + raise ValueError("image_show requires --path") + path = PurePosixPath(args[1]) + if ( + not path.is_absolute() + or path.as_posix() != args[1] + or args[1].startswith("//") + or ".." in path.parts + or args[1] == "/" + ): + raise ValueError("image path must be canonical and absolute") + expected["path"] = args[1] + else: + if ( + len(args) != 2 + or args[0] != "--question" + or not args[1].strip() + or len(args[1]) > 2000 + ): + raise ValueError("ask_user requires a nonempty question <=2000 characters") + expected["question"] = args[1] + return name, args, expected + + +class Guest: + def __init__(self, env, post=None): + self.env = env + self.post = post or self._post + self.submitted = False + self.website_host_suffix = "" + + def _post(self, payload, timeout, limit): + request = urllib.request.Request( + self.env.controller.http_server + "/execute", + data=json.dumps(payload).encode(), + headers={"Content-Type": "application/json"}, + ) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + data = response.read(limit + 1) + except (urllib.error.URLError, OSError) as exc: + return { + "returncode": -1, + "output": "", + "error": f"Guest transport failed ({type(exc).__name__}).", + } + if len(data) > limit: + return { + "returncode": -1, + "output": "", + "error": "Guest response exceeded transport limit.", + } + try: + return exact_json(data) + except (ValueError, UnicodeError): + return { + "returncode": -1, + "output": "", + "error": "Guest returned invalid JSON.", + } + + def execute(self, argv, timeout, limit=EXECUTION_LIMIT): + self.env.is_environment_used = True + payload = { + "command": [ + "/usr/bin/timeout", + "--verbose", + "--signal=TERM", + "--kill-after=5s", + f"{timeout}s", + *argv, + ], + "shell": False, + "timeout": timeout + 15, + } + value = self.post(payload, timeout + 25, limit) + if ( + not isinstance(value, dict) + or type(value.get("returncode")) is not int + or not isinstance(value.get("output"), str) + or not isinstance(value.get("error"), str) + ): + return Result(-1, stderr="Invalid guest execution response.") + return Result(value["returncode"], value["output"], value["error"]) + + def python(self, script, payload, timeout=90, limit=2 * 1024 * 1024): + return self.execute( + ["/usr/bin/python3", "-c", materialize(script, payload)], timeout, limit + ) + + def provision_controls(self): + previously_used = self.env.is_environment_used + payload = { + name: base64.b64encode(source).decode() for name, source in CLIENTS.items() + } + script = materialize(INSTALL, payload) + password = getattr(self.env, "client_password", None) + if password is None: + argv = ["sudo", "-n", "/usr/bin/python3", "-c", script] + else: + if ( + not isinstance(password, str) + or not password + or any(char in password for char in "\n\r\0") + ): + raise ReleaseError("invalid guest administrative credential") + command = ( + f"printf '%s\\n' {shlex.quote(password)} | " + f"sudo -S -p '' /usr/bin/python3 -c {shlex.quote(script)}" + ) + argv = ["/bin/bash", "-c", command] + try: + result = self.execute(argv, 90) + finally: + self.env.is_environment_used = previously_used + if result.exit_code != 0 or result.stdout.strip() != "installed": + raise ReleaseError("control client provisioning failed") + + def provision_support(self): + support = self.execute(["/bin/bash", "-c", SUPPORT], 240) + if support.exit_code: + raise ReleaseError("guest support provisioning failed") + verified = self.execute(["/bin/bash", "-c", VERIFY], 30) + if verified.exit_code: + raise ReleaseError("guest disk or writable-home verification failed") + + def provision(self): + self.provision_controls() + self.provision_support() + + def run(self, command, timeout): + if self.submitted: + raise ReleaseError("action after submission") + try: + request = control(command) + except ValueError as exc: + return Result(2, stderr=str(exc)) + if request is None: + wrapped = ( + 'export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:' + '/sbin:/bin:$HOME/.local/bin"\n' + f"export WEBSITE_HOST_SUFFIX={shlex.quote(self.website_host_suffix)}\n" + + command + ) + return self.execute(["/bin/bash", "-c", wrapped], timeout) + name, args, expected = request + executed = self.python( + EXECUTE_CLIENT, + {"name": name, "args": args, "sha256": CLIENT_HASHES[name]}, + timeout, + ) + if executed.exit_code != 0: + return Result(2, stderr="control client execution failed") + if len(executed.stdout.encode("utf-8")) > 16_384: + raise ReleaseError("control descriptor exceeded limit") + try: + descriptor = exact_json(executed.stdout) + except ValueError: + descriptor = None + if ( + descriptor != expected + or not isinstance(descriptor, dict) + or type(descriptor.get("cuawright_control")) is not int + or executed.stderr + ): + return Result(2, stderr="Control client descriptor verification failed.") + if name == "submit": + self.submitted = True + return Result(stdout="Submitted.", completed=True) + if name == "ask_user": + simulator = self.env.user_simulator + if simulator is None: + return Result(2, stderr="No official user simulator configured.") + try: + answer = simulator.respond(expected["question"]) + except Exception: + raise ReleaseError("official user simulator failed") from None + if not isinstance(answer, str): + raise ReleaseError("official user simulator returned invalid answer") + return Result(stdout=answer) + image = self.python( + READ_IMAGE, + {"path": expected["path"], "limit": IMAGE_LIMIT}, + timeout, + limit=IMAGE_LIMIT * 2, + ) + if image.exit_code: + return Result(2, stderr="Unable to read a bounded nonsymlink image.") + try: + data = base64.b64decode(image.stdout.strip(), validate=True) + except ValueError: + return Result(2, stderr="Invalid image bridge response.") + if len(data) > IMAGE_LIMIT: + raise ReleaseError("image exceeded limit") + if data.startswith(b"\x89PNG\r\n\x1a\n"): + media = "image/png" + elif data.startswith(b"\xff\xd8\xff"): + media = "image/jpeg" + else: + return Result(2, stderr="Only PNG and JPEG images are supported.") + return Result( + stdout="Image attached.", + image=data, + media_type=media, + path=expected["path"], + ) diff --git a/src/cuawright/desktop/environments/osworld/source.py b/src/cuawright/desktop/environments/osworld/source.py new file mode 100644 index 00000000..77107bd3 --- /dev/null +++ b/src/cuawright/desktop/environments/osworld/source.py @@ -0,0 +1,115 @@ +import importlib +import hashlib +import json +import os +import re +import sys +from pathlib import Path + +from ...exceptions import ReleaseError +from ...utils.artifacts import file_hash, git, inventory + +RELEASE = "v2026.08.08" +COMMIT = "d578d2d4e0dc82b43e270fdaa7fa89d9708cd154" +REPOSITORY = "https://github.com/xlang-ai/OSWorld-V2.git" + + +def activate(root): + root = Path(root).resolve(strict=True) + if not (root / ".git").exists(): + raise ReleaseError("OSWorld source must be a Git checkout root") + if git(root, "rev-parse", "HEAD") != COMMIT: + raise ReleaseError("OSWorld source does not match supported official pin") + if git(root, "status", "--porcelain", "--untracked-files=all"): + raise ReleaseError("OSWorld source must be clean; no runtime patches allowed") + for relative in ("desktop_env/desktop_env.py", "task_loader.py"): + path = root / relative + if not path.is_file() or path.is_symlink(): + raise ReleaseError("incomplete OSWorld source layout") + for name, module in list(sys.modules.items()): + if name.split(".")[0] in {"desktop_env", "task_loader"}: + origin = getattr(module, "__file__", None) + if origin is None or not Path(origin).resolve().is_relative_to(root): + raise ReleaseError("OSWorld imported before source activation") + sys.path.insert(0, str(root)) + importlib.invalidate_caches() + return { + "release": RELEASE, + "repository": REPOSITORY, + "commit": COMMIT, + "tree": git(root, "rev-parse", "HEAD^{tree}"), + "dirty": False, + } + + +def load_task(tasks, task_id): + if not re.fullmatch(r"[A-Za-z0-9_-]+", task_id): + raise ReleaseError("invalid task identity") + tasks = Path(tasks).resolve(strict=True) + loader = importlib.import_module("task_loader") + path = loader.find_task_class_path(task_id, str(tasks), "tasks", "v2") + if not path: + raise ReleaseError("only official V2 Python class tasks are supported") + path = Path(path).resolve(strict=True) + if not path.is_relative_to(tasks): + raise ReleaseError("task class resolves outside supplied tasks root") + task_provenance = { + "id": task_id, + "class_sha256": file_hash(path), + "source_tree": inventory(tasks), + } + task = loader.load_task_from_file(str(path)) + base = importlib.import_module("desktop_env.task_base").BaseTask + if ( + not isinstance(task, base) + or type(task).evaluate is base.evaluate + or task.get("platform", "linux") != "linux" + or str(task.get("id")) != task_id + ): + raise ReleaseError("require Linux V2 class task with custom evaluator") + instruction = task.get("instruction") + if not isinstance(instruction, str) or not instruction.strip(): + raise ReleaseError("task has no official instruction") + sha = hashlib.sha256() + try: + encoder = json.JSONEncoder( + sort_keys=True, separators=(",", ":"), allow_nan=False + ) + for fragment in encoder.iterencode(dict(task)): + sha.update(fragment.encode()) + except (TypeError, ValueError): + raise ReleaseError( + "official task configuration cannot be deterministically hashed" + ) from None + task_provenance["config_sha256"] = sha.hexdigest() + return task, instruction, task_provenance + + +def configure_external(assets, key, base_url, website_host_suffix, proxy_config=None): + values = { + "OSWORLD_FILE_BASE_URL": str(assets), + "WEBSITE_HOST_SUFFIX": website_host_suffix, + "OPENAI_API_KEY": key, + "OPENAI_BASE_URL": base_url, + "OSWORLD_EVAL_MODEL_PROVIDER": "openai", + "OSWORLD_EVAL_MODEL_API_KEY": key, + "OSWORLD_EVAL_MODEL_BASE_URL": base_url, + "OSWORLD_USER_SIM_API_KEY": key, + "OSWORLD_USER_SIM_BASE_URL": base_url, + } + names = (*values, "PROXY_CONFIG_FILE") + previous = {name: os.environ.get(name) for name in names} + os.environ.update(values) + if proxy_config is None: + os.environ.pop("PROXY_CONFIG_FILE", None) + else: + os.environ["PROXY_CONFIG_FILE"] = str(proxy_config) + return previous + + +def restore_external(previous): + for name, value in previous.items(): + if value is None: + os.environ.pop(name, None) + else: + os.environ[name] = value diff --git a/src/cuawright/desktop/exceptions.py b/src/cuawright/desktop/exceptions.py new file mode 100644 index 00000000..2d5ea2a7 --- /dev/null +++ b/src/cuawright/desktop/exceptions.py @@ -0,0 +1,2 @@ +class ReleaseError(RuntimeError): + """Explicit release failure; messages must not contain external diagnostics.""" diff --git a/src/cuawright/desktop/models/__init__.py b/src/cuawright/desktop/models/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/cuawright/desktop/models/openai_response_model.py b/src/cuawright/desktop/models/openai_response_model.py new file mode 100644 index 00000000..04207820 --- /dev/null +++ b/src/cuawright/desktop/models/openai_response_model.py @@ -0,0 +1,9 @@ +def create_client(key, settings): + from openai import OpenAI + + return OpenAI( + api_key=key, + base_url=settings.base_url, + max_retries=0, + timeout=600, + ) diff --git a/src/cuawright/desktop/models/utils/__init__.py b/src/cuawright/desktop/models/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/cuawright/desktop/models/utils/actions_toolcall_response.py b/src/cuawright/desktop/models/utils/actions_toolcall_response.py new file mode 100644 index 00000000..65a7ff79 --- /dev/null +++ b/src/cuawright/desktop/models/utils/actions_toolcall_response.py @@ -0,0 +1,90 @@ +import json +from dataclasses import dataclass + +OUTPUT_LIMIT = 16_000 +TOOLS = [ + { + "type": "function", + "name": "run_command", + "description": ( + "Execute a shell command in the active terminal and return its standard " + "output, standard error, and exit status." + ), + "strict": True, + "parameters": { + "type": "object", + "properties": { + "command": {"type": "string"}, + "timeout": {"type": "integer", "minimum": 1, "maximum": 300}, + }, + "required": ["command", "timeout"], + "additionalProperties": False, + }, + } +] + + +def _unique(pairs): + result = {} + for key, value in pairs: + if key in result: + raise ValueError("duplicate JSON key") + result[key] = value + return result + + +def _invalid_constant(value): + raise ValueError("non-finite JSON constant") + + +def exact_json(text): + return json.loads(text, object_pairs_hook=_unique, parse_constant=_invalid_constant) + + +def command_arguments(call): + if call.get("name") != "run_command": + raise ValueError("unknown tool") + value = exact_json(call["arguments"]) + if not isinstance(value, dict) or set(value) != {"command", "timeout"}: + raise ValueError("expected exactly command and timeout") + command, timeout = value["command"], value["timeout"] + if not isinstance(command, str) or not command.strip() or "\x00" in command: + raise ValueError("command must be nonempty text without NUL") + if type(timeout) is not int or not 1 <= timeout <= 300: + raise ValueError("timeout must be an integer in 1..300") + return command, timeout + + +def capped(text): + if len(text) <= OUTPUT_LIMIT: + return text + return ( + text[:12_000] + + "\n\n[OUTPUT TRUNCATED: request a narrower command, smaller range, " + "or filtered output.]\n\n" + text[-3_000:] + ) + + +@dataclass +class Result: + exit_code: int = 0 + stdout: str = "" + stderr: str = "" + completed: bool = False + image: bytes | None = None + media_type: str = "" + path: str = "" + + def text(self): + return capped( + json.dumps( + { + "exit_code": self.exit_code, + "stdout": self.stdout, + "stderr": self.stderr, + "completed": self.completed, + }, + ensure_ascii=False, + separators=(",", ":"), + ) + ) diff --git a/src/cuawright/desktop/run/__init__.py b/src/cuawright/desktop/run/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/cuawright/desktop/run/benchmarks/__init__.py b/src/cuawright/desktop/run/benchmarks/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/cuawright/desktop/run/benchmarks/osworld.py b/src/cuawright/desktop/run/benchmarks/osworld.py new file mode 100644 index 00000000..fca6edfc --- /dev/null +++ b/src/cuawright/desktop/run/benchmarks/osworld.py @@ -0,0 +1,412 @@ +import contextlib +import importlib +import io +import json +import logging +import math +import time +from dataclasses import asdict, dataclass +from pathlib import Path +from urllib.parse import urlsplit + +from ...agents.default import Actor, RETRY_DELAYS +from ...utils.artifacts import ( + Store, + credentials, + digest, + file_hash, + harness_provenance, + inventory, +) +from ...config import prompts +from ...environments.osworld.compatibility import install as install_compatibility +from ...environments.osworld.compatibility import patch_task +from ...environments.osworld.guest_tools import CLIENT_HASHES, Guest +from ...environments.osworld.source import ( + activate, + configure_external, + load_task, + restore_external, +) +from ...models.openai_response_model import create_client +from ...exceptions import ReleaseError +from ...models.utils.actions_toolcall_response import TOOLS + + +@dataclass(frozen=True) +class Settings: + source: Path + tasks: Path + assets: Path + vm: Path + credentials: Path + results: Path + task_id: str + model: str + base_url: str = "https://api.openai.com/v1" + reasoning: str = "high" + steps: int = 300 + compact_every: int = 40 + max_output_tokens: int = 32768 + website_host_suffix: str = "" + proxy_config: Path | None = None + + def validate(self): + if type(self.steps) is not int or not 1 <= self.steps <= 500: + raise ReleaseError("steps must be in 1..500") + if ( + type(self.compact_every) is not int + or self.compact_every < 0 + or type(self.max_output_tokens) is not int + or not 1 <= self.max_output_tokens <= 32768 + ): + raise ReleaseError("invalid compaction or output token limit") + endpoint = urlsplit(self.base_url) + if ( + endpoint.scheme not in ("http", "https") + or not endpoint.hostname + or endpoint.username + or endpoint.password + or endpoint.query + or endpoint.fragment + or any(character.isspace() for character in self.base_url) + ): + raise ReleaseError("base URL must be an HTTP endpoint without credentials") + if not self.model.strip(): + raise ReleaseError("model must be nonempty") + for path in (self.source, self.tasks, self.assets): + if not path.is_dir() or path.is_symlink(): + raise ReleaseError("source, tasks, and assets must be real directories") + for path in (self.vm, self.credentials): + if not path.is_file() or path.is_symlink(): + raise ReleaseError("VM and credentials must be nonsymlink files") + if self.proxy_config is not None and ( + not self.proxy_config.is_file() or self.proxy_config.is_symlink() + ): + raise ReleaseError("proxy config must be a nonsymlink file") + if self.results.exists() or self.results.is_symlink(): + raise ReleaseError("results directory must not already exist") + if not self.results.parent.is_dir(): + raise ReleaseError("results parent must exist") + protected = [path.resolve() for path in (self.source, self.tasks, self.assets)] + if any(self.results.resolve().is_relative_to(path) for path in protected): + raise ReleaseError( + "results must be separate from source, tasks, and assets" + ) + if self.credentials.resolve().is_relative_to(self.results.resolve()): + raise ReleaseError("credentials must remain external") + if any(self.credentials.resolve().is_relative_to(path) for path in protected): + raise ReleaseError("credentials must be outside source, tasks, and assets") + + def public(self): + values = asdict(self) + values.pop("credentials") + values.pop("proxy_config") + values.pop("base_url") + return { + key: str(value) if isinstance(value, Path) else value + for key, value in values.items() + } + + +@contextlib.contextmanager +def quiet_external(): + # Official setup/evaluator diagnostics can contain hidden task state. + previous = logging.root.manager.disable + logging.disable(logging.CRITICAL) + try: + with ( + contextlib.redirect_stdout(io.StringIO()), + contextlib.redirect_stderr(io.StringIO()), + ): + yield + finally: + logging.disable(previous) + + +def task_phases(task): + getter = getattr(task, "get_phases", None) + if not callable(getter): + return [] + phases = getter() + if not isinstance(phases, list) or not phases: + raise ReleaseError("multi-phase task must provide a nonempty phase list") + for item in phases: + if ( + not isinstance(item, dict) + or not isinstance(item.get("instruction"), str) + or not item["instruction"].strip() + or not callable(item.get("setup")) + or not callable(item.get("evaluate")) + ): + raise ReleaseError("invalid multi-phase task contract") + return phases + + +def score_value(evaluation): + score = evaluation.get("score") if isinstance(evaluation, dict) else evaluation + if ( + type(score) not in (int, float) + or not math.isfinite(score) + or not 0 <= score <= 1 + ): + raise ReleaseError("official evaluator returned invalid score") + return float(score) + + +def retryable_evaluation(exc): + status = getattr(exc, "status_code", None) + if isinstance(status, int): + return status in (400, 408, 429) or status >= 500 + return type(exc).__name__ in { + "APIConnectionError", + "APITimeoutError", + "RateLimitError", + "ReadTimeout", + "ConnectionError", + } + + +def evaluate(callback, store): + for attempt in range(10): + try: + with quiet_external(): + return score_value(callback()) + except Exception as exc: + if not retryable_evaluation(exc) or attempt == 9: + raise + store.event( + "evaluation_retry", + { + "attempt": attempt + 1, + "exception_type": type(exc).__name__, + "status_code": getattr(exc, "status_code", None), + "next_delay_seconds": RETRY_DELAYS[attempt], + }, + ) + time.sleep(RETRY_DELAYS[attempt]) + raise ReleaseError("evaluation retry loop exhausted") + + +def opening_instruction(phases): + schedule = "\n".join( + f"{index}. {item.get('name', f'Phase {index}')} " + f"(weight {float(item.get('weight', 1.0)):.2f})" + for index, item in enumerate(phases, 1) + ) + return ( + "This is a sequential multi-phase task. One shared model-call budget " + "covers every phase and is never reset. Reserve enough calls for later " + "phases. Submitting the current phase triggers its evaluation and the " + "next phase setup.\n\nPhase schedule:\n" + schedule + ) + + +def run_phases(actor, guest, env, task, phases): + phase_results = [] + total_score = 0.0 + outcome = actor.outcome("budget_exhausted") + use_proxy = bool(task.get("proxy", False) and env.enable_proxy) + for index, item in enumerate(phases, 1): + if index > 1: + env._step_no = 0 + env.action_history.clear() + env._traj_no += 1 + with quiet_external(): + item["setup"](env.setup_controller, use_proxy=use_proxy) + env.is_environment_used = True + pause = float(item.get("pause_after_setup_seconds", 5) or 0) + if pause: + time.sleep(pause) + guest.submitted = False + instruction = item["instruction"] + env.instruction = instruction + actor.set_phase( + prompts.phase( + instruction, + f"phase-{index}", + index, + len(phases), + previous_evaluated=index > 1, + ) + ) + outcome = actor.run() + env.action_history.append("DONE") + phase_score = evaluate(lambda: item["evaluate"](env), actor.store) + total_score += phase_score + phase_results.append( + { + "phase_index": index, + "phase_name": item.get("name", f"Phase {index}"), + "score": phase_score, + "model_calls": outcome["model_calls"], + "commands": outcome["commands"], + } + ) + gate = item.get("gate_min_score") + if gate is not None and phase_score < float(gate): + break + if outcome["stop_reason"] != "submitted" or not actor.remaining: + break + outcome["score"] = round(max(0.0, min(1.0, total_score)), 4) + outcome["phases"] = phase_results + return outcome + + +def run(settings, client_factory=create_client, guest_factory=Guest): + settings.validate() + store = Store(settings.results) + env = None + client = None + actor = None + previous = {} + failure = None + outcome = None + stage = "preflight" + try: + external = activate(settings.source) + key = credentials(settings.credentials) + previous = configure_external( + settings.assets.resolve(), + key, + settings.base_url, + settings.website_host_suffix, + settings.proxy_config, + ) + # OSWorld reads PROXY_CONFIG_FILE when desktop_env.controllers.setup is + # imported, so the environment must be configured first. + install_compatibility() + with quiet_external(): + task, instruction, task_manifest = load_task( + settings.tasks, settings.task_id + ) + phases = task_phases(task) + evaluator_image_max = patch_task(task) + proxy_requested = bool(task.get("proxy", False)) + if proxy_requested and settings.proxy_config is None: + raise ReleaseError("proxy task requires an external proxy config") + actor_instruction = opening_instruction(phases) if phases else instruction + manifest = { + "condition": "osworld-minimal-reliability-v1", + "harness": harness_provenance(), + "external": external, + "task": task_manifest, + "assets": inventory(settings.assets), + "vm_sha256": file_hash(settings.vm), + "proxy": { + "requested": proxy_requested, + "enabled": proxy_requested, + "config_sha256": ( + file_hash(settings.proxy_config) + if settings.proxy_config is not None + else None + ), + }, + "compatibility": {"evaluator_image_max": evaluator_image_max}, + "settings": settings.public(), + "prompts": { + "actor": digest(prompts.ACTOR.encode()), + "compaction": digest(prompts.COMPACTION.encode()), + "initial": digest(prompts.initial(actor_instruction).encode()), + "phases": [ + digest( + prompts.phase( + item["instruction"], + f"phase-{index}", + index, + len(phases), + previous_evaluated=index > 1, + ).encode() + ) + for index, item in enumerate(phases, 1) + ], + "module": file_hash(Path(prompts.__file__)), + }, + "tools": digest(json.dumps(TOOLS, sort_keys=True).encode()), + "guest_clients": CLIENT_HASHES, + } + store.write("manifest.json", manifest) + stage = "desktop_initialization" + with quiet_external(): + desktop_class = importlib.import_module( + "desktop_env.desktop_env" + ).DesktopEnv + # Retain the partially constructed object for cleanup if __init__ fails. + env = desktop_class.__new__(desktop_class) + desktop_class.__init__( + env, + provider_name="docker", + path_to_vm=str(settings.vm.resolve()), + os_type="Ubuntu", + action_space="pyautogui", + cache_dir=str(store.root / "evaluator-cache"), + headless=True, + require_a11y_tree=True, + enable_proxy=proxy_requested, + volume_size=task.get("volume_size") or 80, + force_disable_recording=True, + ) + guest = guest_factory(env) + guest.website_host_suffix = settings.website_host_suffix + stage = "control_provision" + guest.provision_controls() + stage = "task_setup" + with quiet_external(): + env.reset(task_config=task) + stage = "support_provision" + guest.provision_support() + client = client_factory(key, settings) + stage = "actor" + actor = Actor(client, guest, store, actor_instruction, settings) + if phases: + outcome = run_phases(actor, guest, env, task, phases) + else: + outcome = actor.run() + stage = "evaluation" + outcome["score"] = evaluate(env.evaluate, store) + stage = "evaluation" + stage = "persistence" + except BaseException as exc: + failure = exc + finally: + cleanup_failed = False + if env is not None: + try: + with quiet_external(): + env.close() + except BaseException: + cleanup_failed = True + if client is not None: + try: + client.close() + except BaseException: + cleanup_failed = True + restore_external(previous) + if cleanup_failed: + failure = ReleaseError("resource cleanup failed") + stage = "cleanup" + if failure is None: + try: + store.event("cleanup", {"status": "completed"}) + store.write("result.json", {"status": "completed", **outcome}) + except BaseException as exc: + failure = exc + stage = "persistence" + if failure is not None: + # Never serialize exception text, task objects, or evaluator payloads. + try: + store.write( + "result.json", + { + "status": "failed", + "stage": stage, + "error_type": type(failure).__name__, + "cleanup_failed": cleanup_failed, + "actor": actor.outcome("failed") if actor is not None else None, + }, + ) + except BaseException: + raise ReleaseError("failure result persistence failed") from None + if failure is not None: + raise ReleaseError(f"release failed during {stage}") from None + return outcome diff --git a/src/cuawright/desktop/run/cli.py b/src/cuawright/desktop/run/cli.py new file mode 100644 index 00000000..9f245209 --- /dev/null +++ b/src/cuawright/desktop/run/cli.py @@ -0,0 +1,46 @@ +import argparse +import sys +from pathlib import Path + +from .benchmarks.osworld import Settings, run + + +def parser(): + result = argparse.ArgumentParser( + description="CUAWright desktop: one official OSWorld-V2 task on Ubuntu via Docker." + ) + for flag in ("source", "tasks", "assets", "vm", "credentials", "results"): + result.add_argument("--" + flag, required=True, type=Path) + result.add_argument("--proxy-config", type=Path) + result.add_argument("--task-id", required=True) + result.add_argument("--model", required=True) + result.add_argument("--base-url", default="https://api.openai.com/v1") + result.add_argument( + "--reasoning", choices=("low", "medium", "high", "xhigh", "max"), default="high" + ) + result.add_argument("--steps", type=int, default=300) + result.add_argument("--compact-every", type=int, default=40) + result.add_argument("--max-output-tokens", type=int, default=32768) + result.add_argument("--website-host-suffix", default="") + return result + + +def main(argv=None): + if sys.version_info < (3, 12): + print("CUAWright desktop requires Python 3.12 or newer.", file=sys.stderr) + return 1 + settings = Settings(**vars(parser().parse_args(argv))) + try: + outcome = run(settings) + except Exception: + print( + "OSWorld release failed; inspect result.json if created. " + "External diagnostics are deliberately not logged.", + file=sys.stderr, + ) + return 1 + print( + f"{outcome['stop_reason']}; score={outcome['score']}; " + f"model_calls={outcome['model_calls']}" + ) + return 0 diff --git a/src/cuawright/desktop/utils/__init__.py b/src/cuawright/desktop/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/cuawright/desktop/utils/artifacts.py b/src/cuawright/desktop/utils/artifacts.py new file mode 100644 index 00000000..2e88e886 --- /dev/null +++ b/src/cuawright/desktop/utils/artifacts.py @@ -0,0 +1,147 @@ +import hashlib +import json +import os +import stat +import subprocess +from datetime import datetime, timezone +from pathlib import Path + +from ..exceptions import ReleaseError + + +def digest(data): + return hashlib.sha256(data).hexdigest() + + +def file_hash(path): + path = Path(path) + if path.is_symlink() or not path.is_file(): + raise ReleaseError("provenance requires nonsymlink regular files") + sha = hashlib.sha256() + with path.open("rb") as stream: + for block in iter(lambda: stream.read(1024 * 1024), b""): + sha.update(block) + return sha.hexdigest() + + +def inventory(root): + entries = [] + for path in sorted(root.rglob("*")): + if path.is_symlink(): + raise ReleaseError("provenance directory contains symlink") + if path.is_file(): + entries.append([path.relative_to(root).as_posix(), file_hash(path)]) + elif not path.is_dir(): + raise ReleaseError("provenance directory contains special file") + return { + "files": len(entries), + "sha256": digest(json.dumps(entries, separators=(",", ":")).encode()), + } + + +def runtime_inventory(root): + entries = [ + [path.relative_to(root).as_posix(), file_hash(path)] + for path in sorted(root.rglob("*.py")) + ] + return { + "files": len(entries), + "sha256": digest(json.dumps(entries, separators=(",", ":")).encode()), + } + + +def git(root, *args): + result = subprocess.run( + ["git", "-C", str(root), *args], capture_output=True, text=True, check=False + ) + if result.returncode: + raise ReleaseError("required Git inspection failed") + return result.stdout.strip() + + +def harness_provenance(): + root = Path(__file__).resolve().parents[4] + if (root / ".git").exists(): + return { + "commit": git(root, "rev-parse", "HEAD"), + "tree": git(root, "rev-parse", "HEAD^{tree}"), + "dirty": bool(git(root, "status", "--porcelain", "--untracked-files=all")), + "status_sha256": digest( + git(root, "status", "--porcelain", "--untracked-files=all").encode() + ), + "diff_sha256": digest(git(root, "diff", "HEAD", "--binary").encode()), + "runtime": runtime_inventory(Path(__file__).parents[1]), + } + metadata = Path(__file__).parents[1] / "build_provenance.json" + if not metadata.is_file(): + raise ReleaseError("wheel build provenance is missing") + provenance = json.loads(metadata.read_text(encoding="utf-8")) + if provenance["runtime"] != runtime_inventory(Path(__file__).parents[1]): + raise ReleaseError("wheel runtime does not match build provenance") + return provenance + + +def credentials(path): + path = Path(path) + fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW) + with os.fdopen(fd, "r", encoding="utf-8") as stream: + info = os.fstat(stream.fileno()) + if ( + not stat.S_ISREG(info.st_mode) + or info.st_mode & 0o077 + or info.st_size > 8192 + or info.st_uid != os.getuid() + ): + raise ReleaseError("credential file must be owned, regular, and mode 0600") + key = stream.read().strip() + if not key or any(character.isspace() for character in key): + raise ReleaseError("credential file must contain one nonempty API key") + return key + + +class Store: + def __init__(self, root): + self.root = Path(root) + self.root.mkdir(mode=0o700, parents=False, exist_ok=False) + self.trace = self.root / "trace.jsonl" + self.sequence = 0 + + def write(self, name, payload): + path = self.root / name + stage = self.root / (name + ".pending") + data = (json.dumps(payload, indent=2, allow_nan=False) + "\n").encode() + try: + fd = os.open(stage, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(fd, "wb") as stream: + stream.write(data) + stream.flush() + os.fsync(stream.fileno()) + os.replace(stage, path) + directory = os.open(self.root, os.O_RDONLY | os.O_DIRECTORY) + try: + os.fsync(directory) + finally: + os.close(directory) + except OSError: + raise ReleaseError("result persistence failed") from None + + def event(self, kind, payload): + self.sequence += 1 + record = { + "sequence": self.sequence, + "time": datetime.now(timezone.utc).isoformat(), + "kind": kind, + "data": payload, + } + try: + fd = os.open( + self.trace, + os.O_WRONLY | os.O_APPEND | os.O_CREAT | os.O_NOFOLLOW, + 0o600, + ) + with os.fdopen(fd, "w", encoding="utf-8") as stream: + stream.write(json.dumps(record, allow_nan=False) + "\n") + stream.flush() + os.fsync(stream.fileno()) + except OSError: + raise ReleaseError("trace persistence failed") from None diff --git a/src/cuawright/webwright/__init__.py b/src/cuawright/webwright/__init__.py new file mode 100644 index 00000000..689df525 --- /dev/null +++ b/src/cuawright/webwright/__init__.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +import os +from pathlib import Path +from typing import Any, Protocol + +try: + import dotenv +except ModuleNotFoundError: + class _DotenvShim: + @staticmethod + def load_dotenv(*args, **kwargs): + return False + dotenv = _DotenvShim() +try: + from platformdirs import user_config_dir +except ModuleNotFoundError: + def user_config_dir(appname: str) -> str: + return str(Path.home() / ".config" / appname) + +__version__ = "0.2.0" + +package_dir = Path(__file__).resolve().parent +global_config_dir = Path( + os.getenv("MSWEBA_GLOBAL_CONFIG_DIR") or user_config_dir("webwright") +) +global_config_dir.mkdir(parents=True, exist_ok=True) +global_config_file = global_config_dir / ".env" +dotenv.load_dotenv(dotenv_path=global_config_file) + + +class Model(Protocol): + config: Any + + def __call__(self, messages: list[dict[str, Any]], **kwargs) -> str: ... + + def query(self, messages: list[dict[str, Any]], **kwargs) -> dict[str, Any]: ... + + def format_message(self, **kwargs) -> dict[str, Any]: ... + + def format_observation_messages( + self, + message: dict[str, Any], + outputs: list[dict[str, Any]], + template_vars: dict[str, Any] | None = None, + ) -> list[dict[str, Any]]: ... + + def get_template_vars(self, **kwargs) -> dict[str, Any]: ... + + def serialize(self) -> dict[str, Any]: ... + + +class Environment(Protocol): + config: Any + + def prepare(self, **kwargs) -> None: ... + + def execute(self, action: dict[str, Any], cwd: str = "") -> dict[str, Any]: ... + + def get_template_vars(self, **kwargs) -> dict[str, Any]: ... + + def serialize(self) -> dict[str, Any]: ... + + def close(self) -> None: ... + + +class Agent(Protocol): + config: Any + + def run(self, task: str, **kwargs) -> dict[str, Any]: ... + + def save(self, path: Path | None, *extra_dicts) -> dict[str, Any]: ... + + +__all__ = [ + "Agent", + "Environment", + "Model", + "__version__", + "global_config_dir", + "global_config_file", + "package_dir", +] diff --git a/src/webwright/agents/__init__.py b/src/cuawright/webwright/agents/__init__.py similarity index 82% rename from src/webwright/agents/__init__.py rename to src/cuawright/webwright/agents/__init__.py index fcfad5be..341177a9 100644 --- a/src/webwright/agents/__init__.py +++ b/src/cuawright/webwright/agents/__init__.py @@ -3,10 +3,10 @@ import copy import importlib -from webwright import Agent, Environment, Model +from cuawright.webwright import Agent, Environment, Model _AGENT_MAPPING = { - "default": "webwright.agents.default.DefaultAgent", + "default": "cuawright.webwright.agents.default.DefaultAgent", } diff --git a/src/webwright/agents/default.py b/src/cuawright/webwright/agents/default.py similarity index 97% rename from src/webwright/agents/default.py rename to src/cuawright/webwright/agents/default.py index 808d1167..6c538da5 100644 --- a/src/webwright/agents/default.py +++ b/src/cuawright/webwright/agents/default.py @@ -8,9 +8,9 @@ from jinja2 import StrictUndefined, Template from pydantic import BaseModel -from webwright import Environment, Model, __version__ -from webwright.exceptions import FormatError, InterruptAgentFlow, LimitsExceeded -from webwright.utils.serialize import recursive_merge +from cuawright.webwright import Environment, Model, __version__ +from cuawright.webwright.exceptions import FormatError, InterruptAgentFlow, LimitsExceeded +from cuawright.webwright.utils.serialize import recursive_merge DEFAULT_SUMMARY_USER_PROMPT = """You are about to have your working context compacted to save tokens. @@ -215,8 +215,8 @@ def _self_reflection_gate_error(self) -> str | None: def _trajectory_gate_error(self) -> str | None: """Require a complete passing judgment for the current trajectory evidence.""" - from webwright.tools.self_reflection import _parse_final_verdict, validate_image_judge_records - from webwright.utils.browser_evidence import ( + from cuawright.webwright.tools.self_reflection import _parse_final_verdict, validate_image_judge_records + from cuawright.webwright.utils.browser_evidence import ( load_browser_steps, optional_file_digest, task_screenshot_paths, trajectory_evidence_digest, trajectory_images, ) @@ -271,7 +271,7 @@ def _tool_gate_error(self) -> str | None: return ( "Completion blocked: no final_runs/ directory exists yet. You must run final_script.py " "in a final_runs/run_/ folder and then run " - "`python -m webwright.tools.self_reflection --config self_reflect_config.json " + "`python -m cuawright.webwright.tools.self_reflection --config self_reflect_config.json " "--workspace-dir \"{0}\" --output final_runs/run_/self_reflect_result.json` with " "predicted_label == 1 before setting done=true." ).format(workspace_dir) @@ -297,7 +297,7 @@ def _tool_gate_error(self) -> str | None: if not judge_path.is_file(): return ( f"Completion blocked: {judge_path} does not exist. Run " - f"`python -m webwright.tools.self_reflection --config self_reflect_config.json " + f"`python -m cuawright.webwright.tools.self_reflection --config self_reflect_config.json " f"--workspace-dir \"{workspace_dir}\" --output {judge_path}` against the latest run " f"(run_{latest_run_id}) and only set done=true after it exits 0 with " f"predicted_label == 1." diff --git a/src/webwright/config/__init__.py b/src/cuawright/webwright/config/__init__.py similarity index 98% rename from src/webwright/config/__init__.py rename to src/cuawright/webwright/config/__init__.py index 3b93c431..4aa12f71 100644 --- a/src/webwright/config/__init__.py +++ b/src/cuawright/webwright/config/__init__.py @@ -7,7 +7,7 @@ import yaml -from webwright import package_dir +from cuawright.webwright import package_dir builtin_config_dir = package_dir / "config" diff --git a/src/webwright/config/base.yaml b/src/cuawright/webwright/config/base.yaml similarity index 90% rename from src/webwright/config/base.yaml rename to src/cuawright/webwright/config/base.yaml index 5eb24131..e857a106 100644 --- a/src/webwright/config/base.yaml +++ b/src/cuawright/webwright/config/base.yaml @@ -4,7 +4,7 @@ # Anthropic variants. Stack a model modifier on top via repeated -c flags: # # source ~/cred.sh -# python -m webwright.run.cli \ +# python -m cuawright.webwright.run.cli \ # -c base.yaml -c model_openai.yaml \ # -t "Search for flights from SEA to JFK on 2026-08-15 to 2026-08-20" \ # --start-url https://www.google.com/flights \ @@ -197,11 +197,11 @@ agent: ``` - Ask a grounded question about a saved screenshot: ``` - python -m webwright.tools.image_qa --workspace-dir "{{ workspace_dir }}" --image screenshots/explore.png --question "Is the BMW filter chip visibly selected?" + python -m cuawright.webwright.tools.image_qa --workspace-dir "{{ workspace_dir }}" --image screenshots/explore.png --question "Is the BMW filter chip visibly selected?" ``` - Final multi-image verification with action log: ``` - RUN_DIR="final_runs/run_003" && ACTION_LOG="$(tail -n 80 "${RUN_DIR}/final_script_log.txt")" && python -m webwright.tools.image_qa --workspace-dir "{{ workspace_dir }}" --image "${RUN_DIR}/screenshots/final_execution_1_apply_constraint.png" --image "${RUN_DIR}/screenshots/final_execution_2_sort.png" --image "${RUN_DIR}/screenshots/final_execution_3_final_state.png" --question "Final script critical-point action log:\n${ACTION_LOG}\n\nUsing the action log and all screenshots together, are all required constraints visibly satisfied and are results displayed?" + RUN_DIR="final_runs/run_003" && ACTION_LOG="$(tail -n 80 "${RUN_DIR}/final_script_log.txt")" && python -m cuawright.webwright.tools.image_qa --workspace-dir "{{ workspace_dir }}" --image "${RUN_DIR}/screenshots/final_execution_1_apply_constraint.png" --image "${RUN_DIR}/screenshots/final_execution_2_sort.png" --image "${RUN_DIR}/screenshots/final_execution_3_final_state.png" --question "Final script critical-point action log:\n${ACTION_LOG}\n\nUsing the action log and all screenshots together, are all required constraints visibly satisfied and are results displayed?" ``` ## Rules @@ -220,7 +220,7 @@ agent: ## Task Reflection Tool Do NOT hand-roll a `judge.py` that loops `image_qa`. Use the built-in - `webwright.tools.self_reflection` CLI. + `cuawright.webwright.tools.self_reflection` CLI. 1. Stage 1 β€” score each screenshot against the full set of critical points with a single (system, user+image) prompt pair. The tool parses a `Score: 1-5` and @@ -236,7 +236,7 @@ agent: **CLI interface:** ``` - python -m webwright.tools.self_reflection \ + python -m cuawright.webwright.tools.self_reflection \ --config {{ workspace_dir }}/self_reflect_config.json \ --workspace-dir "{{ workspace_dir }}" \ --output {{ workspace_dir }}/final_runs/run_/self_reflect_result.json @@ -309,7 +309,7 @@ agent: 3. `final_script.py` was executed successfully from scratch inside a `final_runs/run_/` folder, producing `final_script_log.txt` and all critical-point screenshots. - 4. `python -m webwright.tools.self_reflection --config self_reflect_config.json + 4. `python -m cuawright.webwright.tools.self_reflection --config self_reflect_config.json --workspace-dir "{{ workspace_dir }}" --output final_runs/run_/self_reflect_result.json` was executed against that run, exited 0, and wrote `final_runs/run_/self_reflect_result.json` with `"predicted_label": 1`. @@ -375,7 +375,7 @@ agent: ## Image QA Tool - Use image_qa during exploration to inspect screenshots and verify UI state: - `python -m webwright.tools.image_qa --workspace-dir "{{ workspace_dir }}" --image screenshots/example.png --question "inspect prompt"` + `python -m cuawright.webwright.tools.image_qa --workspace-dir "{{ workspace_dir }}" --image screenshots/example.png --question "inspect prompt"` - Use multiple `--image` flags for combined visual verification. - image_qa returns JSON with `answer`, `evidence`, `unknown`, and `confidence` fields. @@ -390,13 +390,13 @@ agent: ``` Each critical point must be independently verifiable from a screenshot or log entry. - 2. **Author self_reflect_config.json (once)**: Write `{{ workspace_dir }}/self_reflect_config.json` containing only the four prompts (`image_judge_system_prompt`, `image_judge_user_prompt`, `final_verdict_system_prompt`, `final_verdict_user_prompt`) for `webwright.tools.self_reflection`. Embed the full critical-point list from `plan.md` and the task description into the user prompts, but keep the prompts generic β€” this file is reused verbatim for every `self_reflection` invocation, so do NOT hard-code a specific run id, screenshot filename, or `final_script_log.txt` content. + 2. **Author self_reflect_config.json (once)**: Write `{{ workspace_dir }}/self_reflect_config.json` containing only the four prompts (`image_judge_system_prompt`, `image_judge_user_prompt`, `final_verdict_system_prompt`, `final_verdict_user_prompt`) for `cuawright.webwright.tools.self_reflection`. Embed the full critical-point list from `plan.md` and the task description into the user prompts, but keep the prompts generic β€” this file is reused verbatim for every `self_reflection` invocation, so do NOT hard-code a specific run id, screenshot filename, or `final_script_log.txt` content. 3. **Exploration**: Inspect `task.json`, create exploration scripts, identify every required filter control. Use `image_qa` during exploration to verify UI state. 4. **Final script**: Write `final_script.py`, run it once in a new `final_runs/run_/` folder. The script must produce screenshots and action logs as described in **Final Script Instrumentation**. - 5. **Run self_reflection**: Execute `python -m webwright.tools.self_reflection --config self_reflect_config.json --workspace-dir "{{ workspace_dir }}" --output final_runs/run_/self_reflect_result.json`. The tool auto-attaches every screenshot in the latest `final_runs/run_*/screenshots/` folder (default `--auto-latest-run final_runs`) β€” you do NOT pass an image list. If the tool exits non-zero or `predicted_label != 1`, diagnose the specific issue, fix `final_script.py`, re-run it in a new `final_runs/run_/` folder, and re-invoke `self_reflection` against the new run. Do NOT edit `self_reflect_config.json` between attempts. + 5. **Run self_reflection**: Execute `python -m cuawright.webwright.tools.self_reflection --config self_reflect_config.json --workspace-dir "{{ workspace_dir }}" --output final_runs/run_/self_reflect_result.json`. The tool auto-attaches every screenshot in the latest `final_runs/run_*/screenshots/` folder (default `--auto-latest-run final_runs`) β€” you do NOT pass an image list. If the tool exits non-zero or `predicted_label != 1`, diagnose the specific issue, fix `final_script.py`, re-run it in a new `final_runs/run_/` folder, and re-invoke `self_reflection` against the new run. Do NOT edit `self_reflect_config.json` between attempts. 6. **Declare done**: {{ 'Declare completion' if response_mode | default('json_schema') == 'run_command_tool' else 'Set `"done": true`' }} ONLY after `self_reflection` exits 0 and `self_reflect_result.json` reports `"predicted_label": 1` for the latest run. The external judge reads that same `self_reflect_result.json` as the final verdict. Declaring done in any other state is a failure. @@ -417,7 +417,7 @@ agent: 1. `plan.md` exists with all critical points identified. 2. `self_reflect_config.json` exists with all four prompts populated for `self_reflection`. 3. `final_script.py` was run from scratch in a `final_runs/run_/` folder. - 4. `python -m webwright.tools.self_reflection --config self_reflect_config.json --workspace-dir "{{ workspace_dir }}" --output final_runs/run_/self_reflect_result.json` was executed against that run, exited 0, and wrote `final_runs/run_/self_reflect_result.json` with `"predicted_label": 1`. + 4. `python -m cuawright.webwright.tools.self_reflection --config self_reflect_config.json --workspace-dir "{{ workspace_dir }}" --output final_runs/run_/self_reflect_result.json` was executed against that run, exited 0, and wrote `final_runs/run_/self_reflect_result.json` with `"predicted_label": 1`. 5. `ls -R final_runs/run_` and `cat final_runs/run_/final_script_log.txt` confirm the expected artifacts. Do NOT declare done if `self_reflection` exits non-zero, if `predicted_label` is not 1, if the run folder is missing, if required screenshots are missing, or if `self_reflection` has not been run against the latest `final_runs/run_/`. diff --git a/src/webwright/config/best_default_judge_json_persistent_cli.yaml b/src/cuawright/webwright/config/best_default_judge_json_persistent_cli.yaml similarity index 89% rename from src/webwright/config/best_default_judge_json_persistent_cli.yaml rename to src/cuawright/webwright/config/best_default_judge_json_persistent_cli.yaml index f56ef740..442779c9 100644 --- a/src/webwright/config/best_default_judge_json_persistent_cli.yaml +++ b/src/cuawright/webwright/config/best_default_judge_json_persistent_cli.yaml @@ -85,13 +85,13 @@ agent: Create the persistent session once before the first browser step: - python -m webwright.tools.browser_session create --workspace-dir "{{ workspace_dir }}" --start-url "{{ start_url }}" + python -m cuawright.webwright.tools.browser_session create --workspace-dir "{{ workspace_dir }}" --start-url "{{ start_url }}" Run an incremental step by sending an async Python function body on stdin. `page`, `context`, `browser`, `playwright`, `task`, and `workspace` already exist. `--action` is REQUIRED and must be a concise natural-language description of what the code does: - python -m webwright.tools.browser_session step --workspace-dir "{{ workspace_dir }}" --action "Open filters and select nonstop flights" --code-file - <<'PY' + python -m cuawright.webwright.tools.browser_session step --workspace-dir "{{ workspace_dir }}" --action "Open filters and select nonstop flights" --code-file - <<'PY' await page.get_by_role("button", name="Filters").click() await page.get_by_role("checkbox", name="Nonstop").check() print(await page.locator("body").aria_snapshot()) @@ -99,7 +99,7 @@ agent: End-of-step screenshots default to NONE. Save one whenever you think it provides useful visual evidence: - python -m webwright.tools.browser_session step --workspace-dir "{{ workspace_dir }}" --action "Apply the exact price range" --screenshot always --code-file - <<'PY' + python -m cuawright.webwright.tools.browser_session step --workspace-dir "{{ workspace_dir }}" --action "Apply the exact price range" --screenshot always --code-file - <<'PY' await page.get_by_label("Minimum price").fill("100") await page.get_by_label("Maximum price").fill("300") await page.get_by_role("button", name="Apply").click() @@ -113,17 +113,17 @@ agent: 2. reacquire the current page from context.pages; 3. open a NEW TAB in the same persistent context (`page = await context.new_page()`) and navigate; 4. only when `error_kind` is `session`, create a new persistent browser epoch with - `python -m webwright.tools.browser_session create --workspace-dir "{{ workspace_dir }}" --replace --start-url "{{ start_url }}"`. + `python -m cuawright.webwright.tools.browser_session create --workspace-dir "{{ workspace_dir }}" --replace --start-url "{{ start_url }}"`. - Use `python -m webwright.tools.browser_session status --workspace-dir "{{ workspace_dir }}"` when session health - is unclear. Use `python -m webwright.tools.browser_session close --workspace-dir "{{ workspace_dir }}"` when the + Use `python -m cuawright.webwright.tools.browser_session status --workspace-dir "{{ workspace_dir }}"` when session health + is unclear. Use `python -m cuawright.webwright.tools.browser_session close --workspace-dir "{{ workspace_dir }}"` when the task is complete. Replacing or closing a session never deletes prior step or screenshot evidence. ## Inspecting saved images To inspect a saved image with your own vision, run this as the only shell command: - python -m webwright.tools.image_read --path "{{ workspace_dir }}/screenshots/result.png" + python -m cuawright.webwright.tools.image_read --path "{{ workspace_dir }}/screenshots/result.png" Use one canonical absolute PNG, JPEG, GIF, or WebP path inside the task workspace. The harness validates the descriptor and attaches the image to the next observation. @@ -170,7 +170,7 @@ agent: Reflect over ALL screenshots saved by every prior incremental browser step: - python -m webwright.tools.self_reflection --scope trajectory --config "{{ workspace_dir }}/judge_config.json" --workspace-dir "{{ workspace_dir }}" --output reflection/judge_result.json + python -m cuawright.webwright.tools.self_reflection --scope trajectory --config "{{ workspace_dir }}/judge_config.json" --workspace-dir "{{ workspace_dir }}" --output reflection/judge_result.json A successful result is invalidated by any later browser step, changed screenshot, plan.md change, or judge_config.json change. diff --git a/src/webwright/config/crafted_cli.yaml b/src/cuawright/webwright/config/crafted_cli.yaml similarity index 90% rename from src/webwright/config/crafted_cli.yaml rename to src/cuawright/webwright/config/crafted_cli.yaml index b67560b8..73f7a74c 100644 --- a/src/webwright/config/crafted_cli.yaml +++ b/src/cuawright/webwright/config/crafted_cli.yaml @@ -8,7 +8,7 @@ # # Usage: # source ~/cred.sh -# python -m webwright.run.cli \ +# python -m cuawright.webwright.run.cli \ # -c base.yaml -c model_openai.yaml -c crafted_cli.yaml \ # -t "" \ # --start-url \ @@ -160,11 +160,11 @@ agent: ``` - Ask a grounded question about a saved screenshot: ``` - python -m webwright.tools.image_qa --workspace-dir "{{ workspace_dir }}" --image screenshots/explore.png --question "Is the BMW filter chip visibly selected?" + python -m cuawright.webwright.tools.image_qa --workspace-dir "{{ workspace_dir }}" --image screenshots/explore.png --question "Is the BMW filter chip visibly selected?" ``` - Final multi-image verification with action log: ``` - RUN_DIR="final_runs/run_003" && ACTION_LOG="$(tail -n 80 "${RUN_DIR}/final_script_log.txt")" && python -m webwright.tools.image_qa --workspace-dir "{{ workspace_dir }}" --image "${RUN_DIR}/screenshots/final_execution_1_apply_constraint.png" --image "${RUN_DIR}/screenshots/final_execution_2_sort.png" --image "${RUN_DIR}/screenshots/final_execution_3_final_state.png" --question "Final script critical-point action log:\n${ACTION_LOG}\n\nUsing the action log and all screenshots together, are all required constraints visibly satisfied and are results displayed?" + RUN_DIR="final_runs/run_003" && ACTION_LOG="$(tail -n 80 "${RUN_DIR}/final_script_log.txt")" && python -m cuawright.webwright.tools.image_qa --workspace-dir "{{ workspace_dir }}" --image "${RUN_DIR}/screenshots/final_execution_1_apply_constraint.png" --image "${RUN_DIR}/screenshots/final_execution_2_sort.png" --image "${RUN_DIR}/screenshots/final_execution_3_final_state.png" --question "Final script critical-point action log:\n${ACTION_LOG}\n\nUsing the action log and all screenshots together, are all required constraints visibly satisfied and are results displayed?" ``` ## Rules @@ -182,7 +182,7 @@ agent: ## Task Reflection Tool Do NOT hand-roll a `judge.py` that loops `image_qa`. Use the built-in - `webwright.tools.self_reflection` CLI. + `cuawright.webwright.tools.self_reflection` CLI. 1. Stage 1 β€” score each screenshot against the full set of critical points with a single (system, user+image) prompt pair. The tool parses a `Score: 1-5` and @@ -198,7 +198,7 @@ agent: **CLI interface:** ``` - python -m webwright.tools.self_reflection \ + python -m cuawright.webwright.tools.self_reflection \ --config {{ workspace_dir }}/judge_config.json \ --workspace-dir "{{ workspace_dir }}" \ --output {{ workspace_dir }}/final_runs/run_/judge_result.json @@ -277,7 +277,7 @@ agent: successfully from scratch with NO arguments inside a `final_runs/run_/` folder, producing `final_script_log.txt` (including a `step 0 params: ...` line) and all critical-point screenshots. - 4. `python -m webwright.tools.self_reflection --config judge_config.json + 4. `python -m cuawright.webwright.tools.self_reflection --config judge_config.json --workspace-dir "{{ workspace_dir }}" --output final_runs/run_/judge_result.json` was executed against that run, exited 0, and wrote `final_runs/run_/judge_result.json` with `"predicted_label": 1`. @@ -344,7 +344,7 @@ agent: ## Image QA Tool - Use image_qa during exploration to inspect screenshots and verify UI state: - `python -m webwright.tools.image_qa --workspace-dir "{{ workspace_dir }}" --image screenshots/example.png --question "inspect prompt"` + `python -m cuawright.webwright.tools.image_qa --workspace-dir "{{ workspace_dir }}" --image screenshots/example.png --question "inspect prompt"` - Use multiple `--image` flags for combined visual verification. - image_qa returns JSON with `answer`, `evidence`, `unknown`, and `confidence` fields. @@ -366,13 +366,13 @@ agent: ``` Each critical point must be independently verifiable from a screenshot or log entry. Each parameter listed here MUST appear as both a function argument AND a `--flag` on the CLI in `final_script.py`. - 2. **Author judge_config.json (once)**: Write `{{ workspace_dir }}/judge_config.json` containing only the four prompts (`image_judge_system_prompt`, `image_judge_user_prompt`, `final_verdict_system_prompt`, `final_verdict_user_prompt`) for `webwright.tools.self_reflection`. Embed the full critical-point list from `plan.md` and the task description into the user prompts, but keep the prompts generic β€” this file is reused verbatim for every `self_reflection` invocation, so do NOT hard-code a specific run id, screenshot filename, or `final_script_log.txt` content. + 2. **Author judge_config.json (once)**: Write `{{ workspace_dir }}/judge_config.json` containing only the four prompts (`image_judge_system_prompt`, `image_judge_user_prompt`, `final_verdict_system_prompt`, `final_verdict_user_prompt`) for `cuawright.webwright.tools.self_reflection`. Embed the full critical-point list from `plan.md` and the task description into the user prompts, but keep the prompts generic β€” this file is reused verbatim for every `self_reflection` invocation, so do NOT hard-code a specific run id, screenshot filename, or `final_script_log.txt` content. 3. **Exploration**: Inspect `task.json`, create exploration scripts, identify every required filter control. Use `image_qa` during exploration to verify UI state. 4. **Final script**: Write `final_script.py` as a CLI tool wrapping a single reusable function (see the **Final-Script Shape (CLI Tool)** section in the system prompt). Every parameter listed under `# Parameters` in `plan.md` MUST appear both as a function argument (with a docstring `Args:` entry) and as an `argparse` `--flag` whose default equals the concrete task value. Run the script once with NO arguments in a new `final_runs/run_/` folder so the defaults reproduce the original task. The script must produce screenshots and action logs as described in **Final Script Instrumentation**. - 5. **Run self_reflection**: Execute `python -m webwright.tools.self_reflection --config judge_config.json --workspace-dir "{{ workspace_dir }}" --output final_runs/run_/judge_result.json`. The tool auto-attaches every screenshot in the latest `final_runs/run_*/screenshots/` folder (default `--auto-latest-run final_runs`) β€” you do NOT pass an image list. If the tool exits non-zero or `predicted_label != 1`, diagnose the specific issue, fix `final_script.py` (preserving the CLI shape), re-run it with NO arguments in a new `final_runs/run_/` folder, and re-invoke `self_reflection` against the new run. Do NOT edit `judge_config.json` between attempts. + 5. **Run self_reflection**: Execute `python -m cuawright.webwright.tools.self_reflection --config judge_config.json --workspace-dir "{{ workspace_dir }}" --output final_runs/run_/judge_result.json`. The tool auto-attaches every screenshot in the latest `final_runs/run_*/screenshots/` folder (default `--auto-latest-run final_runs`) β€” you do NOT pass an image list. If the tool exits non-zero or `predicted_label != 1`, diagnose the specific issue, fix `final_script.py` (preserving the CLI shape), re-run it with NO arguments in a new `final_runs/run_/` folder, and re-invoke `self_reflection` against the new run. Do NOT edit `judge_config.json` between attempts. 6. **Declare done**: Set `"done": true` ONLY after `self_reflection` exits 0 and `judge_result.json` reports `"predicted_label": 1` for the latest run. The external judge reads that same `judge_result.json` as the final verdict. Declaring done in any other state is a failure. @@ -397,7 +397,7 @@ agent: 1. `plan.md` exists with all critical points identified AND a `# Parameters` section listing every parameterisable requirement. 2. `judge_config.json` exists with all four prompts populated for `self_reflection`. 3. `final_script.py` is a CLI tool: it defines a reusable function with a full `Args:` docstring, every `# Parameters` entry is both a function argument and an `argparse --flag` with the task value as default, and it was run from scratch with NO arguments in a `final_runs/run_/` folder. - 4. `python -m webwright.tools.self_reflection --config judge_config.json --workspace-dir "{{ workspace_dir }}" --output final_runs/run_/judge_result.json` was executed against that run, exited 0, and wrote `final_runs/run_/judge_result.json` with `"predicted_label": 1`. + 4. `python -m cuawright.webwright.tools.self_reflection --config judge_config.json --workspace-dir "{{ workspace_dir }}" --output final_runs/run_/judge_result.json` was executed against that run, exited 0, and wrote `final_runs/run_/judge_result.json` with `"predicted_label": 1`. 5. `ls -R final_runs/run_` and `cat final_runs/run_/final_script_log.txt` confirm the expected artifacts (including a `step 0 params: ...` line). Do NOT declare done if `self_reflection` exits non-zero, if `predicted_label` is not 1, if the run folder is missing, if required screenshots are missing, or if `self_reflection` has not been run against the latest `final_runs/run_/`. diff --git a/src/webwright/config/local_browser.yaml b/src/cuawright/webwright/config/local_browser.yaml similarity index 98% rename from src/webwright/config/local_browser.yaml rename to src/cuawright/webwright/config/local_browser.yaml index 207c0a24..8f960fac 100644 --- a/src/webwright/config/local_browser.yaml +++ b/src/cuawright/webwright/config/local_browser.yaml @@ -1,7 +1,7 @@ # Live local browser modifier. Stack it on top of base.yaml, then add a model # modifier, for example: # -# python -m webwright.run.cli \ +# python -m cuawright.webwright.run.cli \ # -c base.yaml -c local_browser.yaml -c model_openai.yaml \ # -t "Open example.com and report the title" \ # --start-url https://example.com @@ -180,7 +180,7 @@ agent: - There is NO workspace directory. Do NOT write any files; do NOT create `final_runs/`, `screenshots/`, `plan.md`, `final_script.py`, or any log. - Drive the live `page`, `context`, `browser`, and `playwright` variables. Do NOT re-launch Playwright. - - Do NOT invoke `webwright.tools.image_qa` or `webwright.tools.self_reflection`. + - Do NOT invoke `cuawright.webwright.tools.image_qa` or `cuawright.webwright.tools.self_reflection`. - Browser mode is `{{ browser_mode }}`. ## Web Task Rules diff --git a/src/webwright/config/model_claude.yaml b/src/cuawright/webwright/config/model_claude.yaml similarity index 80% rename from src/webwright/config/model_claude.yaml rename to src/cuawright/webwright/config/model_claude.yaml index e229a1de..24fe1909 100644 --- a/src/webwright/config/model_claude.yaml +++ b/src/cuawright/webwright/config/model_claude.yaml @@ -1,7 +1,7 @@ # Model modifier β€” Claude (Anthropic) variant. # # Stack on top of base.yaml: -# python -m webwright.run.cli -c base.yaml -c model_claude.yaml ... +# python -m cuawright.webwright.run.cli -c base.yaml -c model_claude.yaml ... # # Required env: # - ANTHROPIC_API_KEY (agent, image_qa, and self_reflection tools) diff --git a/src/webwright/config/model_openai.yaml b/src/cuawright/webwright/config/model_openai.yaml similarity index 83% rename from src/webwright/config/model_openai.yaml rename to src/cuawright/webwright/config/model_openai.yaml index 981f9e75..430ee874 100644 --- a/src/webwright/config/model_openai.yaml +++ b/src/cuawright/webwright/config/model_openai.yaml @@ -1,7 +1,7 @@ # Model modifier β€” OpenAI variant. # # Stack on top of base.yaml: -# python -m webwright.run.cli -c base.yaml -c model_openai.yaml ... +# python -m cuawright.webwright.run.cli -c base.yaml -c model_openai.yaml ... # # Required env: OPENAI_API_KEY (also used by image_qa / self_reflection tools). diff --git a/src/webwright/config/model_openrouter.yaml b/src/cuawright/webwright/config/model_openrouter.yaml similarity index 59% rename from src/webwright/config/model_openrouter.yaml rename to src/cuawright/webwright/config/model_openrouter.yaml index 7965faaf..3300e3e6 100644 --- a/src/webwright/config/model_openrouter.yaml +++ b/src/cuawright/webwright/config/model_openrouter.yaml @@ -1,8 +1,8 @@ # Model modifier β€” OpenRouter chat completions variant. # # Stack on top of base.yaml or local_browser.yaml: -# python -m webwright.run.cli -c base.yaml -c model_openrouter.yaml ... -# python -m webwright.run.cli -c base.yaml -c local_browser.yaml -c model_openrouter.yaml ... +# python -m cuawright.webwright.run.cli -c base.yaml -c model_openrouter.yaml ... +# python -m cuawright.webwright.run.cli -c base.yaml -c local_browser.yaml -c model_openrouter.yaml ... # # Required env: OPENROUTER_API_KEY. diff --git a/src/webwright/config/persistent_browser.yaml b/src/cuawright/webwright/config/persistent_browser.yaml similarity index 83% rename from src/webwright/config/persistent_browser.yaml rename to src/cuawright/webwright/config/persistent_browser.yaml index b2e00213..7f46e269 100644 --- a/src/webwright/config/persistent_browser.yaml +++ b/src/cuawright/webwright/config/persistent_browser.yaml @@ -2,7 +2,7 @@ # # Identical to base.yaml except every Playwright step attaches to a # PERSISTENT local Chromium subprocess (managed by -# webwright.tools.persistent_local_browser) instead of a fresh +# cuawright.webwright.tools.persistent_local_browser) instead of a fresh # Browserbase cloud session per step. Page state, cookies, local-storage, # and any open dropdowns/dialogs survive across bash steps because every # script attaches via `connect_over_cdp(connectUrl)` and ends with @@ -10,7 +10,7 @@ # Chromium subprocess keeps running. # # Usage: -# python -m webwright.run.cli \ +# python -m cuawright.webwright.run.cli \ # -c persistent_browser.yaml -c model_openai.yaml \ # -t "" --start-url \ # --task-id -o outputs/default @@ -102,13 +102,13 @@ agent: - Put exactly one shell command in the `bash_command` string. Never emit raw Python or shell outside that field. Use heredocs (`python - <<'PY' ... PY`) to run Python inline when needed. - Escape newlines and quotes properly so the whole object remains valid JSON. - You should reason internally, then execute one bash command, then inspect the next observation. - - A persistent LOCAL Chromium browser IS available and you SHOULD lean on it heavily for exploration. Run `python -m webwright.tools.persistent_local_browser --workspace-dir "{{ workspace_dir }}" create --out .lb_session.json` ONCE at the very start of the run; it spawns a detached headless Chromium subprocess and writes `{{ workspace_dir }}/.lb_session.json` containing `id`, `pid`, `connectUrl`, and `userDataDir`. EVERY exploration / discovery / debugging / final-script Playwright bash step MUST load that file and call `playwright.chromium.connect_over_cdp(connectUrl)`, then end with `await browser.close()`. For a CDP-attached browser `browser.close()` only closes the Playwright connection β€” the underlying Chromium subprocess keeps running, so the page, cookies, local-storage, and currently-open dropdowns/dialogs all persist across steps. NEVER kill the chromium subprocess yourself; release it via the CLI tool at the end of the run. + - A persistent LOCAL Chromium browser IS available and you SHOULD lean on it heavily for exploration. Run `python -m cuawright.webwright.tools.persistent_local_browser --workspace-dir "{{ workspace_dir }}" create --out .lb_session.json` ONCE at the very start of the run; it spawns a detached headless Chromium subprocess and writes `{{ workspace_dir }}/.lb_session.json` containing `id`, `pid`, `connectUrl`, and `userDataDir`. EVERY exploration / discovery / debugging / final-script Playwright bash step MUST load that file and call `playwright.chromium.connect_over_cdp(connectUrl)`, then end with `await browser.close()`. For a CDP-attached browser `browser.close()` only closes the Playwright connection β€” the underlying Chromium subprocess keeps running, so the page, cookies, local-storage, and currently-open dropdowns/dialogs all persist across steps. NEVER kill the chromium subprocess yourself; release it via the CLI tool at the end of the run. - Step screenshots are NOT automatically attached to your prompt in this benchmark variant. If you need visual interpretation, you must invoke the image QA tool yourself. - Set `"done": true` only when the task goal is complete and `final_script.py` is the final artifact. - NEVER set `"done": true` in the same response as a non-empty `bash_command`. Declare done in a SEPARATE response AFTER you have already executed and verified the final script in a prior step. - In `thought`, write in detail your observation, reasoning, and next step. - Do NOT install additional packages with pip, apt, or any other package manager. All required packages (playwright, httpx, etc.) are already installed. - - You MUST release the persistent local Chromium at the end of the run. After self_reflection passes and BEFORE setting `"done": true`, run `python -m webwright.tools.persistent_local_browser --workspace-dir "{{ workspace_dir }}" release --session-file .lb_session.json --delete-file --delete-user-data` in its own bash step. Forgetting this leaves a zombie Chromium process. The completion gate requires that `{{ workspace_dir }}/.lb_session.json` no longer exist when you declare done. + - You MUST release the persistent local Chromium at the end of the run. After self_reflection passes and BEFORE setting `"done": true`, run `python -m cuawright.webwright.tools.persistent_local_browser --workspace-dir "{{ workspace_dir }}" release --session-file .lb_session.json --delete-file --delete-user-data` in its own bash step. Forgetting this leaves a zombie Chromium process. The completion gate requires that `{{ workspace_dir }}/.lb_session.json` no longer exist when you declare done. ## Playwright Examples @@ -116,7 +116,7 @@ agent: ``` { "thought": "Spawn the persistent local Chromium that every later Playwright step will attach to.", - "bash_command": "python -m webwright.tools.persistent_local_browser --workspace-dir \"{{ workspace_dir }}\" create --out .lb_session.json", + "bash_command": "python -m cuawright.webwright.tools.persistent_local_browser --workspace-dir \"{{ workspace_dir }}\" create --out .lb_session.json", "done": false, "final_response": "" } @@ -175,11 +175,11 @@ agent: ## Helpful Command Patterns - Create the persistent local Chromium session (run ONCE at the start): - `python -m webwright.tools.persistent_local_browser --workspace-dir "{{ workspace_dir }}" create --out .lb_session.json` + `python -m cuawright.webwright.tools.persistent_local_browser --workspace-dir "{{ workspace_dir }}" create --out .lb_session.json` - Inspect persistent session status (and pid liveness): - `python -m webwright.tools.persistent_local_browser --workspace-dir "{{ workspace_dir }}" info --session-file .lb_session.json` + `python -m cuawright.webwright.tools.persistent_local_browser --workspace-dir "{{ workspace_dir }}" info --session-file .lb_session.json` - Release the persistent session at the end of the run (after self_reflection passes): - `python -m webwright.tools.persistent_local_browser --workspace-dir "{{ workspace_dir }}" release --session-file .lb_session.json --delete-file --delete-user-data` + `python -m cuawright.webwright.tools.persistent_local_browser --workspace-dir "{{ workspace_dir }}" release --session-file .lb_session.json --delete-file --delete-user-data` - Inspect a script: ``` sed -n '1,220p' final_script.py @@ -190,11 +190,11 @@ agent: ``` - Ask a grounded question about a saved screenshot: ``` - python -m webwright.tools.image_qa --workspace-dir "{{ workspace_dir }}" --image screenshots/explore.png --question "Is the BMW filter chip visibly selected?" + python -m cuawright.webwright.tools.image_qa --workspace-dir "{{ workspace_dir }}" --image screenshots/explore.png --question "Is the BMW filter chip visibly selected?" ``` - Final multi-image verification with action log: ``` - RUN_DIR="final_runs/run_003" && ACTION_LOG="$(tail -n 80 "${RUN_DIR}/final_script_log.txt")" && python -m webwright.tools.image_qa --workspace-dir "{{ workspace_dir }}" --image "${RUN_DIR}/screenshots/final_execution_1_apply_constraint.png" --image "${RUN_DIR}/screenshots/final_execution_2_sort.png" --image "${RUN_DIR}/screenshots/final_execution_3_final_state.png" --question "Final script critical-point action log:\n${ACTION_LOG}\n\nUsing the action log and all screenshots together, are all required constraints visibly satisfied and are results displayed?" + RUN_DIR="final_runs/run_003" && ACTION_LOG="$(tail -n 80 "${RUN_DIR}/final_script_log.txt")" && python -m cuawright.webwright.tools.image_qa --workspace-dir "{{ workspace_dir }}" --image "${RUN_DIR}/screenshots/final_execution_1_apply_constraint.png" --image "${RUN_DIR}/screenshots/final_execution_2_sort.png" --image "${RUN_DIR}/screenshots/final_execution_3_final_state.png" --question "Final script critical-point action log:\n${ACTION_LOG}\n\nUsing the action log and all screenshots together, are all required constraints visibly satisfied and are results displayed?" ``` ## Rules @@ -217,7 +217,7 @@ agent: ## Task Reflection Tool Do NOT hand-roll a `judge.py` that loops `image_qa`. Use the built-in - `webwright.tools.self_reflection` CLI. + `cuawright.webwright.tools.self_reflection` CLI. 1. Stage 1 β€” score each screenshot against the full set of critical points with a single (system, user+image) prompt pair. The tool parses a `Score: 1-5` and @@ -233,7 +233,7 @@ agent: **CLI interface:** ``` - python -m webwright.tools.self_reflection \ + python -m cuawright.webwright.tools.self_reflection \ --config {{ workspace_dir }}/judge_config.json \ --workspace-dir "{{ workspace_dir }}" \ --output {{ workspace_dir }}/final_runs/run_/judge_result.json @@ -306,7 +306,7 @@ agent: 3. `final_script.py` was executed successfully from scratch inside a `final_runs/run_/` folder, producing `final_script_log.txt` and all critical-point screenshots. - 4. `python -m webwright.tools.self_reflection --config judge_config.json + 4. `python -m cuawright.webwright.tools.self_reflection --config judge_config.json --workspace-dir "{{ workspace_dir }}" --output final_runs/run_/judge_result.json` was executed against that run, exited 0, and wrote `final_runs/run_/judge_result.json` with `"predicted_label": 1`. @@ -315,7 +315,7 @@ agent: `cat final_runs/run_/final_script_log.txt` to confirm the artifacts and logs are in place. 6. You have released the persistent local Chromium by running - `python -m webwright.tools.persistent_local_browser --workspace-dir + `python -m cuawright.webwright.tools.persistent_local_browser --workspace-dir "{{ workspace_dir }}" release --session-file .lb_session.json --delete-file --delete-user-data` in a prior step, and `{{ workspace_dir }}/.lb_session.json` no longer exists. Skipping this leaves a zombie Chromium subprocess. @@ -355,7 +355,7 @@ agent: - Create `final_runs/run_/` folders for every clean execution of the final script. Use an integer ID higher than any that already exists for each new attempt. - Store each run's `final_script.py`, `final_script_log.txt`, and final verification screenshots **only** inside that run folder. - Always use the PERSISTENT LOCAL Chromium at `{{ workspace_dir }}/.lb_session.json`. - - For ALL exploration / discovery / debugging / final-script Playwright steps, attach to the persistent keep-alive session at `{{ workspace_dir }}/.lb_session.json` (created via `python -m webwright.tools.persistent_local_browser --workspace-dir "{{ workspace_dir }}" create --out .lb_session.json`) and end every script with `await browser.close()` β€” for a CDP-attached browser this only closes the Playwright connection, NOT the chromium subprocess, so the page, cookies, local-storage, and currently-open dropdowns/dialogs all persist across steps. Never spawn a second local browser. + - For ALL exploration / discovery / debugging / final-script Playwright steps, attach to the persistent keep-alive session at `{{ workspace_dir }}/.lb_session.json` (created via `python -m cuawright.webwright.tools.persistent_local_browser --workspace-dir "{{ workspace_dir }}" create --out .lb_session.json`) and end every script with `await browser.close()` β€” for a CDP-attached browser this only closes the Playwright connection, NOT the chromium subprocess, so the page, cookies, local-storage, and currently-open dropdowns/dialogs all persist across steps. Never spawn a second local browser. ## Web Task Rules @@ -379,7 +379,7 @@ agent: ## Image QA Tool - Use image_qa during exploration to inspect screenshots and verify UI state: - `python -m webwright.tools.image_qa --workspace-dir "{{ workspace_dir }}" --image screenshots/example.png --question "inspect prompt"` + `python -m cuawright.webwright.tools.image_qa --workspace-dir "{{ workspace_dir }}" --image screenshots/example.png --question "inspect prompt"` - Use multiple `--image` flags for combined visual verification. - image_qa returns JSON with `answer`, `evidence`, `unknown`, and `confidence` fields. @@ -387,7 +387,7 @@ agent: 0. **Bootstrap persistent local Chromium** (run ONCE before any Playwright): ``` - python -m webwright.tools.persistent_local_browser \ + python -m cuawright.webwright.tools.persistent_local_browser \ --workspace-dir "{{ workspace_dir }}" create --out .lb_session.json ``` This spawns a detached headless Chromium subprocess and writes @@ -405,16 +405,16 @@ agent: ``` Each critical point must be independently verifiable from a screenshot or log entry. - 2. **Author judge_config.json (once)**: Write `{{ workspace_dir }}/judge_config.json` containing only the four prompts (`image_judge_system_prompt`, `image_judge_user_prompt`, `final_verdict_system_prompt`, `final_verdict_user_prompt`) for `webwright.tools.self_reflection`. Embed the full critical-point list from `plan.md` and the task description into the user prompts, but keep the prompts generic β€” this file is reused verbatim for every `self_reflection` invocation, so do NOT hard-code a specific run id, screenshot filename, or `final_script_log.txt` content. + 2. **Author judge_config.json (once)**: Write `{{ workspace_dir }}/judge_config.json` containing only the four prompts (`image_judge_system_prompt`, `image_judge_user_prompt`, `final_verdict_system_prompt`, `final_verdict_user_prompt`) for `cuawright.webwright.tools.self_reflection`. Embed the full critical-point list from `plan.md` and the task description into the user prompts, but keep the prompts generic β€” this file is reused verbatim for every `self_reflection` invocation, so do NOT hard-code a specific run id, screenshot filename, or `final_script_log.txt` content. 3. **Exploration**: Inspect `task.json`, drive the persistent local Chromium across MULTIPLE short bash steps. Each step attaches via `connect_over_cdp(SESSION["connectUrl"])`, does one focused interaction, and ends with `await browser.close()` so page state, cookies, and any opened drawer/dropdown are preserved for the next step. Use `image_qa` during exploration to verify UI state. 4. **Final script**: Write `final_script.py`, run it once in a new `final_runs/run_/` folder. The script must also attach to the persistent local Chromium via `.lb_session.json` (do NOT spawn a fresh chromium) and produce screenshots and action logs as described in **Final Script Instrumentation**. - 5. **Run self_reflection**: Execute `python -m webwright.tools.self_reflection --config judge_config.json --workspace-dir "{{ workspace_dir }}" --output final_runs/run_/judge_result.json`. The tool auto-attaches every screenshot in the latest `final_runs/run_*/screenshots/` folder (default `--auto-latest-run final_runs`) β€” you do NOT pass an image list. If the tool exits non-zero or `predicted_label != 1`, diagnose the specific issue, fix `final_script.py`, re-run it in a new `final_runs/run_/` folder, and re-invoke `self_reflection` against the new run. Do NOT edit `judge_config.json` between attempts. + 5. **Run self_reflection**: Execute `python -m cuawright.webwright.tools.self_reflection --config judge_config.json --workspace-dir "{{ workspace_dir }}" --output final_runs/run_/judge_result.json`. The tool auto-attaches every screenshot in the latest `final_runs/run_*/screenshots/` folder (default `--auto-latest-run final_runs`) β€” you do NOT pass an image list. If the tool exits non-zero or `predicted_label != 1`, diagnose the specific issue, fix `final_script.py`, re-run it in a new `final_runs/run_/` folder, and re-invoke `self_reflection` against the new run. Do NOT edit `judge_config.json` between attempts. 6. **Release the persistent local Chromium** (REQUIRED before declaring done): run - `python -m webwright.tools.persistent_local_browser --workspace-dir + `python -m cuawright.webwright.tools.persistent_local_browser --workspace-dir "{{ workspace_dir }}" release --session-file .lb_session.json --delete-file --delete-user-data` in its own bash step. Verify `{{ workspace_dir }}/.lb_session.json` no longer exists. Skipping this leaves @@ -444,7 +444,7 @@ agent: 1. `plan.md` exists with all critical points identified. 2. `judge_config.json` exists with all four prompts populated for `self_reflection`. 3. `final_script.py` was run from scratch in a `final_runs/run_/` folder. - 4. `python -m webwright.tools.self_reflection --config judge_config.json --workspace-dir "{{ workspace_dir }}" --output final_runs/run_/judge_result.json` was executed against that run, exited 0, and wrote `final_runs/run_/judge_result.json` with `"predicted_label": 1`. + 4. `python -m cuawright.webwright.tools.self_reflection --config judge_config.json --workspace-dir "{{ workspace_dir }}" --output final_runs/run_/judge_result.json` was executed against that run, exited 0, and wrote `final_runs/run_/judge_result.json` with `"predicted_label": 1`. 5. `ls -R final_runs/run_` and `cat final_runs/run_/final_script_log.txt` confirm the expected artifacts. 6. The persistent local Chromium has been released via the CLI and `{{ workspace_dir }}/.lb_session.json` no longer exists. diff --git a/src/webwright/config/task_showcase.yaml b/src/cuawright/webwright/config/task_showcase.yaml similarity index 92% rename from src/webwright/config/task_showcase.yaml rename to src/cuawright/webwright/config/task_showcase.yaml index e3e1d02c..b4cde0f1 100644 --- a/src/webwright/config/task_showcase.yaml +++ b/src/cuawright/webwright/config/task_showcase.yaml @@ -2,7 +2,7 @@ # # Usage: # source ~/cred.sh -# python -m webwright.run.cli \ +# python -m cuawright.webwright.run.cli \ # -c base.yaml -c model_openai.yaml -c task_showcase.yaml \ # -t "..." \ # --task-id my_repeatable_task \ @@ -188,11 +188,11 @@ agent: ``` - Ask a grounded question about a saved screenshot: ``` - python -m webwright.tools.image_qa --workspace-dir "{{ workspace_dir }}" --image screenshots/explore.png --question "Is the BMW filter chip visibly selected?" + python -m cuawright.webwright.tools.image_qa --workspace-dir "{{ workspace_dir }}" --image screenshots/explore.png --question "Is the BMW filter chip visibly selected?" ``` - Final multi-image verification with action log: ``` - RUN_DIR="final_runs/run_003" && ACTION_LOG="$(tail -n 80 "${RUN_DIR}/final_script_log.txt")" && python -m webwright.tools.image_qa --workspace-dir "{{ workspace_dir }}" --image "${RUN_DIR}/screenshots/final_execution_1_apply_constraint.png" --image "${RUN_DIR}/screenshots/final_execution_2_sort.png" --image "${RUN_DIR}/screenshots/final_execution_3_final_state.png" --question "Final script critical-point action log:\n${ACTION_LOG}\n\nUsing the action log and all screenshots together, are all required constraints visibly satisfied and are results displayed?" + RUN_DIR="final_runs/run_003" && ACTION_LOG="$(tail -n 80 "${RUN_DIR}/final_script_log.txt")" && python -m cuawright.webwright.tools.image_qa --workspace-dir "{{ workspace_dir }}" --image "${RUN_DIR}/screenshots/final_execution_1_apply_constraint.png" --image "${RUN_DIR}/screenshots/final_execution_2_sort.png" --image "${RUN_DIR}/screenshots/final_execution_3_final_state.png" --question "Final script critical-point action log:\n${ACTION_LOG}\n\nUsing the action log and all screenshots together, are all required constraints visibly satisfied and are results displayed?" ``` ## Rules @@ -211,7 +211,7 @@ agent: ## Task Reflection Tool Do NOT hand-roll a `judge.py` that loops `image_qa`. Use the built-in - `webwright.tools.self_reflection` CLI. + `cuawright.webwright.tools.self_reflection` CLI. 1. Stage 1 β€” score each screenshot against the full set of critical points with a single (system, user+image) prompt pair. The tool parses a `Score: 1-5` and @@ -227,7 +227,7 @@ agent: **CLI interface:** ``` - python -m webwright.tools.self_reflection \ + python -m cuawright.webwright.tools.self_reflection \ --config {{ workspace_dir }}/self_reflect_config.json \ --workspace-dir "{{ workspace_dir }}" \ --output {{ workspace_dir }}/final_runs/run_/self_reflect_result.json @@ -384,7 +384,7 @@ agent: 5. `task_showcase/tasks//report.json` exists and validates. 6. The Flask renderer was smoke-tested against `{{ workspace_dir }}/task_showcase/tasks`, or Flask was unavailable and the exact render command was reported. - 7. `python -m webwright.tools.self_reflection --config self_reflect_config.json + 7. `python -m cuawright.webwright.tools.self_reflection --config self_reflect_config.json --workspace-dir "{{ workspace_dir }}" --output final_runs/run_/self_reflect_result.json` was executed after JSON/render validation, against that same run, exited 0, and wrote `final_runs/run_/self_reflect_result.json` with `"predicted_label": 1`. @@ -460,7 +460,7 @@ agent: ## Image QA Tool - Use image_qa during exploration to inspect screenshots and verify UI state: - `python -m webwright.tools.image_qa --workspace-dir "{{ workspace_dir }}" --image screenshots/example.png --question "inspect prompt"` + `python -m cuawright.webwright.tools.image_qa --workspace-dir "{{ workspace_dir }}" --image screenshots/example.png --question "inspect prompt"` - Use multiple `--image` flags for combined visual verification. - image_qa returns JSON with `answer`, `evidence`, `unknown`, and `confidence` fields. @@ -479,7 +479,7 @@ agent: ``` Each critical point must be independently verifiable from a screenshot, log entry, extracted source data, or generated JSON field. - 2. **Author self_reflect_config.json (once)**: Write `{{ workspace_dir }}/self_reflect_config.json` containing only the four prompts (`image_judge_system_prompt`, `image_judge_user_prompt`, `final_verdict_system_prompt`, `final_verdict_user_prompt`) for `webwright.tools.self_reflection`. Embed the full critical-point list from `plan.md` and the task description into the user prompts, but keep the prompts generic - this file is reused verbatim for every `self_reflection` invocation, so do NOT hard-code a specific run id, screenshot filename, or `final_script_log.txt` content. + 2. **Author self_reflect_config.json (once)**: Write `{{ workspace_dir }}/self_reflect_config.json` containing only the four prompts (`image_judge_system_prompt`, `image_judge_user_prompt`, `final_verdict_system_prompt`, `final_verdict_user_prompt`) for `cuawright.webwright.tools.self_reflection`. Embed the full critical-point list from `plan.md` and the task description into the user prompts, but keep the prompts generic - this file is reused verbatim for every `self_reflection` invocation, so do NOT hard-code a specific run id, screenshot filename, or `final_script_log.txt` content. 3. **Exploration**: Inspect `{{ task_metadata_path }}`, create exploration scripts, identify every required filter control, source URL, and output field. Use `image_qa` during exploration to verify UI state when visual evidence matters. @@ -503,7 +503,7 @@ agent: ``` python - <<'PY' from pathlib import Path - import webwright + import cuawright.webwright as webwright starts = [Path.cwd().resolve(), Path(webwright.__file__).resolve()] candidates = [] for start in starts: @@ -522,7 +522,7 @@ agent: - If Flask or the renderer file is unavailable, do not install or generate it. Validate JSON and include the exact render command or missing renderer path in `final_response`. - If JSON validation or renderer smoke testing fails, fix `final_script.py`, rerun it in a new `final_runs/run_/` folder, and repeat JSON validation and render smoke testing before self_reflection. - 7. **Run self_reflection**: Execute `python -m webwright.tools.self_reflection --config self_reflect_config.json --workspace-dir "{{ workspace_dir }}" --output final_runs/run_/self_reflect_result.json` only after JSON validation and renderer smoke testing have passed for the same run. The tool auto-attaches screenshots from `final_runs/run_/screenshots` when that is the latest numbered run with screenshots - you do NOT pass an image list. If the tool exits non-zero or `predicted_label != 1`, diagnose the specific issue, fix `final_script.py`, re-run it in a new `final_runs/run_/` folder, and re-invoke JSON validation, renderer smoke testing, and self_reflection against the new run. Do NOT edit `self_reflect_config.json` between attempts. + 7. **Run self_reflection**: Execute `python -m cuawright.webwright.tools.self_reflection --config self_reflect_config.json --workspace-dir "{{ workspace_dir }}" --output final_runs/run_/self_reflect_result.json` only after JSON validation and renderer smoke testing have passed for the same run. The tool auto-attaches screenshots from `final_runs/run_/screenshots` when that is the latest numbered run with screenshots - you do NOT pass an image list. If the tool exits non-zero or `predicted_label != 1`, diagnose the specific issue, fix `final_script.py`, re-run it in a new `final_runs/run_/` folder, and re-invoke JSON validation, renderer smoke testing, and self_reflection against the new run. Do NOT edit `self_reflect_config.json` between attempts. 8. **Declare done**: Set `"done": true` ONLY after both Task Showcase JSON files validate, the renderer was smoke-tested or Flask unavailability was handled exactly as described above, `self_reflection` exits 0, and `self_reflect_result.json` reports `"predicted_label": 1` for the latest run. The external judge reads that same `self_reflect_result.json` as the final verdict. Declaring done in any other state is a failure. @@ -553,7 +553,7 @@ agent: 4. `task_showcase/tasks//task.json` exists and validates. 5. `task_showcase/tasks//report.json` exists and validates. 6. The Flask renderer was smoke-tested against `{{ workspace_dir }}/task_showcase/tasks`, or Flask was unavailable and the exact render command was reported. - 7. `python -m webwright.tools.self_reflection --config self_reflect_config.json --workspace-dir "{{ workspace_dir }}" --output final_runs/run_/self_reflect_result.json` was executed after JSON/render validation against that same run, exited 0, and wrote `final_runs/run_/self_reflect_result.json` with `"predicted_label": 1`. + 7. `python -m cuawright.webwright.tools.self_reflection --config self_reflect_config.json --workspace-dir "{{ workspace_dir }}" --output final_runs/run_/self_reflect_result.json` was executed after JSON/render validation against that same run, exited 0, and wrote `final_runs/run_/self_reflect_result.json` with `"predicted_label": 1`. 8. `ls -R final_runs/run_`, `ls -R final_runs/run_/screenshots`, and `cat final_runs/run_/final_script_log.txt` confirm the expected artifacts. 9. `final_response` names the generated JSON paths and the render command. diff --git a/src/webwright/environments/__init__.py b/src/cuawright/webwright/environments/__init__.py similarity index 71% rename from src/webwright/environments/__init__.py rename to src/cuawright/webwright/environments/__init__.py index 3258077c..433d912e 100644 --- a/src/webwright/environments/__init__.py +++ b/src/cuawright/webwright/environments/__init__.py @@ -3,11 +3,11 @@ import copy import importlib -from webwright import Environment +from cuawright.webwright import Environment _ENVIRONMENT_MAPPING = { - "local_browser": "webwright.environments.local_browser.LocalBrowserEnvironment", - "local_workspace": "webwright.environments.local_workspace.LocalWorkspaceEnvironment", + "local_browser": "cuawright.webwright.environments.local_browser.LocalBrowserEnvironment", + "local_workspace": "cuawright.webwright.environments.local_workspace.LocalWorkspaceEnvironment", } diff --git a/src/webwright/environments/local_browser.py b/src/cuawright/webwright/environments/local_browser.py similarity index 100% rename from src/webwright/environments/local_browser.py rename to src/cuawright/webwright/environments/local_browser.py diff --git a/src/webwright/environments/local_workspace.py b/src/cuawright/webwright/environments/local_workspace.py similarity index 97% rename from src/webwright/environments/local_workspace.py rename to src/cuawright/webwright/environments/local_workspace.py index 97eb04dc..99938975 100644 --- a/src/webwright/environments/local_workspace.py +++ b/src/cuawright/webwright/environments/local_workspace.py @@ -10,7 +10,7 @@ from pydantic import BaseModel, Field -from webwright.tools.image_read import image_read_descriptor +from cuawright.webwright.tools.image_read import image_read_descriptor _EXPORT_RE = re.compile(r"^export\s+([A-Za-z_][A-Za-z0-9_]*)=(.*)$") _IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".webp"} @@ -100,7 +100,12 @@ def _image_read_path(command: str) -> str | None: argv = shlex.split(command, comments=False, posix=True) except ValueError: return None - if len(argv) != 5 or argv[:4] != ["python", "-m", "webwright.tools.image_read", "--path"]: + if ( + len(argv) != 5 + or argv[:2] != ["python", "-m"] + or argv[2] not in {"cuawright.webwright.tools.image_read", "webwright.tools.image_read"} + or argv[3] != "--path" + ): return None return argv[4] diff --git a/src/webwright/exceptions.py b/src/cuawright/webwright/exceptions.py similarity index 100% rename from src/webwright/exceptions.py rename to src/cuawright/webwright/exceptions.py diff --git a/src/webwright/models/__init__.py b/src/cuawright/webwright/models/__init__.py similarity index 67% rename from src/webwright/models/__init__.py rename to src/cuawright/webwright/models/__init__.py index 6631ac0d..f14474cf 100644 --- a/src/webwright/models/__init__.py +++ b/src/cuawright/webwright/models/__init__.py @@ -3,12 +3,12 @@ import copy import importlib -from webwright import Model +from cuawright.webwright import Model _MODEL_MAPPING = { - "openai": "webwright.models.openai_model.OpenAIModel", - "anthropic": "webwright.models.anthropic_model.AnthropicModel", - "openrouter": "webwright.models.openrouter_model.OpenRouterModel", + "openai": "cuawright.webwright.models.openai_model.OpenAIModel", + "anthropic": "cuawright.webwright.models.anthropic_model.AnthropicModel", + "openrouter": "cuawright.webwright.models.openrouter_model.OpenRouterModel", } diff --git a/src/webwright/models/anthropic_model.py b/src/cuawright/webwright/models/anthropic_model.py similarity index 99% rename from src/webwright/models/anthropic_model.py rename to src/cuawright/webwright/models/anthropic_model.py index 53393b5b..64257e90 100644 --- a/src/webwright/models/anthropic_model.py +++ b/src/cuawright/webwright/models/anthropic_model.py @@ -6,7 +6,7 @@ import random from typing import Any -from webwright.models.base import ( +from cuawright.webwright.models.base import ( BaseModel, BaseModelConfig, OptStr, diff --git a/src/webwright/models/base.py b/src/cuawright/webwright/models/base.py similarity index 98% rename from src/webwright/models/base.py rename to src/cuawright/webwright/models/base.py index 6238471d..5db2af07 100644 --- a/src/webwright/models/base.py +++ b/src/cuawright/webwright/models/base.py @@ -13,9 +13,9 @@ from jinja2 import StrictUndefined, Template from pydantic import BaseModel as PydanticBaseModel, BeforeValidator, field_validator -from webwright.exceptions import FormatError -from webwright.utils.logging import append_runtime_log -from webwright.utils.runtime import run_async +from cuawright.webwright.exceptions import FormatError +from cuawright.webwright.utils.logging import append_runtime_log +from cuawright.webwright.utils.runtime import run_async def _none_to_str(value: Any) -> str: @@ -260,7 +260,7 @@ def __init__(self, *, config_class: type | None = None, **kwargs): if getattr(self.config, "response_mode", "") == "run_command_tool": if self.config.action_field != "bash_command": raise ValueError("run_command_tool requires the local workspace bash_command action field") - from webwright.models.tool_calls import DEFAULT_TOOL_FORMAT_ERROR_TEMPLATE + from cuawright.webwright.models.tool_calls import DEFAULT_TOOL_FORMAT_ERROR_TEMPLATE self.config.format_error_template = DEFAULT_TOOL_FORMAT_ERROR_TEMPLATE def _parse_response(self, payload: dict[str, Any]) -> dict[str, Any]: diff --git a/src/webwright/models/openai_model.py b/src/cuawright/webwright/models/openai_model.py similarity index 98% rename from src/webwright/models/openai_model.py rename to src/cuawright/webwright/models/openai_model.py index ba9520f9..d46cfa4e 100644 --- a/src/webwright/models/openai_model.py +++ b/src/cuawright/webwright/models/openai_model.py @@ -7,8 +7,8 @@ from pathlib import Path from typing import Any, Literal -from webwright.models.tool_calls import RUN_COMMAND_TOOL, TOOL_RESPONSE_MODE, _normalize_response_items, parse_tool_call_output -from webwright.models.base import ( +from cuawright.webwright.models.tool_calls import RUN_COMMAND_TOOL, TOOL_RESPONSE_MODE, _normalize_response_items, parse_tool_call_output +from cuawright.webwright.models.base import ( BaseModel, BaseModelConfig, OptStr, diff --git a/src/webwright/models/openrouter_model.py b/src/cuawright/webwright/models/openrouter_model.py similarity index 99% rename from src/webwright/models/openrouter_model.py rename to src/cuawright/webwright/models/openrouter_model.py index 95c300fd..0a2ccb57 100644 --- a/src/webwright/models/openrouter_model.py +++ b/src/cuawright/webwright/models/openrouter_model.py @@ -5,7 +5,7 @@ from typing import Any from urllib.parse import urlparse -from webwright.models.base import ( +from cuawright.webwright.models.base import ( BaseModel, BaseModelConfig, OptStr, diff --git a/src/webwright/models/tool_calls.py b/src/cuawright/webwright/models/tool_calls.py similarity index 100% rename from src/webwright/models/tool_calls.py rename to src/cuawright/webwright/models/tool_calls.py diff --git a/src/cuawright/webwright/run/__init__.py b/src/cuawright/webwright/run/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/webwright/run/cli.py b/src/cuawright/webwright/run/cli.py similarity index 93% rename from src/webwright/run/cli.py rename to src/cuawright/webwright/run/cli.py index 343ab707..e3dd2f84 100644 --- a/src/webwright/run/cli.py +++ b/src/cuawright/webwright/run/cli.py @@ -7,12 +7,12 @@ import typer from rich.console import Console -from webwright.agents import get_agent -from webwright.config import get_config_from_spec, snapshot_config_specs -from webwright.environments import get_environment -from webwright.models import get_model -from webwright.utils.serialize import UNSET, recursive_merge -from webwright.run.doctor import run_doctor +from cuawright.webwright.agents import get_agent +from cuawright.webwright.config import get_config_from_spec, snapshot_config_specs +from cuawright.webwright.environments import get_environment +from cuawright.webwright.models import get_model +from cuawright.webwright.utils.serialize import UNSET, recursive_merge +from cuawright.webwright.run.doctor import run_doctor DEFAULT_CONFIGS = ["base.yaml", "model_openai.yaml"] diff --git a/src/webwright/run/doctor.py b/src/cuawright/webwright/run/doctor.py similarity index 100% rename from src/webwright/run/doctor.py rename to src/cuawright/webwright/run/doctor.py diff --git a/src/webwright/run/run_command.sh b/src/cuawright/webwright/run/run_command.sh similarity index 84% rename from src/webwright/run/run_command.sh rename to src/cuawright/webwright/run/run_command.sh index a07bc107..34400ce7 100644 --- a/src/webwright/run/run_command.sh +++ b/src/cuawright/webwright/run/run_command.sh @@ -1,4 +1,4 @@ -python -m webwright.run.cli \ +python -m cuawright.webwright.run.cli \ -c base.yaml -c model_openai.yaml \ -t "Search for flights from SEA to JFK on 2026-08-15 to 2026-08-20" \ --start-url https://www.google.com/flights \ diff --git a/src/webwright/skill_factory/README.md b/src/cuawright/webwright/skill_factory/README.md similarity index 96% rename from src/webwright/skill_factory/README.md rename to src/cuawright/webwright/skill_factory/README.md index 6a599e4f..fc1af500 100644 --- a/src/webwright/skill_factory/README.md +++ b/src/cuawright/webwright/skill_factory/README.md @@ -102,7 +102,7 @@ live Google Flights. A learned skill is a plain CLI. Run it directly β€” no model, no API key, about 40 seconds: ```bash -python src/webwright/skill_factory/examples/learned_library/what_is_the_earliest_nonstop_flight_from_2c8dab1/skill.py \ +python src/cuawright/webwright/skill_factory/examples/learned_library/what_is_the_earliest_nonstop_flight_from_2c8dab1/skill.py \ --origin-city Seattle --origin-code SEA --destination-city Denver --destination-code DEN --date 2026-08-26 ``` @@ -121,7 +121,7 @@ It prints the ten fixed steps it executed and the location of the saved screensh Now consider you have another task the skill almost fits but can't be directedly used: finding the nonstop flight with the **shortest duration**, while the existing skill finds the **earliest departure**. The workflow is nearly identical, with only the final filter changed, so `route` selects the skill as a prior and returns `adapt`. ```bash -python -m webwright.skill_factory route \ +python -m cuawright.webwright.skill_factory route \ --task "What is the nonstop flight with the shortest flight duration from Seattle (SEA) to Denver (DEN) on 2026-08-26 (one-way)? Return the answer as a list: [flight_number, airline, duration]." \ --library ./library \ --start-url https://www.google.com/flights -c @@ -153,7 +153,7 @@ Everything below requires an API key. The standard workflow is `init` β†’ `build `init` drafts a spec; after reviewing it, run `build` to solve the instances and learn a verified skill. ```bash -python -m webwright.skill_factory init "the earliest nonstop flight from to on " +python -m cuawright.webwright.skill_factory init "the earliest nonstop flight from to on " ``` ```yaml @@ -179,14 +179,14 @@ build: # every key here is also a CLI flag; the flag wins Review the generated task, site, verification mode, and instances before building. Incorrect instance values will train the skill on the wrong task. For account-scoped tasks, `init` leaves private values blank. ```bash -python -m webwright.skill_factory build skill.yaml --library ./library --jobs 3 +python -m cuawright.webwright.skill_factory build skill.yaml --library ./library --jobs 3 # where it lands ↑ ↑ solve 3 at a time # on a gateway: nothing extra β€” build points the agent at your OPENAI_ENDPOINT / OPENAI_MODEL # and prints the config it used. Pass -c only to give the agent a *different* model. # no spec of your own yet? the one behind the checked-in library is sitting next to you in # examples/, and --dry-run only prints the plan β€” no key, no browser: -python -m webwright.skill_factory build flights.skill.yaml --library ./library --dry-run +python -m cuawright.webwright.skill_factory build flights.skill.yaml --library ./library --dry-run ``` `build` solves each unfinished instance and then calls `learn`. It shows the planned tasks before starting. `--dry-run` only prints the plan, while `--jobs N` controls parallel solves. Start with 3–5 jobs to avoid site throttling. @@ -198,14 +198,14 @@ For changing answers such as prices or rankings, shape verification only checks Point `learn` at a folder of completed Webwright runs to distill them directly. ```bash -python -m webwright.skill_factory learn outputs/ --library ./library +python -m cuawright.webwright.skill_factory learn outputs/ --library ./library ``` The repository also includes three example trajectories: ```bash -cd src/webwright/skill_factory/examples -python -m webwright.skill_factory learn trajectories --library ./library --verify off +cd src/cuawright/webwright/skill_factory/examples +python -m cuawright.webwright.skill_factory learn trajectories --library ./library --verify off ``` This turns three runs into one five-parameter skill in about 100 seconds. The examples use flights scheduled for August 15, 2026, so `--verify off` avoids stale browser replay. Before that date, you can also use `--verify strict`. See [the trajectory README](examples/trajectories/README.md) for details on when the examples become stale. @@ -226,7 +226,7 @@ Common failures: * **The answer differs from the recorded answer.** Remove the incorrect run or provide a gold answer: ```bash - python -m webwright.skill_factory learn build_outputs/ --library ./library \ + python -m cuawright.webwright.skill_factory learn build_outputs/ --library ./library \ --golds '{"": ""}' ``` diff --git a/src/webwright/skill_factory/__init__.py b/src/cuawright/webwright/skill_factory/__init__.py similarity index 79% rename from src/webwright/skill_factory/__init__.py rename to src/cuawright/webwright/skill_factory/__init__.py index cb91a5a2..7b15571c 100644 --- a/src/webwright/skill_factory/__init__.py +++ b/src/cuawright/webwright/skill_factory/__init__.py @@ -1,4 +1,4 @@ -"""webwright.skill_factory β€” a memory/skill library module for webwright. +"""cuawright.webwright.skill_factory β€” a memory/skill library module for cuawright.webwright. Store solved tasks as reusable, executable code skills; retrieve + judge (use/adapt/skip) at solve time; admit via a gate; and grow the library incrementally (evolve). Plugs into webwright @@ -16,9 +16,9 @@ from .prompt import with_skill_hint # NOTE: `update` (evolve / Trace) is deliberately NOT imported here. It is the module run as a -# CLI (`python -m webwright.skill_factory.update`); importing it eagerly makes runpy print a +# CLI (`python -m cuawright.webwright.skill_factory.update`); importing it eagerly makes runpy print a # "found in sys.modules" RuntimeWarning on every CLI invocation. Import it directly: -# from webwright.skill_factory.update import evolve, Trace +# from cuawright.webwright.skill_factory.update import evolve, Trace __all__ = [ "Library", "Skill", "retrieve", "Candidate", "decide", "Decision", diff --git a/src/webwright/skill_factory/__main__.py b/src/cuawright/webwright/skill_factory/__main__.py similarity index 60% rename from src/webwright/skill_factory/__main__.py rename to src/cuawright/webwright/skill_factory/__main__.py index 6f57829e..ef1c47ab 100644 --- a/src/webwright/skill_factory/__main__.py +++ b/src/cuawright/webwright/skill_factory/__main__.py @@ -1,12 +1,12 @@ -"""python -m webwright.skill_factory β€” friendly entry points.""" +"""python -m cuawright.webwright.skill_factory β€” friendly entry points.""" import sys CMDS = { - "init": "webwright.skill_factory.init", - "build": "webwright.skill_factory.build", - "learn": "webwright.skill_factory.learn", - "update": "webwright.skill_factory.update", - "route": "webwright.skill_factory.route", + "init": "cuawright.webwright.skill_factory.init", + "build": "cuawright.webwright.skill_factory.build", + "learn": "cuawright.webwright.skill_factory.learn", + "update": "cuawright.webwright.skill_factory.update", + "route": "cuawright.webwright.skill_factory.route", } def main() -> int: @@ -14,7 +14,7 @@ def main() -> int: import importlib mod = importlib.import_module(CMDS[sys.argv[1]]) return mod.main(sys.argv[2:]) - print("usage: python -m webwright.skill_factory …\n" + print("usage: python -m cuawright.webwright.skill_factory …\n" " init draft a skill.yaml skeleton from a one-line need (you fill the values)\n" " build solve a spec's instances, then learn β€” for a task you haven't solved yet\n" " learn distill a folder of finished runs into skills (no manifest needed)\n" diff --git a/src/webwright/skill_factory/build.py b/src/cuawright/webwright/skill_factory/build.py similarity index 98% rename from src/webwright/skill_factory/build.py rename to src/cuawright/webwright/skill_factory/build.py index 0b146713..b4f9f793 100644 --- a/src/webwright/skill_factory/build.py +++ b/src/cuawright/webwright/skill_factory/build.py @@ -1,4 +1,4 @@ -"""python -m webwright.skill_factory build β€” solve a template's instances, then learn. +"""python -m cuawright.webwright.skill_factory build β€” solve a template's instances, then learn. build = solve x N + learn. Give it a skill spec (a task template + a table of instances) and it solves each instance with the webwright agent, then distills the solves into a verified library @@ -203,7 +203,7 @@ def _ticker(stop: threading.Event, idxs: list[int]) -> None: def main(argv=None) -> int: - p = argparse.ArgumentParser(prog="python -m webwright.skill_factory build", + p = argparse.ArgumentParser(prog="python -m cuawright.webwright.skill_factory build", description="Solve a template's instances, then learn a skill.") p.add_argument("spec", help="skill.yaml: task template + start_url + instances (draft with init).") p.add_argument("--library", default="library") diff --git a/src/webwright/skill_factory/decide.py b/src/cuawright/webwright/skill_factory/decide.py similarity index 100% rename from src/webwright/skill_factory/decide.py rename to src/cuawright/webwright/skill_factory/decide.py diff --git a/src/webwright/skill_factory/entry_shim.py b/src/cuawright/webwright/skill_factory/entry_shim.py similarity index 100% rename from src/webwright/skill_factory/entry_shim.py rename to src/cuawright/webwright/skill_factory/entry_shim.py diff --git a/src/webwright/skill_factory/examples/README.md b/src/cuawright/webwright/skill_factory/examples/README.md similarity index 93% rename from src/webwright/skill_factory/examples/README.md rename to src/cuawright/webwright/skill_factory/examples/README.md index bea2e9ae..af217749 100644 --- a/src/webwright/skill_factory/examples/README.md +++ b/src/cuawright/webwright/skill_factory/examples/README.md @@ -18,7 +18,7 @@ examples/ Three from-scratch solves of "earliest nonstop flight" on Google Flights (SEAβ†’JFK, SFOβ†’BOS, LAXβ†’ORD; 59, 25 and 40 agent steps) were grouped by -`python -m webwright.skill_factory learn --verify strict` into one template with **five** +`python -m cuawright.webwright.skill_factory learn --verify strict` into one template with **five** lifted parameters, and the distilled skill reproduced all three training answers standalone before it landed (`meta.json`: `verified: true, grade: executable`): @@ -53,7 +53,7 @@ The task below asks for the *shortest-duration* nonstop, which this skill (it fi can't run as-is, so `route` adapts it with the agent. Drop `--start-url` to just print the decision: ```bash -python -m webwright.skill_factory route \ +python -m cuawright.webwright.skill_factory route \ --task "What is the nonstop flight with the shortest flight duration from Seattle (SEA) to Denver (DEN) on 2026-08-26 (one-way)? Return the answer as a list: [flight_number, airline, duration]." \ --library ./learned_library \ --start-url https://www.google.com/flights -c @@ -68,7 +68,7 @@ Manual mode (explicit manifests, gold gates): see the module README; the two produced, and running it reproduces the whole loop: ```bash -python -m webwright.skill_factory build flights.skill.yaml --library ./library --jobs 3 +python -m cuawright.webwright.skill_factory build flights.skill.yaml --library ./library --jobs 3 ``` Its `date` is pinned so the run is reproducible, which also means it goes stale β€” move the date diff --git a/src/webwright/skill_factory/examples/batch.example.json b/src/cuawright/webwright/skill_factory/examples/batch.example.json similarity index 100% rename from src/webwright/skill_factory/examples/batch.example.json rename to src/cuawright/webwright/skill_factory/examples/batch.example.json diff --git a/src/webwright/skill_factory/examples/flights.skill.yaml b/src/cuawright/webwright/skill_factory/examples/flights.skill.yaml similarity index 93% rename from src/webwright/skill_factory/examples/flights.skill.yaml rename to src/cuawright/webwright/skill_factory/examples/flights.skill.yaml index 3e543bf6..95e5800d 100644 --- a/src/webwright/skill_factory/examples/flights.skill.yaml +++ b/src/cuawright/webwright/skill_factory/examples/flights.skill.yaml @@ -1,5 +1,5 @@ # The example spec β€” the whole loop as a file you can read, edit and re-run: -# python -m webwright.skill_factory build flights.skill.yaml --library ./library --jobs 3 +# python -m cuawright.webwright.skill_factory build flights.skill.yaml --library ./library --jobs 3 # # Task chosen on purpose: a flight SCHEDULE is a fact the page states plainly, it doesn't move on # its own, and it reads the same on your machine as on ours. That is what lets `verify: strict` diff --git a/src/webwright/skill_factory/examples/learned_library/what_is_the_earliest_nonstop_flight_from_2c8dab1/meta.json b/src/cuawright/webwright/skill_factory/examples/learned_library/what_is_the_earliest_nonstop_flight_from_2c8dab1/meta.json similarity index 100% rename from src/webwright/skill_factory/examples/learned_library/what_is_the_earliest_nonstop_flight_from_2c8dab1/meta.json rename to src/cuawright/webwright/skill_factory/examples/learned_library/what_is_the_earliest_nonstop_flight_from_2c8dab1/meta.json diff --git a/src/webwright/skill_factory/examples/learned_library/what_is_the_earliest_nonstop_flight_from_2c8dab1/replays.json b/src/cuawright/webwright/skill_factory/examples/learned_library/what_is_the_earliest_nonstop_flight_from_2c8dab1/replays.json similarity index 100% rename from src/webwright/skill_factory/examples/learned_library/what_is_the_earliest_nonstop_flight_from_2c8dab1/replays.json rename to src/cuawright/webwright/skill_factory/examples/learned_library/what_is_the_earliest_nonstop_flight_from_2c8dab1/replays.json diff --git a/src/webwright/skill_factory/examples/learned_library/what_is_the_earliest_nonstop_flight_from_2c8dab1/skill.py b/src/cuawright/webwright/skill_factory/examples/learned_library/what_is_the_earliest_nonstop_flight_from_2c8dab1/skill.py similarity index 100% rename from src/webwright/skill_factory/examples/learned_library/what_is_the_earliest_nonstop_flight_from_2c8dab1/skill.py rename to src/cuawright/webwright/skill_factory/examples/learned_library/what_is_the_earliest_nonstop_flight_from_2c8dab1/skill.py diff --git a/src/webwright/skill_factory/examples/model_gateway.example.yaml b/src/cuawright/webwright/skill_factory/examples/model_gateway.example.yaml similarity index 100% rename from src/webwright/skill_factory/examples/model_gateway.example.yaml rename to src/cuawright/webwright/skill_factory/examples/model_gateway.example.yaml diff --git a/src/webwright/skill_factory/examples/solve_with_library.sh b/src/cuawright/webwright/skill_factory/examples/solve_with_library.sh similarity index 71% rename from src/webwright/skill_factory/examples/solve_with_library.sh rename to src/cuawright/webwright/skill_factory/examples/solve_with_library.sh index c44a6838..84216e58 100755 --- a/src/webwright/skill_factory/examples/solve_with_library.sh +++ b/src/cuawright/webwright/skill_factory/examples/solve_with_library.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Solve a task WITH the library β€” prepends the skill hint and the answer-output -# instruction, then hands off to webwright. Usage: +# instruction, then hands off to cuawright.webwright. Usage: # ./solve_with_library.sh "task text" START_URL /abs/path/to/library [webwright args...] if [ $# -lt 3 ]; then echo "usage: $0 \"task text\" START_URL /abs/path/to/library [webwright args...]" >&2 @@ -8,6 +8,6 @@ if [ $# -lt 3 ]; then fi TASK="$1"; URL="$2"; LIB="$3"; shift 3 SPEC='Additionally, write the final answer into $WORKSPACE_DIR/agent_response.json as {"retrieved_data": }.' -PROMPT=$(python -c 'import sys; from webwright.skill_factory import with_skill_hint +PROMPT=$(python -c 'import sys; from cuawright.webwright.skill_factory import with_skill_hint print(with_skill_hint(sys.argv[1] + " " + sys.argv[3], task=sys.argv[1], library=sys.argv[2]))' "$TASK" "$LIB" "$SPEC") -exec python -m webwright.run.cli main -t "$PROMPT" --start-url "$URL" "$@" +exec python -m cuawright.webwright.run.cli main -t "$PROMPT" --start-url "$URL" "$@" diff --git a/src/webwright/skill_factory/examples/tasks.example.json b/src/cuawright/webwright/skill_factory/examples/tasks.example.json similarity index 100% rename from src/webwright/skill_factory/examples/tasks.example.json rename to src/cuawright/webwright/skill_factory/examples/tasks.example.json diff --git a/src/webwright/skill_factory/examples/trajectories/README.md b/src/cuawright/webwright/skill_factory/examples/trajectories/README.md similarity index 93% rename from src/webwright/skill_factory/examples/trajectories/README.md rename to src/cuawright/webwright/skill_factory/examples/trajectories/README.md index df950d41..31c503f9 100644 --- a/src/webwright/skill_factory/examples/trajectories/README.md +++ b/src/cuawright/webwright/skill_factory/examples/trajectories/README.md @@ -6,8 +6,8 @@ minutes solving before you had anything to distil. These are three real solves o template, so you don't have to: ```bash -cd src/webwright/skill_factory/examples -python -m webwright.skill_factory learn trajectories --library ./library --verify off +cd src/cuawright/webwright/skill_factory/examples +python -m cuawright.webwright.skill_factory learn trajectories --library ./library --verify off ``` Three runs β†’ one template β†’ five lifted parameters β†’ one skill. ~100 s and an API key (the @@ -23,7 +23,7 @@ lift parameters β†’ skill), not the gate that proves it runs. drives the live site: ```bash -python -m webwright.skill_factory learn trajectories --library ./library # --verify strict +python -m cuawright.webwright.skill_factory learn trajectories --library ./library # --verify strict ``` That takes ~5-13 min, opens a browser per instance, and **may reject on the first attempt** β€” diff --git a/src/webwright/skill_factory/examples/trajectories/lax_ord/agent_response.json b/src/cuawright/webwright/skill_factory/examples/trajectories/lax_ord/agent_response.json similarity index 100% rename from src/webwright/skill_factory/examples/trajectories/lax_ord/agent_response.json rename to src/cuawright/webwright/skill_factory/examples/trajectories/lax_ord/agent_response.json diff --git a/src/webwright/skill_factory/examples/trajectories/lax_ord/final_script.py b/src/cuawright/webwright/skill_factory/examples/trajectories/lax_ord/final_script.py similarity index 100% rename from src/webwright/skill_factory/examples/trajectories/lax_ord/final_script.py rename to src/cuawright/webwright/skill_factory/examples/trajectories/lax_ord/final_script.py diff --git a/src/cuawright/webwright/skill_factory/examples/trajectories/lax_ord/task.json b/src/cuawright/webwright/skill_factory/examples/trajectories/lax_ord/task.json new file mode 100644 index 00000000..8fd6b110 --- /dev/null +++ b/src/cuawright/webwright/skill_factory/examples/trajectories/lax_ord/task.json @@ -0,0 +1,5 @@ +{ + "task": "## Skill library (reuse before solving from scratch)\nA library of previously-built executable code skills may contain one that helps this task.\nBEFORE planning from scratch, query it ONCE from bash:\n\n python -m cuawright.webwright.tools.skill_use --task 'What is the earliest nonstop flight from Los Angeles (LAX) to Chicago (ORD) on 2026-08-15 (one-way)? Return the answer as a list: [flight_number, airline, departure_time], e.g. [\"AS 336\", \"Alaska\", \"6:00 AM\"].' --library ./library\n\nIt returns JSON {verdict, skill_id, source_path, how_to_reuse}:\n- \"use\" : read source_path, copy it into your final_script, fill THIS task's params.\n- \"adapt\" : read source_path, reuse its login/navigation/extraction core, change ONLY the last step.\n- \"skip\" : no useful skill β€” solve from scratch.\nRecord your choice in skill_decision.json ({skill_id, verdict, reason}) before acting.\n\n---\nWhat is the earliest nonstop flight from Los Angeles (LAX) to Chicago (ORD) on 2026-08-15 (one-way)? Return the answer as a list: [flight_number, airline, departure_time], e.g. [\"AS 336\", \"Alaska\", \"6:00 AM\"]. Additionally, write the final answer into $WORKSPACE_DIR/agent_response.json as {\"retrieved_data\": }.", + "task_id": "tr2_lax_ord", + "start_url": "https://www.google.com/flights" +} \ No newline at end of file diff --git a/src/webwright/skill_factory/examples/trajectories/sea_jfk/agent_response.json b/src/cuawright/webwright/skill_factory/examples/trajectories/sea_jfk/agent_response.json similarity index 100% rename from src/webwright/skill_factory/examples/trajectories/sea_jfk/agent_response.json rename to src/cuawright/webwright/skill_factory/examples/trajectories/sea_jfk/agent_response.json diff --git a/src/webwright/skill_factory/examples/trajectories/sea_jfk/final_script.py b/src/cuawright/webwright/skill_factory/examples/trajectories/sea_jfk/final_script.py similarity index 100% rename from src/webwright/skill_factory/examples/trajectories/sea_jfk/final_script.py rename to src/cuawright/webwright/skill_factory/examples/trajectories/sea_jfk/final_script.py diff --git a/src/cuawright/webwright/skill_factory/examples/trajectories/sea_jfk/task.json b/src/cuawright/webwright/skill_factory/examples/trajectories/sea_jfk/task.json new file mode 100644 index 00000000..6fb0fadd --- /dev/null +++ b/src/cuawright/webwright/skill_factory/examples/trajectories/sea_jfk/task.json @@ -0,0 +1,5 @@ +{ + "task": "## Skill library (reuse before solving from scratch)\nA library of previously-built executable code skills may contain one that helps this task.\nBEFORE planning from scratch, query it ONCE from bash:\n\n python -m cuawright.webwright.tools.skill_use --task 'What is the earliest nonstop flight from Seattle (SEA) to New York (JFK) on 2026-08-15 (one-way)? Return the answer as a list: [flight_number, airline, departure_time], e.g. [\"AS 336\", \"Alaska\", \"6:00 AM\"].' --library ./library\n\nIt returns JSON {verdict, skill_id, source_path, how_to_reuse}:\n- \"use\" : read source_path, copy it into your final_script, fill THIS task's params.\n- \"adapt\" : read source_path, reuse its login/navigation/extraction core, change ONLY the last step.\n- \"skip\" : no useful skill β€” solve from scratch.\nRecord your choice in skill_decision.json ({skill_id, verdict, reason}) before acting.\n\n---\nWhat is the earliest nonstop flight from Seattle (SEA) to New York (JFK) on 2026-08-15 (one-way)? Return the answer as a list: [flight_number, airline, departure_time], e.g. [\"AS 336\", \"Alaska\", \"6:00 AM\"]. Additionally, write the final answer into $WORKSPACE_DIR/agent_response.json as {\"retrieved_data\": }.", + "task_id": "tr2_sea_jfk", + "start_url": "https://www.google.com/flights" +} \ No newline at end of file diff --git a/src/webwright/skill_factory/examples/trajectories/sfo_bos/agent_response.json b/src/cuawright/webwright/skill_factory/examples/trajectories/sfo_bos/agent_response.json similarity index 100% rename from src/webwright/skill_factory/examples/trajectories/sfo_bos/agent_response.json rename to src/cuawright/webwright/skill_factory/examples/trajectories/sfo_bos/agent_response.json diff --git a/src/webwright/skill_factory/examples/trajectories/sfo_bos/final_script.py b/src/cuawright/webwright/skill_factory/examples/trajectories/sfo_bos/final_script.py similarity index 100% rename from src/webwright/skill_factory/examples/trajectories/sfo_bos/final_script.py rename to src/cuawright/webwright/skill_factory/examples/trajectories/sfo_bos/final_script.py diff --git a/src/cuawright/webwright/skill_factory/examples/trajectories/sfo_bos/task.json b/src/cuawright/webwright/skill_factory/examples/trajectories/sfo_bos/task.json new file mode 100644 index 00000000..b65eac41 --- /dev/null +++ b/src/cuawright/webwright/skill_factory/examples/trajectories/sfo_bos/task.json @@ -0,0 +1,5 @@ +{ + "task": "## Skill library (reuse before solving from scratch)\nA library of previously-built executable code skills may contain one that helps this task.\nBEFORE planning from scratch, query it ONCE from bash:\n\n python -m cuawright.webwright.tools.skill_use --task 'What is the earliest nonstop flight from San Francisco (SFO) to Boston (BOS) on 2026-08-15 (one-way)? Return the answer as a list: [flight_number, airline, departure_time], e.g. [\"AS 336\", \"Alaska\", \"6:00 AM\"].' --library ./library\n\nIt returns JSON {verdict, skill_id, source_path, how_to_reuse}:\n- \"use\" : read source_path, copy it into your final_script, fill THIS task's params.\n- \"adapt\" : read source_path, reuse its login/navigation/extraction core, change ONLY the last step.\n- \"skip\" : no useful skill β€” solve from scratch.\nRecord your choice in skill_decision.json ({skill_id, verdict, reason}) before acting.\n\n---\nWhat is the earliest nonstop flight from San Francisco (SFO) to Boston (BOS) on 2026-08-15 (one-way)? Return the answer as a list: [flight_number, airline, departure_time], e.g. [\"AS 336\", \"Alaska\", \"6:00 AM\"]. Additionally, write the final answer into $WORKSPACE_DIR/agent_response.json as {\"retrieved_data\": }.", + "task_id": "tr2_sfo_bos", + "start_url": "https://www.google.com/flights" +} \ No newline at end of file diff --git a/src/webwright/skill_factory/execute.py b/src/cuawright/webwright/skill_factory/execute.py similarity index 100% rename from src/webwright/skill_factory/execute.py rename to src/cuawright/webwright/skill_factory/execute.py diff --git a/src/webwright/skill_factory/fill.py b/src/cuawright/webwright/skill_factory/fill.py similarity index 100% rename from src/webwright/skill_factory/fill.py rename to src/cuawright/webwright/skill_factory/fill.py diff --git a/src/webwright/skill_factory/gate.py b/src/cuawright/webwright/skill_factory/gate.py similarity index 100% rename from src/webwright/skill_factory/gate.py rename to src/cuawright/webwright/skill_factory/gate.py diff --git a/src/webwright/skill_factory/init.py b/src/cuawright/webwright/skill_factory/init.py similarity index 96% rename from src/webwright/skill_factory/init.py rename to src/cuawright/webwright/skill_factory/init.py index beed0eac..ebc87c65 100644 --- a/src/webwright/skill_factory/init.py +++ b/src/cuawright/webwright/skill_factory/init.py @@ -1,4 +1,4 @@ -"""python -m webwright.skill_factory init "" β€” draft a skill spec to review. +"""python -m cuawright.webwright.skill_factory init "" β€” draft a skill spec to review. One LLM call turns a natural-language need into a skill.yaml DRAFT: a task template with {parameter} holes, a *guessed* start_url, and a few PROPOSED instances β€” real, varied values to @@ -147,12 +147,12 @@ def init(need: str, out_path: str, rows: int = 3) -> int: f" instances: {len(instances)} proposed (review before build)\n" f" verify: {'shape (this answer drifts)' if drifts else 'strict (this answer should hold still)'}\n\n" f"{next_line}" - f" python -m webwright.skill_factory build {out} --library ./library -c your_model.yaml") + f" python -m cuawright.webwright.skill_factory build {out} --library ./library -c your_model.yaml") return 0 def main(argv=None) -> int: - p = argparse.ArgumentParser(prog="python -m webwright.skill_factory init", + p = argparse.ArgumentParser(prog="python -m cuawright.webwright.skill_factory init", description="Draft a skill.yaml skeleton from a one-line need.") p.add_argument("need", help="One-line description of the repeatable task you want a skill for.") p.add_argument("-o", "--out", default="skill.yaml", help="Where to write the spec.") diff --git a/src/webwright/skill_factory/learn.py b/src/cuawright/webwright/skill_factory/learn.py similarity index 98% rename from src/webwright/skill_factory/learn.py rename to src/cuawright/webwright/skill_factory/learn.py index 97e13280..a605b794 100644 --- a/src/webwright/skill_factory/learn.py +++ b/src/cuawright/webwright/skill_factory/learn.py @@ -1,6 +1,6 @@ """learn β€” the friendly entry: turn a folder of finished runs into library skills. - python -m webwright.skill_factory learn [--library ./library] [--golds golds.json] + python -m cuawright.webwright.skill_factory learn [--library ./library] [--golds golds.json] [--chunk 25] [--dry-run] No manifest to write. For every run dir under it reads task.json (task text, @@ -305,7 +305,7 @@ def learn(runs_dir, library_root, golds=None, chunk=25, dry_run=False, verify="s def main(argv=None) -> int: import argparse - p = argparse.ArgumentParser(prog="python -m webwright.skill_factory learn", + p = argparse.ArgumentParser(prog="python -m cuawright.webwright.skill_factory learn", description="Distill a folder of finished runs into library skills.") p.add_argument("runs_dir", help="Folder containing webwright run directories.") p.add_argument("--library", default="library") diff --git a/src/webwright/skill_factory/library.py b/src/cuawright/webwright/skill_factory/library.py similarity index 100% rename from src/webwright/skill_factory/library.py rename to src/cuawright/webwright/skill_factory/library.py diff --git a/src/webwright/skill_factory/llm.py b/src/cuawright/webwright/skill_factory/llm.py similarity index 96% rename from src/webwright/skill_factory/llm.py rename to src/cuawright/webwright/skill_factory/llm.py index bdac44e0..913d4495 100644 --- a/src/webwright/skill_factory/llm.py +++ b/src/cuawright/webwright/skill_factory/llm.py @@ -8,7 +8,7 @@ import json from typing import Any, Optional -from webwright.models import get_model +from cuawright.webwright.models import get_model # Process-wide default model, set once via configure_llm() so retrieve/decide/update can call # llm() without each caller threading a Model through. Falls back to env-configured openai. @@ -25,7 +25,7 @@ def _model() -> Any: if _DEFAULT_MODEL is not None: return _DEFAULT_MODEL # default: build an openai-style model from env so a bare CLI invocation - # (e.g. `python -m webwright.tools.skill_use`) uses the SAME backend as the running agent. + # (e.g. `python -m cuawright.webwright.tools.skill_use`) uses the SAME backend as the running agent. # Honors SKILL_MODEL_NAME / SKILL_MODEL_ENDPOINT (or OPENAI_* fallbacks); no hardcoded gateway. import os # long request timeout: refine emits a large skill (~16k tokens) which is slow on a busy diff --git a/src/webwright/skill_factory/prompt.py b/src/cuawright/webwright/skill_factory/prompt.py similarity index 97% rename from src/webwright/skill_factory/prompt.py rename to src/cuawright/webwright/skill_factory/prompt.py index a5547e77..e77a4f0c 100644 --- a/src/webwright/skill_factory/prompt.py +++ b/src/cuawright/webwright/skill_factory/prompt.py @@ -39,7 +39,7 @@ def with_skill_hint(task_prompt: str, *, task: str, library: str) -> str: """ library = str(Path(library).resolve()) try: - from webwright.tools.skill_use import recommend + from cuawright.webwright.tools.skill_use import recommend rec = recommend(task, library) except Exception: return task_prompt # lookup failed β€” solve without a hint, never block diff --git a/src/webwright/skill_factory/retrieve.py b/src/cuawright/webwright/skill_factory/retrieve.py similarity index 100% rename from src/webwright/skill_factory/retrieve.py rename to src/cuawright/webwright/skill_factory/retrieve.py diff --git a/src/webwright/skill_factory/route.py b/src/cuawright/webwright/skill_factory/route.py similarity index 96% rename from src/webwright/skill_factory/route.py rename to src/cuawright/webwright/skill_factory/route.py index ae2cdea0..81c8efab 100644 --- a/src/webwright/skill_factory/route.py +++ b/src/cuawright/webwright/skill_factory/route.py @@ -33,7 +33,7 @@ def run_webwright(prompt: str, *, start_url: str, cfg=None, outputs: str = ".", task_id: str = "task", log_path=None) -> int: """Launch one webwright agent solve on `prompt`. Shared by build (always-agent, for training trajectories) and by route's agent branch (fallback / adapt / skip). Returns the exit code.""" - cmd = [sys.executable, "-m", "webwright.run.cli", "main", "-t", prompt, + cmd = [sys.executable, "-m", "cuawright.webwright.run.cli", "main", "-t", prompt, "--start-url", start_url, "-o", str(outputs), "--task-id", task_id] for c in (cfg or []): cmd += ["-c", c] @@ -70,14 +70,14 @@ def agent_cfg(cfg: list[str]) -> list[str]: if not over: return cfg over.append(f"model.max_output_tokens={os.environ.get('SKILL_AGENT_MAX_TOKENS', '16000')}") - from webwright.run.cli import DEFAULT_CONFIGS + from cuawright.webwright.run.cli import DEFAULT_CONFIGS return list(DEFAULT_CONFIGS) + over def agent_launcher(*, start_url: str, cfg=None, outputs: str = ".", task_id: str = "task", log_path=None): """Build an agent_fn(task, hint) for route: prepend the hint, add the answer instruction, and - launch webwright. Its return value becomes route's outcome["result"] (the agent's exit code).""" + launch cuawright.webwright. Its return value becomes route's outcome["result"] (the agent's exit code).""" def agent_fn(task: str, hint: str) -> int: prompt = (hint + "\n" if hint else "") + task + " " + _ANSWER_INSTR return run_webwright(prompt, start_url=start_url, cfg=cfg, outputs=outputs, @@ -134,7 +134,7 @@ def route(task: str, library: str, *, recommend_fn=None, run_skill_fn=run_skill, `recommend_fn`/`run_skill_fn` are injectable too. """ if recommend_fn is None: - from webwright.tools.skill_use import recommend as recommend_fn + from cuawright.webwright.tools.skill_use import recommend as recommend_fn rec = recommend_fn(task, library) if on_decision is not None: on_decision(rec) # show the decision before the skill or agent runs @@ -160,7 +160,7 @@ def route(task: str, library: str, *, recommend_fn=None, run_skill_fn=run_skill, def main(argv=None) -> int: p = argparse.ArgumentParser( - prog="python -m webwright.skill_factory route", + prog="python -m cuawright.webwright.skill_factory route", description="Route a task: run a matching skill directly, or hand it to the agent.") p.add_argument("--task", required=True, help="The task, in natural language.") p.add_argument("--library", default=os.environ.get("SKILL_LIBRARY_ROOT", "library"), diff --git a/src/webwright/skill_factory/update.py b/src/cuawright/webwright/skill_factory/update.py similarity index 99% rename from src/webwright/skill_factory/update.py rename to src/cuawright/webwright/skill_factory/update.py index 17772aaf..0dd93d28 100644 --- a/src/webwright/skill_factory/update.py +++ b/src/cuawright/webwright/skill_factory/update.py @@ -422,7 +422,7 @@ def traces_from_manifest(manifest: dict) -> list["Trace"]: def main(argv=None) -> int: import argparse p = argparse.ArgumentParser( - prog="python -m webwright.skill_factory.update", + prog="python -m cuawright.webwright.skill_factory.update", description="Batch-update the skill library from a manifest of gate-judged solves.") p.add_argument("--manifest", required=True, help="JSON: {template, runs:[{dir,admit,params,...}]}") p.add_argument("--library", required=True, help="Path to the skill library directory.") diff --git a/src/cuawright/webwright/tools/__init__.py b/src/cuawright/webwright/tools/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/webwright/tools/_model_config.py b/src/cuawright/webwright/tools/_model_config.py similarity index 98% rename from src/webwright/tools/_model_config.py rename to src/cuawright/webwright/tools/_model_config.py index cb1777a2..4d2a0401 100644 --- a/src/webwright/tools/_model_config.py +++ b/src/cuawright/webwright/tools/_model_config.py @@ -14,7 +14,7 @@ import yaml -from webwright.models import get_model +from cuawright.webwright.models import get_model DEFAULT_MERGED_CONFIG_RELPATH = Path("config_snapshot") / "merged_config.yaml" diff --git a/src/webwright/tools/browser_session.py b/src/cuawright/webwright/tools/browser_session.py similarity index 98% rename from src/webwright/tools/browser_session.py rename to src/cuawright/webwright/tools/browser_session.py index b76851be..13aa8c68 100644 --- a/src/webwright/tools/browser_session.py +++ b/src/cuawright/webwright/tools/browser_session.py @@ -5,7 +5,7 @@ (e.g. the Browserbase REST `create_browserbase_session` block). Every Playwright step simply does:: - from webwright.tools.browser_session import open_browser_session + from cuawright.webwright.tools.browser_session import open_browser_session async with async_playwright() as playwright: browser = await open_browser_session(playwright) @@ -33,10 +33,10 @@ A persistent CLI lets local-workspace agents execute short Playwright fragments against one reusable browser without embedding provider boilerplate:: - python -m webwright.tools.browser_session create --workspace-dir "$WORKSPACE_DIR" - python -m webwright.tools.browser_session step --workspace-dir "$WORKSPACE_DIR" \ + python -m cuawright.webwright.tools.browser_session create --workspace-dir "$WORKSPACE_DIR" + python -m cuawright.webwright.tools.browser_session step --workspace-dir "$WORKSPACE_DIR" \ --action "Open the filters" --code-file - - python -m webwright.tools.browser_session close --workspace-dir "$WORKSPACE_DIR" + python -m cuawright.webwright.tools.browser_session close --workspace-dir "$WORKSPACE_DIR" """ from __future__ import annotations @@ -60,7 +60,7 @@ from pathlib import Path from typing import Any -from webwright.utils.browser_evidence import ( +from cuawright.webwright.utils.browser_evidence import ( DEFAULT_BROWSER_STEPS_FILE, append_jsonl, load_browser_steps, @@ -169,7 +169,7 @@ async def _create_connect_url( # backend == "local": reuse the local-chromium spawner so the same code # path is shared with the persistent local-browser tool. - from webwright.tools import persistent_local_browser as lbs + from cuawright.webwright.tools import persistent_local_browser as lbs if playwright is not None: chromium = playwright.chromium.executable_path @@ -486,7 +486,7 @@ async def _create_persistent_resource( "api_url": api_url, } - from webwright.tools.persistent_local_browser import _wait_for_devtools_url + from cuawright.webwright.tools.persistent_local_browser import _wait_for_devtools_url from playwright.async_api import async_playwright async with async_playwright() as playwright: @@ -1123,7 +1123,7 @@ def _add_persistent_paths(parser: argparse.ArgumentParser) -> None: def _build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( - prog="python -m webwright.tools.browser_session", + prog="python -m cuawright.webwright.tools.browser_session", description="Backend-agnostic browser-session helper (browserbase|local|cdp).", ) sub = parser.add_subparsers(dest="command", required=True) diff --git a/src/webwright/tools/image_qa.py b/src/cuawright/webwright/tools/image_qa.py similarity index 97% rename from src/webwright/tools/image_qa.py rename to src/cuawright/webwright/tools/image_qa.py index 6df07853..6a810d05 100644 --- a/src/webwright/tools/image_qa.py +++ b/src/cuawright/webwright/tools/image_qa.py @@ -7,8 +7,8 @@ from pathlib import Path from typing import Any -from webwright.models.base import text_part -from webwright.tools._model_config import load_tool_model +from cuawright.webwright.models.base import text_part +from cuawright.webwright.tools._model_config import load_tool_model def _build_prompt(question: str) -> str: diff --git a/src/webwright/tools/image_read.py b/src/cuawright/webwright/tools/image_read.py similarity index 100% rename from src/webwright/tools/image_read.py rename to src/cuawright/webwright/tools/image_read.py diff --git a/src/webwright/tools/persistent_local_browser.py b/src/cuawright/webwright/tools/persistent_local_browser.py similarity index 95% rename from src/webwright/tools/persistent_local_browser.py rename to src/cuawright/webwright/tools/persistent_local_browser.py index b861d1c0..178caa88 100644 --- a/src/webwright/tools/persistent_local_browser.py +++ b/src/cuawright/webwright/tools/persistent_local_browser.py @@ -19,9 +19,9 @@ the user-data-dir and the JSON file. Usage: - python -m webwright.tools.persistent_local_browser create --workspace-dir --out .lb_session.json - python -m webwright.tools.persistent_local_browser info --workspace-dir --session-file .lb_session.json - python -m webwright.tools.persistent_local_browser release --workspace-dir --session-file .lb_session.json --delete-file + python -m cuawright.webwright.tools.persistent_local_browser create --workspace-dir --out .lb_session.json + python -m cuawright.webwright.tools.persistent_local_browser info --workspace-dir --session-file .lb_session.json + python -m cuawright.webwright.tools.persistent_local_browser release --workspace-dir --session-file .lb_session.json --delete-file """ from __future__ import annotations @@ -230,7 +230,7 @@ def _cmd_release(args: argparse.Namespace) -> int: def _build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( - prog="python -m webwright.tools.persistent_local_browser", + prog="python -m cuawright.webwright.tools.persistent_local_browser", description="Manage a keep-alive local Chromium session shared across bash steps.", ) parser.add_argument( diff --git a/src/webwright/tools/self_reflection.py b/src/cuawright/webwright/tools/self_reflection.py similarity index 98% rename from src/webwright/tools/self_reflection.py rename to src/cuawright/webwright/tools/self_reflection.py index 0db14811..952e20c2 100644 --- a/src/webwright/tools/self_reflection.py +++ b/src/cuawright/webwright/tools/self_reflection.py @@ -15,7 +15,7 @@ Usage:: - python -m webwright.tools.self_reflection --config self_reflect_config.json + python -m cuawright.webwright.tools.self_reflection --config self_reflect_config.json JSON schema (paths relative to ``--workspace-dir`` or the CWD):: @@ -50,10 +50,10 @@ from pathlib import Path from typing import Any -from webwright.utils.browser_evidence import (task_screenshot_paths, load_browser_steps, +from cuawright.webwright.utils.browser_evidence import (task_screenshot_paths, load_browser_steps, format_action_history, trajectory_evidence_digest, optional_file_digest, trajectory_images) -from webwright.models.base import text_part -from webwright.tools._model_config import load_tool_model +from cuawright.webwright.models.base import text_part +from cuawright.webwright.tools._model_config import load_tool_model DEFAULT_IMAGE_PARSE_MAX_RETRIES = 3 diff --git a/src/webwright/tools/skill_use.py b/src/cuawright/webwright/tools/skill_use.py similarity index 95% rename from src/webwright/tools/skill_use.py rename to src/cuawright/webwright/tools/skill_use.py index 32575210..ab9dfb2f 100644 --- a/src/webwright/tools/skill_use.py +++ b/src/cuawright/webwright/tools/skill_use.py @@ -3,7 +3,7 @@ Resolved OUT of the agent's step loop β€” `route` and the prompt-hint injection call it before/around a solve, so the agent never spends its own steps querying the library. Also runnable as a CLI: - python -m webwright.tools.skill_use --task "Get the latest release version of facebook/react" \ + python -m cuawright.webwright.tools.skill_use --task "Get the latest release version of facebook/react" \ --library "$WORKSPACE_DIR/../library" It retrieves the most relevant skill (relevance) and judges utility, then returns a JSON @@ -20,9 +20,9 @@ import sys from pathlib import Path -from webwright.skill_factory.library import Library -from webwright.skill_factory.retrieve import retrieve -from webwright.skill_factory.decide import decide, promote +from cuawright.webwright.skill_factory.library import Library +from cuawright.webwright.skill_factory.retrieve import retrieve +from cuawright.webwright.skill_factory.decide import decide, promote def _load_replays(lib: Library, skill_id: str) -> list: @@ -113,7 +113,7 @@ def recommend(task: str, library_root: str) -> dict: def build_parser() -> argparse.ArgumentParser: p = argparse.ArgumentParser( - prog="python -m webwright.tools.skill_use", + prog="python -m cuawright.webwright.tools.skill_use", description="Query the skill library for a reusable skill for the current task.", ) p.add_argument("--task", required=True, help="The current task description / intent.") diff --git a/src/cuawright/webwright/utils/__init__.py b/src/cuawright/webwright/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/webwright/utils/browser_evidence.py b/src/cuawright/webwright/utils/browser_evidence.py similarity index 100% rename from src/webwright/utils/browser_evidence.py rename to src/cuawright/webwright/utils/browser_evidence.py diff --git a/src/webwright/utils/logging.py b/src/cuawright/webwright/utils/logging.py similarity index 100% rename from src/webwright/utils/logging.py rename to src/cuawright/webwright/utils/logging.py diff --git a/src/webwright/utils/runtime.py b/src/cuawright/webwright/utils/runtime.py similarity index 100% rename from src/webwright/utils/runtime.py rename to src/cuawright/webwright/utils/runtime.py diff --git a/src/webwright/utils/serialize.py b/src/cuawright/webwright/utils/serialize.py similarity index 100% rename from src/webwright/utils/serialize.py rename to src/cuawright/webwright/utils/serialize.py diff --git a/src/webwright/__init__.py b/src/webwright/__init__.py index 12b1da44..4b3425ca 100644 --- a/src/webwright/__init__.py +++ b/src/webwright/__init__.py @@ -1,83 +1,50 @@ -from __future__ import annotations +"""Compatibility namespace sharing CUAWright's browser module objects.""" -import os -from pathlib import Path -from typing import Any, Protocol +import importlib +import importlib.abc +import importlib.util +import sys -try: - import dotenv -except ModuleNotFoundError: - class _DotenvShim: - @staticmethod - def load_dotenv(*args, **kwargs): - return False - dotenv = _DotenvShim() -try: - from platformdirs import user_config_dir -except ModuleNotFoundError: - def user_config_dir(appname: str) -> str: - return str(Path.home() / ".config" / appname) +from cuawright.webwright import * +from cuawright import webwright as _web -__version__ = "0.1.0" +__path__ = _web.__path__ +__version__ = _web.__version__ -package_dir = Path(__file__).resolve().parent -global_config_dir = Path( - os.getenv("MSWEBA_GLOBAL_CONFIG_DIR") or user_config_dir("webwright") -) -global_config_dir.mkdir(parents=True, exist_ok=True) -global_config_file = global_config_dir / ".env" -dotenv.load_dotenv(dotenv_path=global_config_file) +class _BrowserAliasLoader(importlib.abc.Loader): + def __init__(self, target): + self.target = target + self.target_spec = importlib.util.find_spec(target) -class Model(Protocol): - config: Any + def create_module(self, spec): + return importlib.import_module(self.target) - def __call__(self, messages: list[dict[str, Any]], **kwargs) -> str: ... + def exec_module(self, module): + # Keep the canonical spec intact when import machinery installs the alias. + module.__spec__ = self.target_spec - def query(self, messages: list[dict[str, Any]], **kwargs) -> dict[str, Any]: ... + def get_filename(self, fullname): + return self.target_spec.origin - def format_message(self, **kwargs) -> dict[str, Any]: ... + def get_code(self, fullname): + # Support legacy `python -m webwright.` invocations. + return self.target_spec.loader.get_code(self.target) - def format_observation_messages( - self, - message: dict[str, Any], - outputs: list[dict[str, Any]], - template_vars: dict[str, Any] | None = None, - ) -> list[dict[str, Any]]: ... - def get_template_vars(self, **kwargs) -> dict[str, Any]: ... +class _BrowserAliasFinder(importlib.abc.MetaPathFinder): + def find_spec(self, fullname, path=None, target=None): + if not fullname.startswith("webwright."): + return None + canonical = "cuawright.webwright." + fullname[len("webwright.") :] + loader = _BrowserAliasLoader(canonical) + if loader.target_spec is None: + return None + return importlib.util.spec_from_loader( + fullname, + loader, + is_package=loader.target_spec.submodule_search_locations is not None, + ) - def serialize(self) -> dict[str, Any]: ... - -class Environment(Protocol): - config: Any - - def prepare(self, **kwargs) -> None: ... - - def execute(self, action: dict[str, Any], cwd: str = "") -> dict[str, Any]: ... - - def get_template_vars(self, **kwargs) -> dict[str, Any]: ... - - def serialize(self) -> dict[str, Any]: ... - - def close(self) -> None: ... - - -class Agent(Protocol): - config: Any - - def run(self, task: str, **kwargs) -> dict[str, Any]: ... - - def save(self, path: Path | None, *extra_dicts) -> dict[str, Any]: ... - - -__all__ = [ - "Agent", - "Environment", - "Model", - "__version__", - "global_config_dir", - "global_config_file", - "package_dir", -] +sys.meta_path.insert(0, _BrowserAliasFinder()) diff --git a/src/webwright/skill_factory/examples/trajectories/lax_ord/task.json b/src/webwright/skill_factory/examples/trajectories/lax_ord/task.json deleted file mode 100644 index 0a6b3777..00000000 --- a/src/webwright/skill_factory/examples/trajectories/lax_ord/task.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "task": "## Skill library (reuse before solving from scratch)\nA library of previously-built executable code skills may contain one that helps this task.\nBEFORE planning from scratch, query it ONCE from bash:\n\n python -m webwright.tools.skill_use --task 'What is the earliest nonstop flight from Los Angeles (LAX) to Chicago (ORD) on 2026-08-15 (one-way)? Return the answer as a list: [flight_number, airline, departure_time], e.g. [\"AS 336\", \"Alaska\", \"6:00 AM\"].' --library ./library\n\nIt returns JSON {verdict, skill_id, source_path, how_to_reuse}:\n- \"use\" : read source_path, copy it into your final_script, fill THIS task's params.\n- \"adapt\" : read source_path, reuse its login/navigation/extraction core, change ONLY the last step.\n- \"skip\" : no useful skill β€” solve from scratch.\nRecord your choice in skill_decision.json ({skill_id, verdict, reason}) before acting.\n\n---\nWhat is the earliest nonstop flight from Los Angeles (LAX) to Chicago (ORD) on 2026-08-15 (one-way)? Return the answer as a list: [flight_number, airline, departure_time], e.g. [\"AS 336\", \"Alaska\", \"6:00 AM\"]. Additionally, write the final answer into $WORKSPACE_DIR/agent_response.json as {\"retrieved_data\": }.", - "task_id": "tr2_lax_ord", - "start_url": "https://www.google.com/flights" -} \ No newline at end of file diff --git a/src/webwright/skill_factory/examples/trajectories/sea_jfk/task.json b/src/webwright/skill_factory/examples/trajectories/sea_jfk/task.json deleted file mode 100644 index 5ead4288..00000000 --- a/src/webwright/skill_factory/examples/trajectories/sea_jfk/task.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "task": "## Skill library (reuse before solving from scratch)\nA library of previously-built executable code skills may contain one that helps this task.\nBEFORE planning from scratch, query it ONCE from bash:\n\n python -m webwright.tools.skill_use --task 'What is the earliest nonstop flight from Seattle (SEA) to New York (JFK) on 2026-08-15 (one-way)? Return the answer as a list: [flight_number, airline, departure_time], e.g. [\"AS 336\", \"Alaska\", \"6:00 AM\"].' --library ./library\n\nIt returns JSON {verdict, skill_id, source_path, how_to_reuse}:\n- \"use\" : read source_path, copy it into your final_script, fill THIS task's params.\n- \"adapt\" : read source_path, reuse its login/navigation/extraction core, change ONLY the last step.\n- \"skip\" : no useful skill β€” solve from scratch.\nRecord your choice in skill_decision.json ({skill_id, verdict, reason}) before acting.\n\n---\nWhat is the earliest nonstop flight from Seattle (SEA) to New York (JFK) on 2026-08-15 (one-way)? Return the answer as a list: [flight_number, airline, departure_time], e.g. [\"AS 336\", \"Alaska\", \"6:00 AM\"]. Additionally, write the final answer into $WORKSPACE_DIR/agent_response.json as {\"retrieved_data\": }.", - "task_id": "tr2_sea_jfk", - "start_url": "https://www.google.com/flights" -} \ No newline at end of file diff --git a/src/webwright/skill_factory/examples/trajectories/sfo_bos/task.json b/src/webwright/skill_factory/examples/trajectories/sfo_bos/task.json deleted file mode 100644 index d430fc6e..00000000 --- a/src/webwright/skill_factory/examples/trajectories/sfo_bos/task.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "task": "## Skill library (reuse before solving from scratch)\nA library of previously-built executable code skills may contain one that helps this task.\nBEFORE planning from scratch, query it ONCE from bash:\n\n python -m webwright.tools.skill_use --task 'What is the earliest nonstop flight from San Francisco (SFO) to Boston (BOS) on 2026-08-15 (one-way)? Return the answer as a list: [flight_number, airline, departure_time], e.g. [\"AS 336\", \"Alaska\", \"6:00 AM\"].' --library ./library\n\nIt returns JSON {verdict, skill_id, source_path, how_to_reuse}:\n- \"use\" : read source_path, copy it into your final_script, fill THIS task's params.\n- \"adapt\" : read source_path, reuse its login/navigation/extraction core, change ONLY the last step.\n- \"skip\" : no useful skill β€” solve from scratch.\nRecord your choice in skill_decision.json ({skill_id, verdict, reason}) before acting.\n\n---\nWhat is the earliest nonstop flight from San Francisco (SFO) to Boston (BOS) on 2026-08-15 (one-way)? Return the answer as a list: [flight_number, airline, departure_time], e.g. [\"AS 336\", \"Alaska\", \"6:00 AM\"]. Additionally, write the final answer into $WORKSPACE_DIR/agent_response.json as {\"retrieved_data\": }.", - "task_id": "tr2_sfo_bos", - "start_url": "https://www.google.com/flights" -} \ No newline at end of file diff --git a/tests/skill_factory/test_build_init.py b/tests/skill_factory/test_build_init.py index cf07c241..df2bd3f0 100644 --- a/tests/skill_factory/test_build_init.py +++ b/tests/skill_factory/test_build_init.py @@ -12,9 +12,9 @@ import pytest import yaml -import webwright.skill_factory.build as B -import webwright.skill_factory.init as I -from webwright.skill_factory.route import agent_cfg # config resolution shared by build and route +import cuawright.webwright.skill_factory.build as B +import cuawright.webwright.skill_factory.init as I +from cuawright.webwright.skill_factory.route import agent_cfg # config resolution shared by build and route # ---------------------------------------------------------------- build: substitution @@ -297,7 +297,7 @@ def test_the_readme_spec_shows_every_key_init_writes(): knob they never learn they have β€” they'd think it was CLI-only. This drifted twice, silently: once when --draws was added, once when --verify-rounds was. Nothing pinned the docs to the code, so pin them.""" - readme = Path(__file__).resolve().parents[2] / "src" / "webwright" / "skill_factory" / "README.md" + readme = Path(__file__).resolve().parents[2] / "src" / "cuawright" / "webwright" / "skill_factory" / "README.md" text = readme.read_text(encoding="utf-8") start = text.index("build:", text.index("# skill.yaml")) shown = set(yaml.safe_load(text[start:text.index("```", start)])["build"]) @@ -344,7 +344,7 @@ def test_a_named_gateway_reaches_the_agent_without_being_asked(monkeypatch): def test_the_cli_defaults_come_along_because_c_replaces_them(monkeypatch): """-c is `config_spec or DEFAULT_CONFIGS` (cli.py), not an addition β€” overrides alone would drop base.yaml. And they're imported, not copied, so they can't drift.""" - from webwright.run.cli import DEFAULT_CONFIGS + from cuawright.webwright.run.cli import DEFAULT_CONFIGS monkeypatch.setenv("OPENAI_ENDPOINT", "https://gw.example/api/responses") got = agent_cfg([]) assert got[:len(DEFAULT_CONFIGS)] == list(DEFAULT_CONFIGS) diff --git a/tests/skill_factory/test_entry_shim.py b/tests/skill_factory/test_entry_shim.py index 97875f90..6221f562 100644 --- a/tests/skill_factory/test_entry_shim.py +++ b/tests/skill_factory/test_entry_shim.py @@ -7,7 +7,7 @@ import sys from pathlib import Path -from webwright.skill_factory.entry_shim import cli_shim_src, prepend_cli_shim +from cuawright.webwright.skill_factory.entry_shim import cli_shim_src, prepend_cli_shim PARAMS = ["origin_city", "origin_code", "destination_code", "date"] diff --git a/tests/skill_factory/test_evolve.py b/tests/skill_factory/test_evolve.py index 883c3b95..8c96d7c7 100644 --- a/tests/skill_factory/test_evolve.py +++ b/tests/skill_factory/test_evolve.py @@ -2,14 +2,14 @@ import sys, tempfile from pathlib import Path pass -import webwright.skill_factory.update as U -from webwright.skill_factory.library import Library, Skill +import cuawright.webwright.skill_factory.update as U +from cuawright.webwright.skill_factory.library import Library, Skill def run(): # stub _refine: deterministically "build/widen" a skill for the group's template def fake_refine(group, library, verify="off", rounds=2, on_fail="reject", draws=1): - from webwright.skill_factory.update import _slug + from cuawright.webwright.skill_factory.update import _slug sid = _slug(group[0].template) library.add(Skill(sid, f"# refined from {len(group)} solves\n", {"template": group[0].template, "provenance": "test-refine"})) @@ -132,7 +132,7 @@ def mktrace(): with tempfile.TemporaryDirectory() as d: lib = Library(d) # guard: a failed refine must NEVER overwrite an existing skill, even with on_fail=reference - from webwright.skill_factory.library import Skill as _Skill + from cuawright.webwright.skill_factory.library import Skill as _Skill sid = U._slug("verify template") lib.add(_Skill(sid, "GOOD OLD CODE", {"template": "verify template", "verified": True, "grade": "executable"})) @@ -175,7 +175,7 @@ def mktrace(): log = U.evolve([t43b], lib, verify="strict") assert log["adapt_refined"], f"general refine passing old+new must land: {log}" - # update CLI smoke: -m webwright.skill_factory.update must not NameError on Path (regression) + # update CLI smoke: -m cuawright.webwright.skill_factory.update must not NameError on Path (regression) with tempfile.TemporaryDirectory() as d: mf = Path(d) / "m.json" mf.write_text(_json.dumps({"template": "T", "runs": []})) @@ -197,8 +197,8 @@ def test_a_fresh_draw_lands_where_repairing_the_bad_one_would_not(): """--draws is not --verify-rounds: rounds repair the SAME candidate, draws throw it away. A draw that is brittle all the way through must not sink the batch when a fresh one works.""" import tempfile - import webwright.skill_factory.update as U - from webwright.skill_factory.library import Library + import cuawright.webwright.skill_factory.update as U + from cuawright.webwright.skill_factory.library import Library BAD = "import json,sys\njson.dump({'retrieved_data': ['wrong']}, open('agent_response.json','w'))\n" GOOD = "import json,sys\njson.dump({'retrieved_data': ['right']}, open('agent_response.json','w'))\n" @@ -233,8 +233,8 @@ def test_every_skill_carries_a_grade_and_the_three_states_are_distinct(): would claim we tested something we never looked at β€” and a missing field breaks the first caller that indexes it.""" import tempfile - import webwright.skill_factory.update as U - from webwright.skill_factory.library import Library + import cuawright.webwright.skill_factory.update as U + from cuawright.webwright.skill_factory.library import Library GOOD = "import json\njson.dump({'retrieved_data': ['right']}, open('agent_response.json','w'))\n" BAD = "import sys\nsys.exit(1)\n" @@ -265,14 +265,14 @@ def test_norm_folds_how_an_answer_is_written_not_what_it_says(): """Real case that cost three solves: the page prints AS26, the agent wrote the label as "AS 26", and strict then rejected every skill that read the page correctly β€” no draw could ever have passed. Spacing is not a logic error.""" - from webwright.skill_factory.update import _norm + from cuawright.webwright.skill_factory.update import _norm assert _norm(["AS26", "Alaska", "7:00 AM"]) == _norm(["AS 26", "Alaska", "7:00 AM"]) assert _norm(["AS26", "Alaska", "7:00 AM"]) == _norm(["AS26", "alaska", "7:00AM"]) def test_norm_still_fails_a_different_answer(): """The folding must not buy leniency: strict is the same *answer*, not the same bytes.""" - from webwright.skill_factory.update import _norm + from cuawright.webwright.skill_factory.update import _norm assert _norm(["AS26", "Alaska", "7:00 AM"]) != _norm(["AS27", "Alaska", "7:00 AM"]) assert _norm(["AS26", "Alaska", "7:00 AM"]) != _norm(["AS26", "Alaska", "8:00 AM"]) @@ -280,20 +280,20 @@ def test_norm_still_fails_a_different_answer(): def test_norm_still_catches_a_mangled_field(): """The draw that prompted this returned "\\b 434" for "B6 434" β€” a regex-escape bug that normalization must not launder into a pass.""" - from webwright.skill_factory.update import _norm + from cuawright.webwright.skill_factory.update import _norm assert _norm(["B6 434", "JetBlue", "6:00 AM"]) != _norm(["\b 434", "JetBlue", "6:00 AM"]) def test_norm_keeps_folding_type_jitter(): """The behaviour it had before: a solve answers in strings, a skill in numbers.""" - from webwright.skill_factory.update import _norm + from cuawright.webwright.skill_factory.update import _norm assert _norm([5]) == _norm(["5"]) # ---------------------------------------------------------------- material: lookups aren't methods def _trace(code, answer): - from webwright.skill_factory.update import Trace + from cuawright.webwright.skill_factory.update import Trace return Trace(template="t", code=code, answer=answer, meta={}) @@ -304,7 +304,7 @@ def test_a_solve_that_recognises_its_answer_is_not_material(): Its answer is right, so the input gate passes it. But there is no method in it, and distillation β€” told never to copy instance values β€” has to invent one, so every draw fails replay on that instance.""" - from webwright.skill_factory.update import _memorized_answer + from cuawright.webwright.skill_factory.update import _memorized_answer code = 'RESULT = ["UA 729", "United", "12:10 AM"]\nif "UA 729" in t: return "UA 729"' assert _memorized_answer(_trace(code, ["UA 729", "United", "12:10 AM"])) @@ -313,14 +313,14 @@ def test_a_working_solve_may_mention_its_answer_without_being_a_lookup(): """Measured on all three shipped trajectories: an airline name is a vocabulary entry, a time lands in an assertion. "The answer appears in the code" would drop 3 of 3 good solves β€” the signal is every field at once, verbatim.""" - from webwright.skill_factory.update import _memorized_answer + from cuawright.webwright.skill_factory.update import _memorized_answer code = 'KNOWN_AIRLINES = ["United", "Alaska", "JetBlue"]\nnum = extract_from_tfs(tfs)' assert not _memorized_answer(_trace(code, ["UA 729", "United", "12:10 AM"])) def test_one_field_is_never_evidence(): """A single-field answer that appears in the code proves nothing β€” "United" is a word.""" - from webwright.skill_factory.update import _memorized_answer + from cuawright.webwright.skill_factory.update import _memorized_answer assert not _memorized_answer(_trace('AIRLINES = ["United"]', ["United"])) diff --git a/tests/skill_factory/test_execute.py b/tests/skill_factory/test_execute.py index bfbe8497..08b5ab41 100644 --- a/tests/skill_factory/test_execute.py +++ b/tests/skill_factory/test_execute.py @@ -2,7 +2,7 @@ failure. A fake skill stands in for the browser-driving body so the test is fast and offline.""" from pathlib import Path -from webwright.skill_factory.execute import run_skill +from cuawright.webwright.skill_factory.execute import run_skill # Reads taskspec.json (as replay/the shim feed it) and writes agent_response.json β€” the contract # every real skill honours. Behaviour is switched by the params so one file covers every branch. diff --git a/tests/skill_factory/test_fill.py b/tests/skill_factory/test_fill.py index 263204bb..bd61e121 100644 --- a/tests/skill_factory/test_fill.py +++ b/tests/skill_factory/test_fill.py @@ -1,6 +1,6 @@ """Slot filling edges: fill_params never invents, coerces blanks to None, and degrades safely on a malformed model reply. The LLM is stubbed; these pin the plumbing around it.""" -import webwright.skill_factory.fill as F +import cuawright.webwright.skill_factory.fill as F def test_no_param_names_returns_empty_without_calling_the_model(monkeypatch): diff --git a/tests/skill_factory/test_gate.py b/tests/skill_factory/test_gate.py index cb5b0b40..340bd852 100644 --- a/tests/skill_factory/test_gate.py +++ b/tests/skill_factory/test_gate.py @@ -2,7 +2,7 @@ import sys from pathlib import Path pass -from webwright.skill_factory.gate import gate +from cuawright.webwright.skill_factory.gate import gate ARR = {"type": "array", "items": {"type": "string"}} diff --git a/tests/skill_factory/test_learn.py b/tests/skill_factory/test_learn.py index f2bb1a5b..e225c8e0 100644 --- a/tests/skill_factory/test_learn.py +++ b/tests/skill_factory/test_learn.py @@ -1,7 +1,7 @@ """Unit test: learn's LLM-free plumbing (schema inference, run collection, ledger skip).""" import json, tempfile from pathlib import Path -from webwright.skill_factory.learn import infer_schema, collect_runs +from cuawright.webwright.skill_factory.learn import infer_schema, collect_runs def run(): @@ -38,7 +38,7 @@ def run_status_gate(): gate (status read from agent_response.json), even when the answer is well-formed. All runs rejected -> learn returns before any LLM call, so this stays offline.""" import contextlib, io, json, tempfile - from webwright.skill_factory.learn import learn + from cuawright.webwright.skill_factory.learn import learn with tempfile.TemporaryDirectory() as td: run = Path(td) / "runs" / "r1_20260711_000000" run.mkdir(parents=True) @@ -60,7 +60,7 @@ def run_status_gate(): def run_regressions(): """F3: grouping-LLM failure must exit with an actionable one-liner, not a traceback.""" - import webwright.skill_factory.learn as L + import cuawright.webwright.skill_factory.learn as L orig = L.llm_json L.llm_json = lambda *a, **k: (_ for _ in ()).throw(RuntimeError("401 unauthorized")) try: @@ -80,7 +80,7 @@ def run_reject_ledger(): """A rejected skill must NOT mark its runs as learned (they get another chance).""" import contextlib, io, json, tempfile from unittest import mock - import webwright.skill_factory.learn as L + import cuawright.webwright.skill_factory.learn as L with tempfile.TemporaryDirectory() as td: run = Path(td) / "runs" / "r1_x" run.mkdir(parents=True) diff --git a/tests/skill_factory/test_learned_example.py b/tests/skill_factory/test_learned_example.py index e5d16c11..18e8f40c 100644 --- a/tests/skill_factory/test_learned_example.py +++ b/tests/skill_factory/test_learned_example.py @@ -3,7 +3,7 @@ import json from pathlib import Path -ROOT = Path(__file__).resolve().parents[2] / "src/webwright/skill_factory/examples/learned_library" +ROOT = Path(__file__).resolve().parents[2] / "src/cuawright/webwright/skill_factory/examples/learned_library" def run(): @@ -16,7 +16,7 @@ def run(): assert len(params) >= 2, f"{d.name}: parameters must be lifted, got {params}" assert "{{" in meta["template"], "template must have {{param}} placeholders" assert "Additionally, write" not in meta["template"], "pipeline text must not leak (F7)" - extras = {f.name for f in d.iterdir()} - {"skill.py", "meta.json", "replays.json"} + extras = {f.name for f in d.iterdir()} - {"skill.py", "meta.json", "replays.json", "__pycache__"} assert not extras, f"{d.name}: run artifacts must not be committed: {extras}" code = (d / "skill.py").read_text() compile(code, d.name, "exec") diff --git a/tests/skill_factory/test_library.py b/tests/skill_factory/test_library.py index bab85835..5e7cbbcf 100644 --- a/tests/skill_factory/test_library.py +++ b/tests/skill_factory/test_library.py @@ -2,7 +2,7 @@ import sys, tempfile from pathlib import Path pass -from webwright.skill_factory.library import Library, Skill +from cuawright.webwright.skill_factory.library import Library, Skill def run(): diff --git a/tests/skill_factory/test_llm_env.py b/tests/skill_factory/test_llm_env.py index 1e55fdcf..76d7374b 100644 --- a/tests/skill_factory/test_llm_env.py +++ b/tests/skill_factory/test_llm_env.py @@ -6,7 +6,7 @@ """ import pytest -import webwright.skill_factory.llm as L +import cuawright.webwright.skill_factory.llm as L class _FakeCfg: diff --git a/tests/skill_factory/test_recommend.py b/tests/skill_factory/test_recommend.py index 7531a296..919153dc 100644 --- a/tests/skill_factory/test_recommend.py +++ b/tests/skill_factory/test_recommend.py @@ -5,11 +5,11 @@ import importlib -from webwright.skill_factory.library import Library, Skill +from cuawright.webwright.skill_factory.library import Library, Skill -# the package re-exports the `decide` FUNCTION as webwright.skill_factory.decide, shadowing the +# the package re-exports the `decide` FUNCTION as cuawright.webwright.skill_factory.decide, shadowing the # submodule; import the module object explicitly so monkeypatching promote's globals works. -DEC = importlib.import_module("webwright.skill_factory.decide") +DEC = importlib.import_module("cuawright.webwright.skill_factory.decide") def _skill(grade, params=("origin_code", "destination_code", "date"), @@ -55,9 +55,9 @@ def test_promote_executable_fits_and_fills_is_run(monkeypatch): # ---- recommend: integration, grade-honest guidance ------------------------------------------ def _wire(monkeypatch, lib, decision): - import webwright.tools.skill_use as T - from webwright.skill_factory.retrieve import Candidate - from webwright.skill_factory.decide import Decision + import cuawright.webwright.tools.skill_use as T + from cuawright.webwright.skill_factory.retrieve import Candidate + from cuawright.webwright.skill_factory.decide import Decision monkeypatch.setattr(T, "retrieve", lambda task, l: [Candidate(lib.get("flt"), 0.9, "stub")]) monkeypatch.setattr(T, "decide", lambda task, cands: Decision(*decision)) return T diff --git a/tests/skill_factory/test_retrieve_decide.py b/tests/skill_factory/test_retrieve_decide.py index 1ad5b041..94e88b84 100644 --- a/tests/skill_factory/test_retrieve_decide.py +++ b/tests/skill_factory/test_retrieve_decide.py @@ -4,9 +4,9 @@ import sys, tempfile from pathlib import Path pass -from webwright.skill_factory.library import Library, Skill -from webwright.skill_factory.retrieve import retrieve, Candidate -from webwright.skill_factory.decide import decide, Decision +from cuawright.webwright.skill_factory.library import Library, Skill +from cuawright.webwright.skill_factory.retrieve import retrieve, Candidate +from cuawright.webwright.skill_factory.decide import decide, Decision def _lib(d): @@ -36,7 +36,7 @@ def run(): # skill_use.recommend: a decision pointing OUTSIDE the retrieved candidates (LLM # hallucination β€” even an id that exists in the library) must downgrade to skip - import webwright.tools.skill_use as T + import cuawright.webwright.tools.skill_use as T orig_retrieve, orig_decide = T.retrieve, T.decide try: T.retrieve = lambda task, lib: [Candidate(lib.get("bestsellers"), 0.9, "stub")] @@ -54,8 +54,8 @@ def run(): # with_skill_hint resolves the lookup OUT of the agent loop and injects the RESULT, not a # command. Mock recommend to pin the three behaviours. import os - from webwright.skill_factory.prompt import with_skill_hint - import webwright.tools.skill_use as SU + from cuawright.webwright.skill_factory.prompt import with_skill_hint + import cuawright.webwright.tools.skill_use as SU orig = SU.recommend try: # a useful skill -> its id + source + guidance are prepended, before the task @@ -79,7 +79,7 @@ def boom(task, lib): SU.recommend = orig # recommend on a MISSING or EMPTY library -> loud skip with a warning (and no mkdir side effect) - import webwright.tools.skill_use as T2 + import cuawright.webwright.tools.skill_use as T2 r = T2.recommend("anything", "/nonexistent/skill/lib/path") assert r["verdict"] == "skip" and "warning" in r and "empty" in r["warning"], r assert not os.path.exists("/nonexistent/skill/lib/path"), "must not mkdir a bogus path" @@ -89,7 +89,7 @@ def boom(task, lib): # F1: a hard failure inside recommend must surface as an ERROR (loud), not a quiet skip import io, contextlib - import webwright.tools.skill_use as TU + import cuawright.webwright.tools.skill_use as TU orig_rec = TU.recommend TU.recommend = lambda *a, **k: (_ for _ in ()).throw(RuntimeError("boom 401")) try: diff --git a/tests/skill_factory/test_route.py b/tests/skill_factory/test_route.py index f4edec74..bdbb6c17 100644 --- a/tests/skill_factory/test_route.py +++ b/tests/skill_factory/test_route.py @@ -7,7 +7,7 @@ offline and deterministically β€” no mock of the executor, no browser, no LLM.""" import textwrap -from webwright.skill_factory import route as R +from cuawright.webwright.skill_factory import route as R def _rec(**kw): diff --git a/tests/unit/test_browser_evidence.py b/tests/unit/test_browser_evidence.py index ac8c74f6..1bb5551a 100644 --- a/tests/unit/test_browser_evidence.py +++ b/tests/unit/test_browser_evidence.py @@ -2,7 +2,7 @@ from pathlib import Path -from webwright.utils.browser_evidence import ( +from cuawright.webwright.utils.browser_evidence import ( append_jsonl, format_action_history, load_browser_steps, diff --git a/tests/unit/test_browser_session_cli.py b/tests/unit/test_browser_session_cli.py index b93bc160..1661e82b 100644 --- a/tests/unit/test_browser_session_cli.py +++ b/tests/unit/test_browser_session_cli.py @@ -11,7 +11,7 @@ def _run_cli(workspace: Path, *args: str, code: str = "") -> subprocess.Complete [ sys.executable, "-m", - "webwright.tools.browser_session", + "cuawright.webwright.tools.browser_session", *args, "--workspace-dir", str(workspace), @@ -101,7 +101,7 @@ def test_persistent_cli_keeps_new_tab_across_processes_and_screenshots_are_opt_i def test_browserbase_persistent_resource_uses_keepalive_and_release(monkeypatch, tmp_path): import asyncio import httpx - from webwright.tools import browser_session as session + from cuawright.webwright.tools import browser_session as session monkeypatch.setenv('BROWSERBASE_API_KEY', 'test-key') monkeypatch.setenv('BROWSERBASE_PROJECT_ID', 'test-project') requests = [] @@ -135,7 +135,7 @@ async def post(self, url, **kwargs): def test_backend_uses_workspace_browser_mode(monkeypatch): - from webwright.tools.browser_session import _resolve_backend + from cuawright.webwright.tools.browser_session import _resolve_backend monkeypatch.delenv('WEBWRIGHT_BROWSER_BACKEND', raising=False) monkeypatch.setenv('BROWSER_MODE', 'browserbase') assert _resolve_backend(None) == 'browserbase' diff --git a/tests/unit/test_cuawright_entrypoints.py b/tests/unit/test_cuawright_entrypoints.py new file mode 100644 index 00000000..a1e56264 --- /dev/null +++ b/tests/unit/test_cuawright_entrypoints.py @@ -0,0 +1,55 @@ +"""Check public entrypoints and legacy browser commands after the package rename.""" + +import subprocess +import sys + +import pytest + + +@pytest.mark.parametrize( + "module", + ["cuawright.webwright.run.cli", "webwright.run.cli", "cuawright.desktop"], +) +def test_public_module_help(module): + result = subprocess.run( + [sys.executable, "-m", module, "--help"], + capture_output=True, + text=True, + timeout=30, + ) + assert result.returncode == 0, result.stderr + assert "Usage" in result.stdout or "usage" in result.stdout + + +def test_legacy_image_command_remains_brokered(): + from cuawright.webwright.environments.local_workspace import ( + LocalWorkspaceEnvironment, + ) + + for module in ( + "webwright.tools.image_read", + "cuawright.webwright.tools.image_read", + ): + assert ( + LocalWorkspaceEnvironment._image_read_path( + f"python -m {module} --path /tmp/image.png" + ) + == "/tmp/image.png" + ) + assert ( + LocalWorkspaceEnvironment._image_read_path( + f"python -m {module} --path /tmp/image.png | cat" + ) + is None + ) + + +def test_legacy_imports_share_classes_and_modules(): + import webwright.exceptions as legacy_exceptions + import cuawright.webwright.exceptions as exceptions + import webwright.agents.default as legacy_agent + import cuawright.webwright.agents.default as agent + + assert legacy_exceptions is exceptions + assert legacy_exceptions.FormatError is exceptions.FormatError + assert legacy_agent is agent diff --git a/tests/unit/test_doctor.py b/tests/unit/test_doctor.py index af2cdd14..c714da1f 100644 --- a/tests/unit/test_doctor.py +++ b/tests/unit/test_doctor.py @@ -1,6 +1,6 @@ from pathlib import Path -from webwright.run.doctor import ( +from cuawright.webwright.run.doctor import ( check_chromium, check_openai_key, check_playwright, diff --git a/tests/unit/test_image_read.py b/tests/unit/test_image_read.py index 051efc03..72be5885 100644 --- a/tests/unit/test_image_read.py +++ b/tests/unit/test_image_read.py @@ -4,9 +4,9 @@ import json from pathlib import Path -from webwright.environments.local_workspace import LocalWorkspaceEnvironment -from webwright.models.openai_model import OpenAIModel -from webwright.tools.image_read import MAX_IMAGE_READ_BYTES, image_read_descriptor, main +from cuawright.webwright.environments.local_workspace import LocalWorkspaceEnvironment +from cuawright.webwright.models.openai_model import OpenAIModel +from cuawright.webwright.tools.image_read import MAX_IMAGE_READ_BYTES, image_read_descriptor, main _TINY_PNG = base64.b64decode( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO0pL1sAAAAASUVORK5CYII=" @@ -79,7 +79,7 @@ def test_local_workspace_brokers_only_standalone_image_read(tmp_path: Path) -> N image = workspace / "screenshots" / "state.png" image.write_bytes(_TINY_PNG) - result = env.execute({"bash_command": f'python -m webwright.tools.image_read --path "{image}"'}) + result = env.execute({"bash_command": f'python -m cuawright.webwright.tools.image_read --path "{image}"'}) assert result["returncode"] == 0 assert len(result["observation"]["image_attachments"]) == 1 @@ -87,7 +87,7 @@ def test_local_workspace_brokers_only_standalone_image_read(tmp_path: Path) -> N assert attachment["path"] == str(image) assert attachment["media_type"] == "image/png" - combined = env.execute({"bash_command": f'python -m webwright.tools.image_read --path "{image}" | cat'}) + combined = env.execute({"bash_command": f'python -m cuawright.webwright.tools.image_read --path "{image}" | cat'}) assert combined["returncode"] == 0 assert combined["observation"]["image_attachments"] == [] diff --git a/tests/unit/test_run_command_tool.py b/tests/unit/test_run_command_tool.py index 9c2c1959..ad391982 100644 --- a/tests/unit/test_run_command_tool.py +++ b/tests/unit/test_run_command_tool.py @@ -2,7 +2,7 @@ import pytest -from webwright.models.openai_model import ( +from cuawright.webwright.models.openai_model import ( RUN_COMMAND_TOOL, TOOL_RESPONSE_MODE, OpenAIModel, @@ -232,7 +232,7 @@ def test_observation_is_untagged_without_a_call(self): def test_tool_query_execute_observe_and_complete(monkeypatch, tmp_path): - from webwright.environments.local_workspace import LocalWorkspaceEnvironment + from cuawright.webwright.environments.local_workspace import LocalWorkspaceEnvironment model = _model(observation_template='Exit={{ observation.returncode }}\n{{ observation.command_output }}') responses = iter([_payload(_reasoning(), _call('{"command":"printf hello"}')), _payload(_message('Final Response: hello'))]) @@ -258,8 +258,8 @@ async def post(payload): def test_new_config_renders_and_uses_browserbase(monkeypatch): - from webwright.config import get_config_from_spec - from webwright.utils.serialize import recursive_merge + from cuawright.webwright.config import get_config_from_spec + from cuawright.webwright.utils.serialize import recursive_merge from jinja2 import Template, StrictUndefined for name, mode in [('best_default_judge_json_persistent_cli.yaml', 'run_command_tool')]: config = recursive_merge(get_config_from_spec(name), get_config_from_spec('model_openai.yaml')) @@ -268,7 +268,7 @@ def test_new_config_renders_and_uses_browserbase(monkeypatch): assert config['environment']['browser_mode'] == 'browserbase' assert config['agent']['trajectory_reflection_config'] == 'judge_config.json' prompt = Template(config['agent']['system_template'], undefined=StrictUndefined).render(workspace_dir='/tmp/task', start_url='https://example.test') - assert 'webwright.tools.image_read' in prompt + assert 'cuawright.webwright.tools.image_read' in prompt assert '--scope trajectory' in prompt assert '/home/luyadong' not in prompt @@ -276,8 +276,8 @@ def test_new_config_renders_and_uses_browserbase(monkeypatch): @pytest.mark.parametrize("mode", [TOOL_RESPONSE_MODE, "json_schema"]) def test_base_prompt_matches_response_mode(mode): from jinja2 import StrictUndefined, Template - from webwright.config import get_config_from_spec - from webwright.utils.serialize import recursive_merge + from cuawright.webwright.config import get_config_from_spec + from cuawright.webwright.utils.serialize import recursive_merge config = recursive_merge(get_config_from_spec("base.yaml"), get_config_from_spec("model_openai.yaml")) model = OpenAIModel(openai_api_key="key", response_mode=mode) diff --git a/tests/unit/test_self_reflection_reliability.py b/tests/unit/test_self_reflection_reliability.py index abdca474..632267f5 100644 --- a/tests/unit/test_self_reflection_reliability.py +++ b/tests/unit/test_self_reflection_reliability.py @@ -5,7 +5,7 @@ import pytest -from webwright.tools import self_reflection as judge +from cuawright.webwright.tools import self_reflection as judge def run_judge(monkeypatch, tmp_path, responses): @@ -95,10 +95,10 @@ def reflect(**kwargs): def passing_gate(tmp_path): - from webwright.agents.default import DefaultAgent - from webwright.environments.local_workspace import LocalWorkspaceEnvironment - from webwright.models.openai_model import OpenAIModel - from webwright.utils.browser_evidence import optional_file_digest, trajectory_evidence_digest + from cuawright.webwright.agents.default import DefaultAgent + from cuawright.webwright.environments.local_workspace import LocalWorkspaceEnvironment + from cuawright.webwright.models.openai_model import OpenAIModel + from cuawright.webwright.utils.browser_evidence import optional_file_digest, trajectory_evidence_digest env = LocalWorkspaceEnvironment(output_dir=tmp_path) env.prepare(task='test') image = tmp_path / 'screenshots' / 'state.png' diff --git a/tests/unit/test_tool_model_routing.py b/tests/unit/test_tool_model_routing.py index cf2c1ee8..ef7c0f48 100644 --- a/tests/unit/test_tool_model_routing.py +++ b/tests/unit/test_tool_model_routing.py @@ -1,13 +1,13 @@ import pytest import yaml -from webwright.config import get_config_from_spec -from webwright.tools._model_config import ( +from cuawright.webwright.config import get_config_from_spec +from cuawright.webwright.tools._model_config import ( DEFAULT_MERGED_CONFIG_RELPATH, _extract_model_block, resolve_model_config_path, ) -from webwright.utils.serialize import recursive_merge +from cuawright.webwright.utils.serialize import recursive_merge def test_model_claude_sets_top_level_anthropic_model() -> None: From 3a9dd6ff58dd4e60179ca38dff2762545c78e660 Mon Sep 17 00:00:00 2001 From: adamlu123 Date: Sun, 4 Oct 2026 20:45:25 -0700 Subject: [PATCH 2/8] Update CUAWright project map with current runtime structure --- README.md | 56 +++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 38 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index fe885b1b..8d67a3f1 100644 --- a/README.md +++ b/README.md @@ -154,25 +154,45 @@ State-of-the-art on two real-website benchmarks with a 100-step budget β€” see t ## πŸ—ΊοΈ Project Map +```text +CUAWright/ +β”œβ”€β”€ pyproject.toml # package, dependency extras, CLI commands +β”œβ”€β”€ setup.py # desktop provenance in wheels and source archives +β”œβ”€β”€ src/ +β”‚ β”œβ”€β”€ cuawright/ +β”‚ β”‚ β”œβ”€β”€ webwright/ # browser runtime +β”‚ β”‚ β”‚ β”œβ”€β”€ agents/ # browser agent loop +β”‚ β”‚ β”‚ β”œβ”€β”€ environments/ # browser and terminal workspaces +β”‚ β”‚ β”‚ β”œβ”€β”€ models/ # OpenAI, Anthropic, OpenRouter backends +β”‚ β”‚ β”‚ β”œβ”€β”€ tools/ # browser sessions, images, self-reflection +β”‚ β”‚ β”‚ β”œβ”€β”€ config/ # stackable browser YAML configs +β”‚ β”‚ β”‚ β”œβ”€β”€ run/ # cuawright-web CLI and doctor +β”‚ β”‚ β”‚ β”œβ”€β”€ skill_factory/ # learn, verify, and reuse browser skills +β”‚ β”‚ β”‚ └── utils/ # evidence, logging, serialization +β”‚ β”‚ └── desktop/ # persistent desktop runtime +β”‚ β”‚ β”œβ”€β”€ agents/ # terminal Actor and shared call budget +β”‚ β”‚ β”œβ”€β”€ environments/osworld/ # guest commands and image/user/submit controls +β”‚ β”‚ β”œβ”€β”€ models/ # standard Responses API transport and tools +β”‚ β”‚ β”œβ”€β”€ config/prompts.py # Actor, compaction, and phase prompts +β”‚ β”‚ β”œβ”€β”€ run/cli.py # cuawright-desktop CLI +β”‚ β”‚ β”œβ”€β”€ run/benchmarks/ # official task setup and evaluation +β”‚ β”‚ └── utils/ # artifacts and provenance +β”‚ └── webwright/__init__.py # legacy Python import compatibility +β”œβ”€β”€ skills/cuawright-web/ # browser skill, commands, reference guides +β”œβ”€β”€ .claude-plugin/ # Claude Code plugin and marketplace manifests +β”œβ”€β”€ .codex-plugin/ # Codex plugin manifest +β”œβ”€β”€ docs/ # desktop setup and Skill Factory guides +β”œβ”€β”€ tests/ # browser, Skill Factory, compatibility tests +β”œβ”€β”€ release/osworld/tests/ # desktop request, lifecycle, privacy tests +β”œβ”€β”€ .github/workflows/ # runtime and Skill Factory CI +β”œβ”€β”€ assets/ # showcase, trajectory viewer, figures, logos +β”œβ”€β”€ LICENSE # MIT browser license +β”œβ”€β”€ licenses/ # Apache-2.0 desktop runtime license +└── NOTICE # imported runtime attribution ``` -cuawright/ -β”œβ”€β”€ pyproject.toml # package: cuawright -β”œβ”€β”€ src/cuawright/ -β”‚ β”œβ”€β”€ webwright/ # Webwright browser runtime -β”‚ β”‚ β”œβ”€β”€ run/cli.py # cuawright-web -β”‚ β”‚ β”œβ”€β”€ agents/, models/, environments/, config/, tools/ -β”‚ β”‚ └── skill_factory/ -β”‚ └── desktop/ # persistent desktop Actor -β”‚ β”œβ”€β”€ run/cli.py # cuawright-desktop -β”‚ β”œβ”€β”€ run/benchmarks/osworld.py -β”‚ └── agents/, models/, environments/, config/, utils/ -β”œβ”€β”€ src/webwright/ # legacy browser import compatibility -β”œβ”€β”€ skills/cuawright-web/ # browser plugin skill -β”œβ”€β”€ docs/desktop.md # external OSWorld setup and execution -β”œβ”€β”€ release/osworld/tests/ # desktop contract and failure tests -β”œβ”€β”€ tests/ # browser and integration tests -└── assets/ # showcase and trajectory viewer -``` + +The browser and desktop runtimes remain separate temporarily for backward +compatibility. They will be merged into one unified runtime soon. --- From 490f3bb422bd2f08a10263d72c4cd77e62093de4 Mon Sep 17 00:00:00 2001 From: adamlu123 Date: Sun, 4 Oct 2026 20:53:16 -0700 Subject: [PATCH 3/8] Make Skill Factory optional and document desktop workflows --- .github/workflows/runtime-tests.yml | 6 +- .github/workflows/skills-tests.yml | 14 ++- README.md | 90 ++++++++++++++++--- docs/desktop.md | 2 +- docs/skill_factory/manual.md | 2 +- docs/skill_factory/reference.md | 8 +- extensions/skill-factory/LICENSE | 21 +++++ extensions/skill-factory/README.md | 30 +++++++ extensions/skill-factory/pyproject.toml | 27 ++++++ .../webwright/skill_factory/README.md | 24 +++-- .../webwright/skill_factory/__init__.py | 2 +- .../webwright/skill_factory/__main__.py | 2 +- .../webwright/skill_factory/build.py | 0 .../webwright/skill_factory/decide.py | 0 .../webwright/skill_factory/entry_shim.py | 0 .../skill_factory/examples/README.md | 0 .../skill_factory/examples/batch.example.json | 0 .../skill_factory/examples/flights.skill.yaml | 0 .../meta.json | 0 .../replays.json | 0 .../skill.py | 0 .../examples/model_gateway.example.yaml | 0 .../examples/solve_with_library.sh | 0 .../skill_factory/examples/tasks.example.json | 0 .../examples/trajectories/README.md | 2 +- .../trajectories/lax_ord/agent_response.json | 0 .../trajectories/lax_ord/final_script.py | 0 .../examples/trajectories/lax_ord/task.json | 0 .../trajectories/sea_jfk/agent_response.json | 0 .../trajectories/sea_jfk/final_script.py | 0 .../examples/trajectories/sea_jfk/task.json | 0 .../trajectories/sfo_bos/agent_response.json | 0 .../trajectories/sfo_bos/final_script.py | 0 .../examples/trajectories/sfo_bos/task.json | 0 .../webwright/skill_factory/execute.py | 0 .../cuawright/webwright/skill_factory/fill.py | 0 .../cuawright/webwright/skill_factory/gate.py | 0 .../cuawright/webwright/skill_factory/init.py | 0 .../webwright/skill_factory/learn.py | 0 .../webwright/skill_factory/library.py | 0 .../cuawright/webwright/skill_factory/llm.py | 0 .../webwright/skill_factory/prompt.py | 0 .../webwright/skill_factory/retrieve.py | 0 .../webwright/skill_factory/route.py | 0 .../webwright/skill_factory/update.py | 0 .../cuawright/webwright/tools/skill_use.py | 0 pyproject.toml | 1 + release/osworld/tests/test_release.py | 7 ++ src/cuawright/__init__.py | 4 + src/cuawright/webwright/__init__.py | 4 + src/cuawright/webwright/tools/__init__.py | 3 + tests/conftest.py | 2 + tests/packaging/check_wheels.py | 68 ++++++++++++++ tests/skill_factory/test_build_init.py | 2 +- tests/skill_factory/test_learned_example.py | 2 +- 55 files changed, 285 insertions(+), 38 deletions(-) create mode 100644 extensions/skill-factory/LICENSE create mode 100644 extensions/skill-factory/README.md create mode 100644 extensions/skill-factory/pyproject.toml rename {src => extensions/skill-factory/src}/cuawright/webwright/skill_factory/README.md (94%) rename {src => extensions/skill-factory/src}/cuawright/webwright/skill_factory/__init__.py (98%) rename {src => extensions/skill-factory/src}/cuawright/webwright/skill_factory/__main__.py (93%) rename {src => extensions/skill-factory/src}/cuawright/webwright/skill_factory/build.py (100%) rename {src => extensions/skill-factory/src}/cuawright/webwright/skill_factory/decide.py (100%) rename {src => extensions/skill-factory/src}/cuawright/webwright/skill_factory/entry_shim.py (100%) rename {src => extensions/skill-factory/src}/cuawright/webwright/skill_factory/examples/README.md (100%) rename {src => extensions/skill-factory/src}/cuawright/webwright/skill_factory/examples/batch.example.json (100%) rename {src => extensions/skill-factory/src}/cuawright/webwright/skill_factory/examples/flights.skill.yaml (100%) rename {src => extensions/skill-factory/src}/cuawright/webwright/skill_factory/examples/learned_library/what_is_the_earliest_nonstop_flight_from_2c8dab1/meta.json (100%) rename {src => extensions/skill-factory/src}/cuawright/webwright/skill_factory/examples/learned_library/what_is_the_earliest_nonstop_flight_from_2c8dab1/replays.json (100%) rename {src => extensions/skill-factory/src}/cuawright/webwright/skill_factory/examples/learned_library/what_is_the_earliest_nonstop_flight_from_2c8dab1/skill.py (100%) rename {src => extensions/skill-factory/src}/cuawright/webwright/skill_factory/examples/model_gateway.example.yaml (100%) rename {src => extensions/skill-factory/src}/cuawright/webwright/skill_factory/examples/solve_with_library.sh (100%) rename {src => extensions/skill-factory/src}/cuawright/webwright/skill_factory/examples/tasks.example.json (100%) rename {src => extensions/skill-factory/src}/cuawright/webwright/skill_factory/examples/trajectories/README.md (97%) rename {src => extensions/skill-factory/src}/cuawright/webwright/skill_factory/examples/trajectories/lax_ord/agent_response.json (100%) rename {src => extensions/skill-factory/src}/cuawright/webwright/skill_factory/examples/trajectories/lax_ord/final_script.py (100%) rename {src => extensions/skill-factory/src}/cuawright/webwright/skill_factory/examples/trajectories/lax_ord/task.json (100%) rename {src => extensions/skill-factory/src}/cuawright/webwright/skill_factory/examples/trajectories/sea_jfk/agent_response.json (100%) rename {src => extensions/skill-factory/src}/cuawright/webwright/skill_factory/examples/trajectories/sea_jfk/final_script.py (100%) rename {src => extensions/skill-factory/src}/cuawright/webwright/skill_factory/examples/trajectories/sea_jfk/task.json (100%) rename {src => extensions/skill-factory/src}/cuawright/webwright/skill_factory/examples/trajectories/sfo_bos/agent_response.json (100%) rename {src => extensions/skill-factory/src}/cuawright/webwright/skill_factory/examples/trajectories/sfo_bos/final_script.py (100%) rename {src => extensions/skill-factory/src}/cuawright/webwright/skill_factory/examples/trajectories/sfo_bos/task.json (100%) rename {src => extensions/skill-factory/src}/cuawright/webwright/skill_factory/execute.py (100%) rename {src => extensions/skill-factory/src}/cuawright/webwright/skill_factory/fill.py (100%) rename {src => extensions/skill-factory/src}/cuawright/webwright/skill_factory/gate.py (100%) rename {src => extensions/skill-factory/src}/cuawright/webwright/skill_factory/init.py (100%) rename {src => extensions/skill-factory/src}/cuawright/webwright/skill_factory/learn.py (100%) rename {src => extensions/skill-factory/src}/cuawright/webwright/skill_factory/library.py (100%) rename {src => extensions/skill-factory/src}/cuawright/webwright/skill_factory/llm.py (100%) rename {src => extensions/skill-factory/src}/cuawright/webwright/skill_factory/prompt.py (100%) rename {src => extensions/skill-factory/src}/cuawright/webwright/skill_factory/retrieve.py (100%) rename {src => extensions/skill-factory/src}/cuawright/webwright/skill_factory/route.py (100%) rename {src => extensions/skill-factory/src}/cuawright/webwright/skill_factory/update.py (100%) rename {src => extensions/skill-factory/src}/cuawright/webwright/tools/skill_use.py (100%) create mode 100644 tests/packaging/check_wheels.py diff --git a/.github/workflows/runtime-tests.yml b/.github/workflows/runtime-tests.yml index b32a7a21..b2abeb9c 100644 --- a/.github/workflows/runtime-tests.yml +++ b/.github/workflows/runtime-tests.yml @@ -10,9 +10,11 @@ jobs: - uses: actions/setup-python@v5 with: python-version: "3.12" - - run: pip install -e ".[desktop,test,build]" + - run: pip install -e ".[desktop,skill-factory,test,build]" -e extensions/skill-factory - run: playwright install --with-deps chromium - run: pytest -q - - run: python -m compileall -q src + - run: python -m compileall -q src extensions/skill-factory/src - run: git diff --check - run: python -m build + - run: python -m build extensions/skill-factory + - run: python tests/packaging/check_wheels.py dist/cuawright-*.whl extensions/skill-factory/dist/*.whl diff --git a/.github/workflows/skills-tests.yml b/.github/workflows/skills-tests.yml index 882d38e0..2bb70a16 100644 --- a/.github/workflows/skills-tests.yml +++ b/.github/workflows/skills-tests.yml @@ -1,9 +1,9 @@ name: skills-tests on: push: - paths: ["src/cuawright/webwright/skill_factory/**", "src/cuawright/webwright/tools/skill_use.py", "tests/skill_factory/**"] + paths: ["extensions/skill-factory/**", "tests/skill_factory/**", "pyproject.toml"] pull_request: - paths: ["src/cuawright/webwright/skill_factory/**", "src/cuawright/webwright/tools/skill_use.py", "tests/skill_factory/**"] + paths: ["extensions/skill-factory/**", "tests/skill_factory/**", "pyproject.toml"] jobs: unit: runs-on: ubuntu-latest @@ -11,15 +11,11 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: { python-version: "3.12" } - - run: pip install httpx pyyaml jinja2 pydantic + - run: pip install -e ".[skill-factory,test]" -e extensions/skill-factory - name: skills unit tests (LLM-free) - run: | - set -e - for t in tests/skill_factory/test_*.py; do - echo "== $t"; PYTHONPATH=src python "$t" - done + run: python -m pytest -q tests/skill_factory - name: wrapper usage check (F6) run: | set +e - bash src/cuawright/webwright/skill_factory/examples/solve_with_library.sh > /dev/null 2>&1 + bash extensions/skill-factory/src/cuawright/webwright/skill_factory/examples/solve_with_library.sh > /dev/null 2>&1 [ $? -eq 1 ] && echo "usage-exit OK" || { echo "wrapper must exit 1 on missing args"; exit 1; } diff --git a/README.md b/README.md index 8d67a3f1..eea8984b 100644 --- a/README.md +++ b/README.md @@ -38,14 +38,16 @@ runtime soon. ```bash pip install -e . playwright install chromium -cuawright-web -c base.yaml -c model_openai.yaml -t "" --start-url "" +cuawright-web main -c base.yaml -c model_openai.yaml -t "" --start-url "" ``` For desktop support, use Python 3.12+ and `pip install -e ".[desktop]"`. -See [desktop setup and execution](docs/desktop.md) for external OSWorld provisioning. +See [Desktop Quick Start](#-desktop-quick-start) for a runnable command and +[desktop setup and execution](docs/desktop.md) for external provisioning. The Python modules are `cuawright.webwright` and `cuawright.desktop`; existing `webwright` imports and module entrypoints remain available for compatibility. Browser configuration and profile locations remain compatible with Webwright. +Skill Factory is a [separately installed optional extension](#-skill-factory-turn-solved-tasks-into-runnable-code-skills); normal browser and desktop runs do not require it. The browser sections below describe the inherited Webwright capabilities and results. @@ -54,7 +56,7 @@ Webwright gives LLM a terminal where it can launch multiple browser sessions to For incremental browsing with native OpenAI `run_command` tool calls, use: ```bash -cuawright-web -c best_default_judge_json_persistent_cli.yaml -c model_openai.yaml \ +cuawright-web main -c best_default_judge_json_persistent_cli.yaml -c model_openai.yaml \ -t "" --start-url "" --task-id example -o outputs/example ``` @@ -75,7 +77,7 @@ Already got your favorite agents, and wonder how to make Claude Code, Codex, Her - **2026-10-03** β€” **Webwright β†’ CUAWright:** renamed the project to bring browser and desktop agents together. The Webwright browser runtime is now `cuawright.webwright`, with `cuawright-web` as its command. Added `cuawright-desktop` for official OSWorld-V2 Linux tasks on Ubuntu via Docker. Existing `webwright` commands and imports remain compatible. The separate browser and desktop runtimes are a temporary compatibility arrangement; a unified runtime is planned soon. See [desktop setup](docs/desktop.md). - **2026-09-01** β€” Persistent step-by-step browsing and native `run_command` tool calls improve performance to **88.1% on Online-Mind2Web** and **77.5% on Odysseys**. -- **2026-07-21** β€” Skill Factory: every solve leaves a script behind, distilled into reusable, verified, parameterized code skills that rerun standalone with no model (~40 s, zero tokens). On WebArena, reuse lifts held-out accuracy 55% β†’ 70% (+15 pp). See [Skill Factory](#-skill-factory-turn-solved-tasks-into-runnable-code-skills). +- **2026-07-21** β€” Skill Factory: every solve leaves a script behind, distilled into reusable, verified, parameterized code skills that rerun standalone with no model (~40 s, zero tokens). On WebArena, reuse lifts held-out accuracy 55% β†’ 70% (+15 pp). See the optional [Skill Factory](#-skill-factory-turn-solved-tasks-into-runnable-code-skills). - **2026-05-11** β€” Support Task2UI mode: Webwright completes the task and renders task results into an HTML-based web app you can easily view and reuse. - **2026-05-06** β€” Codex and Claude Code plugin manifests added; install via `/plugin install cuawright@cuawright`. OpenClaw and Hermes Agent integrations shipped; the same `skills/cuawright-web/` folder now loads across Claude Code, Codex, OpenClaw, and Hermes. - **2026-05-04** β€” Initial public release: ~1.5k LoC, OpenAI / Anthropic / OpenRouter backends, Playwright environment. @@ -167,7 +169,6 @@ CUAWright/ β”‚ β”‚ β”‚ β”œβ”€β”€ tools/ # browser sessions, images, self-reflection β”‚ β”‚ β”‚ β”œβ”€β”€ config/ # stackable browser YAML configs β”‚ β”‚ β”‚ β”œβ”€β”€ run/ # cuawright-web CLI and doctor -β”‚ β”‚ β”‚ β”œβ”€β”€ skill_factory/ # learn, verify, and reuse browser skills β”‚ β”‚ β”‚ └── utils/ # evidence, logging, serialization β”‚ β”‚ └── desktop/ # persistent desktop runtime β”‚ β”‚ β”œβ”€β”€ agents/ # terminal Actor and shared call budget @@ -178,6 +179,9 @@ CUAWright/ β”‚ β”‚ β”œβ”€β”€ run/benchmarks/ # official task setup and evaluation β”‚ β”‚ └── utils/ # artifacts and provenance β”‚ └── webwright/__init__.py # legacy Python import compatibility +β”œβ”€β”€ extensions/skill-factory/ # optional cuawright-skill-factory distribution +β”‚ β”œβ”€β”€ pyproject.toml # separate wheel; depends on the base package +β”‚ └── src/cuawright/webwright/ # skill_factory/ and tools/skill_use.py β”œβ”€β”€ skills/cuawright-web/ # browser skill, commands, reference guides β”œβ”€β”€ .claude-plugin/ # Claude Code plugin and marketplace manifests β”œβ”€β”€ .codex-plugin/ # Codex plugin manifest @@ -215,7 +219,7 @@ To have Webwright produce a renderer-ready task folder at runtime, stack the Task Showcase overlay: ```bash -python -m cuawright.webwright.run.cli \ +python -m cuawright.webwright.run.cli main \ -c base.yaml -c model_openai.yaml -c task_showcase.yaml \ -t "" \ --task-id my_repeatable_task \ @@ -239,8 +243,24 @@ python assets/task_showcase/app.py \ ## 🧠 Skill Factory (turn solved tasks into runnable code skills) +**Optional browser extension.** Browser and desktop execution do not require +Skill Factory. Enable it only when you want to learn or reuse a library of browser +automation scripts. It is separate from the host-agent plugin in `skills/`. +The base `cuawright` wheel excludes Skill Factory and its examples. Install the +companion package from this checkout to enable it: + +```bash +pip install -e ".[skill-factory]" -e extensions/skill-factory +cuawright-skill-factory --help +``` + +For desktop execution and Skill Factory together, use +`pip install -e ".[desktop,skill-factory]" -e extensions/skill-factory`. +The import path remains `cuawright.webwright.skill_factory`; learning and reuse +run only when you invoke them explicitly. + **Most agent skills are context the model reads. Ours are programs.** -[`cuawright.webwright.skill_factory`](src/cuawright/webwright/skill_factory/) distills the script every solve leaves +[`cuawright.webwright.skill_factory`](extensions/skill-factory/src/cuawright/webwright/skill_factory/) distills the script every solve leaves behind into a growing library of **reusable, verified, parameterized skills** β€” code you can run without a model and compose into the next task instead of re-exploring the site. Plugs in with **no change to the agent loop**: @@ -259,11 +279,11 @@ poison the library. New solves widen a skill in place, regression-replayed so ol Once learned, a skill **runs standalone in ~40 s with zero tokens**. On WebArena (10 retrieve-type templates, 3 self-hosted sites, gpt-5.4) reuse lifts held-out accuracy **55% β†’ 70% (+15 pp)** while -cutting steps. See [`src/cuawright/webwright/skill_factory/README.md`](src/cuawright/webwright/skill_factory/README.md). +cutting steps. See [`extensions/skill-factory/src/cuawright/webwright/skill_factory/README.md`](extensions/skill-factory/src/cuawright/webwright/skill_factory/README.md). --- -## πŸš€ Quick Start +## πŸš€ Browser Quick Start ### Prerequisites @@ -286,7 +306,7 @@ with `model_openai.yaml` or `ANTHROPIC_API_KEY` with `model_claude.yaml`). The so an Anthropic run does not require an OpenAI key. Then: ```bash -python -m cuawright.webwright.run.cli \ +python -m cuawright.webwright.run.cli main \ -c base.yaml -c model_openai.yaml \ -t "Search for flights from SEA to JFK on 2026-08-15 to 2026-08-20" \ --start-url https://www.google.com/flights \ @@ -306,6 +326,56 @@ python -m cuawright.webwright.run.cli \ --- +## πŸ–₯️ Desktop Quick Start + +`cuawright-desktop` drives an Ubuntu desktop through a persistent terminal Actor. +It can inspect application state, run shell commands and automation, read screenshots, +ask the task's simulated user for missing information, and submit the saved result +for official evaluation. Desktop tasks currently run through the OSWorld-V2 adapter; +this release does not connect to an arbitrary personal desktop. + +### Requirements and installation + +- **Python 3.12+** and a Responses-compatible model API key. +- A clean external **OSWorld-V2 v2026.08.08** checkout at commit + `d578d2d4e0dc82b43e270fdaa7fa89d9708cd154`, with its dependencies installed in the same interpreter. +- Docker, a pre-provisioned Ubuntu VM, official Linux V2 tasks and assets, and any + websites or proxies required by the selected task. + +```bash +pip install -e ".[desktop]" +cuawright-desktop --help +``` + +The installer does not download VM images, tasks, or assets. Put the API key in a +user-owned file with mode `0600`, outside source, task, asset, and result directories. +Choose a new result directory whose parent already exists. + +### Run one desktop task + +```bash +cuawright-desktop \ + --source /absolute/path/to/OSWorld-V2 \ + --tasks /absolute/path/to/tasks \ + --assets /absolute/path/to/assets \ + --vm /absolute/path/to/Ubuntu.qcow2 \ + --credentials /absolute/path/to/api-key \ + --results /absolute/path/to/results/task-001 \ + --task-id 001 --model YOUR_MODEL --reasoning high +``` + +Use `--base-url` for your own Responses-compatible endpoint and `--proxy-config` +for tasks that request a proxy. The default budget is 300 model calls with context +compaction every 40 calls; multi-phase tasks share the same budget and VM state. + +Runs save `manifest.json`, `trace.jsonl`, `result.json`, and screenshot evidence. +Submitting triggers official evaluation. Setup, transport, evaluation, persistence, +and cleanup failures are explicit; credentials, evaluator payloads, and custom API +endpoints are excluded from public settings and diagnostics. +See [desktop setup and execution](docs/desktop.md) for the full contract. + +--- + ## πŸ”Œ Use as a Plugin CUAWright ships plugin manifests for both [Claude Code](https://docs.claude.com/en/docs/claude-code/plugins) ([`.claude-plugin/plugin.json`](.claude-plugin/plugin.json)) and [OpenAI Codex](https://developers.openai.com/codex/plugins) ([`.codex-plugin/plugin.json`](.codex-plugin/plugin.json)), with the shared skill at [`skills/cuawright-web/`](skills/cuawright-web/) and slash commands at [`skills/cuawright-web/commands/`](skills/cuawright-web/commands/). The host agent drives the Webwright loop natively β€” no extra LLM API key or cost beyond your host subscription. Hosts that read PNG screenshots natively skip the `image_qa` / `self_reflection` tools. diff --git a/docs/desktop.md b/docs/desktop.md index f16914f1..b53eb015 100644 --- a/docs/desktop.md +++ b/docs/desktop.md @@ -55,7 +55,7 @@ Evaluator payloads, hidden task state, and credentials are excluded from diagnos ```bash pip install -e ".[desktop,test,build]" -pytest -q +pytest -q tests/unit release/osworld/tests python -m build --wheel ``` diff --git a/docs/skill_factory/manual.md b/docs/skill_factory/manual.md index a96fac90..7f74d43c 100644 --- a/docs/skill_factory/manual.md +++ b/docs/skill_factory/manual.md @@ -1,6 +1,6 @@ # Manual mode β€” manifests, gold gates, full control -[← back to the module README](../../src/cuawright/webwright/skill_factory/README.md) +[← back to the module README](../../extensions/skill-factory/src/cuawright/webwright/skill_factory/README.md) ## When to use this mode diff --git a/docs/skill_factory/reference.md b/docs/skill_factory/reference.md index 929b7a90..1b2dd185 100644 --- a/docs/skill_factory/reference.md +++ b/docs/skill_factory/reference.md @@ -1,6 +1,6 @@ # Reference β€” verification, parameters, components -[← back to the module README](../../src/cuawright/webwright/skill_factory/README.md) +[← back to the module README](../../extensions/skill-factory/src/cuawright/webwright/skill_factory/README.md) ## Verification and grades @@ -54,9 +54,9 @@ Why code even at `reference` grade, versus a natural-language note: the selector shapes are verbatim-copyable into the agent's next script, individual primitives often still run when the whole skill doesn't, and a reference skill is one repair away from executable. And a prior alone pulls its weight: the WebArena numbers in -[Results](../../src/cuawright/webwright/skill_factory/README.md#-results) come from a library the agent read +[Results](../../extensions/skill-factory/src/cuawright/webwright/skill_factory/README.md#-results) come from a library the agent read exactly this way. The flights skill in the -[Quick Start](../../src/cuawright/webwright/skill_factory/README.md#-quick-start), by contrast, reruns an +[Quick Start](../../extensions/skill-factory/src/cuawright/webwright/skill_factory/README.md#-quick-start), by contrast, reruns an unseen route standalone, which is what `executable` buys. ## All parameters @@ -174,7 +174,7 @@ else already reads `OPENAI_*`; if you don't care, set only `OPENAI_*`. Set neither of those and you get that class's own fallbacks, `gpt-4o` at `https://api.openai.com/v1/responses`, and a line on stderr saying so. Those are inherited defaults, not suggestions: the -[Results](../../src/cuawright/webwright/skill_factory/README.md#-results) ran on a much newer model, and +[Results](../../extensions/skill-factory/src/cuawright/webwright/skill_factory/README.md#-results) ran on a much newer model, and every skill in your library is written by whichever one you leave it on. Name it. **The agent's model reads none of these vars**; nothing outside `llm.py` does. On a custom diff --git a/extensions/skill-factory/LICENSE b/extensions/skill-factory/LICENSE new file mode 100644 index 00000000..9e841e7a --- /dev/null +++ b/extensions/skill-factory/LICENSE @@ -0,0 +1,21 @@ + MIT License + + Copyright (c) Microsoft Corporation. + + 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 diff --git a/extensions/skill-factory/README.md b/extensions/skill-factory/README.md new file mode 100644 index 00000000..dc7f86cf --- /dev/null +++ b/extensions/skill-factory/README.md @@ -0,0 +1,30 @@ +# Optional CUAWright Skill Factory + +This companion distribution adds browser-skill learning, verification, routing, +and reuse. The base CUAWright package excludes this code and its examples; +browser and desktop task execution work without it. + +From the repository root: + +```bash +pip install -e ".[skill-factory]" -e extensions/skill-factory +cuawright-skill-factory --help +``` + +Modules retain their existing `cuawright.webwright.skill_factory` and +`cuawright.webwright.tools.skill_use` names. Legacy `webwright` imports are +supported when the extension is installed. Installing it does not enable +automatic learning or reuse in normal runs; invoke its commands explicitly. + +For development with both runtimes and the complete test suite: + +```bash +pip install -e ".[desktop,skill-factory,test,build]" -e extensions/skill-factory +pytest -q +python -m build +python -m build extensions/skill-factory +``` + +Build and distribute the companion wheel alongside the core wheel; published +`cuawright[skill-factory]` resolves the matching `cuawright-skill-factory` release. +See the [full guide](src/cuawright/webwright/skill_factory/README.md). diff --git a/extensions/skill-factory/pyproject.toml b/extensions/skill-factory/pyproject.toml new file mode 100644 index 00000000..0fc2cc29 --- /dev/null +++ b/extensions/skill-factory/pyproject.toml @@ -0,0 +1,27 @@ +[build-system] +requires = ["setuptools>=77", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "cuawright-skill-factory" +version = "0.2.0" +description = "Optional verified browser-skill learning and reuse for CUAWright" +requires-python = ">=3.10" +license = "MIT" +license-files = ["LICENSE"] +dependencies = ["cuawright==0.2.0"] + +[project.scripts] +cuawright-skill-factory = "cuawright.webwright.skill_factory.__main__:main" + +[tool.setuptools.packages.find] +where = ["src"] +include = ["cuawright.webwright.skill_factory*", "cuawright.webwright.tools"] +namespaces = true + +[tool.setuptools.package-data] +"cuawright.webwright.skill_factory" = [ + "README.md", "examples/**/*.md", "examples/**/*.json", + "examples/**/*.yaml", "examples/**/*.sh", "examples/*.md", + "examples/*.json", "examples/*.yaml", "examples/*.sh", +] diff --git a/src/cuawright/webwright/skill_factory/README.md b/extensions/skill-factory/src/cuawright/webwright/skill_factory/README.md similarity index 94% rename from src/cuawright/webwright/skill_factory/README.md rename to extensions/skill-factory/src/cuawright/webwright/skill_factory/README.md index fc1af500..0767ec7d 100644 --- a/src/cuawright/webwright/skill_factory/README.md +++ b/extensions/skill-factory/src/cuawright/webwright/skill_factory/README.md @@ -7,6 +7,18 @@ Every task Webwright solves leaves a working script behind. The Skill Factory tu scripts into a growing library of **reusable, verified, parameterized skills**: code you can run without a model and compose into the next task instead of re-exploring the site. +Skill Factory is an **optional companion package**. It is excluded from the base +CUAWright wheel and does not participate in normal browser or desktop runs. +From the CUAWright repository root, install it with: + +```bash +pip install -e ".[skill-factory]" -e extensions/skill-factory +cuawright-skill-factory --help +``` + +The existing `python -m cuawright.webwright.skill_factory` and legacy +`webwright.skill_factory` entrypoints remain available when the extension is installed. + ## πŸŽ₯ Demo @@ -26,7 +38,7 @@ https://github.com/user-attachments/assets/a6cb7d8e-2411-4d14-b85e-4255ccb1ae81 ## πŸ—ΊοΈ How it works -![components, the loop, and what a skill is](../../../assets/skill_factory_pipeline.png) +![components, the loop, and what a skill is](../../../../../../assets/skill_factory_pipeline.png) The system adds two integration points to WebWright without changing the agent loop: @@ -65,7 +77,7 @@ git clone https://github.com/microsoft/Webwright.git cd Webwright python3 -m venv .venv source .venv/bin/activate -pip install -e . +pip install -e ".[skill-factory]" -e extensions/skill-factory playwright install chromium ``` @@ -102,7 +114,7 @@ live Google Flights. A learned skill is a plain CLI. Run it directly β€” no model, no API key, about 40 seconds: ```bash -python src/cuawright/webwright/skill_factory/examples/learned_library/what_is_the_earliest_nonstop_flight_from_2c8dab1/skill.py \ +python extensions/skill-factory/src/cuawright/webwright/skill_factory/examples/learned_library/what_is_the_earliest_nonstop_flight_from_2c8dab1/skill.py \ --origin-city Seattle --origin-code SEA --destination-city Denver --destination-code DEN --date 2026-08-26 ``` @@ -204,7 +216,7 @@ python -m cuawright.webwright.skill_factory learn outputs/ --library ./library The repository also includes three example trajectories: ```bash -cd src/cuawright/webwright/skill_factory/examples +cd extensions/skill-factory/src/cuawright/webwright/skill_factory/examples python -m cuawright.webwright.skill_factory learn trajectories --library ./library --verify off ``` @@ -296,8 +308,8 @@ Here are some known rough edges, and directions we might take them. | doc | what's in it | |---|---| -| [docs/skill_factory/manual.md](../../../docs/skill_factory/manual.md) | manual mode: you declare the template, params, and admission yourself. Use it for benchmarks (pipe your evaluator's verdict in as the gate), logged-in sites, or cases where an LLM shouldn't be guessing your template | -| [docs/skill_factory/reference.md](../../../docs/skill_factory/reference.md) | verification & grades, every flag and env var, component map, backend | +| [docs/skill_factory/manual.md](../../../../../../docs/skill_factory/manual.md) | manual mode: you declare the template, params, and admission yourself. Use it for benchmarks (pipe your evaluator's verdict in as the gate), logged-in sites, or cases where an LLM shouldn't be guessing your template | +| [docs/skill_factory/reference.md](../../../../../../docs/skill_factory/reference.md) | verification & grades, every flag and env var, component map, backend | | [examples/README.md](examples/README.md) | the checked-in skill and the example inputs | ## πŸ“ Citation diff --git a/src/cuawright/webwright/skill_factory/__init__.py b/extensions/skill-factory/src/cuawright/webwright/skill_factory/__init__.py similarity index 98% rename from src/cuawright/webwright/skill_factory/__init__.py rename to extensions/skill-factory/src/cuawright/webwright/skill_factory/__init__.py index 7b15571c..72660605 100644 --- a/src/cuawright/webwright/skill_factory/__init__.py +++ b/extensions/skill-factory/src/cuawright/webwright/skill_factory/__init__.py @@ -2,7 +2,7 @@ Store solved tasks as reusable, executable code skills; retrieve + judge (use/adapt/skip) at solve time; admit via a gate; and grow the library incrementally (evolve). Plugs into webwright -as a built-in submodule: +as an optional extension: - solve-time reuse : the `skill_use` tool (agent invokes it like self_reflection / image_qa) - offline growth : `update.evolve` (run after solves to distill gate-passed solves into skills) diff --git a/src/cuawright/webwright/skill_factory/__main__.py b/extensions/skill-factory/src/cuawright/webwright/skill_factory/__main__.py similarity index 93% rename from src/cuawright/webwright/skill_factory/__main__.py rename to extensions/skill-factory/src/cuawright/webwright/skill_factory/__main__.py index ef1c47ab..790d8ae7 100644 --- a/src/cuawright/webwright/skill_factory/__main__.py +++ b/extensions/skill-factory/src/cuawright/webwright/skill_factory/__main__.py @@ -20,7 +20,7 @@ def main() -> int: " learn distill a folder of finished runs into skills (no manifest needed)\n" " update manual mode: distill from an explicit batch.json manifest\n" " route route a task: run a matching skill directly, or hand it to the agent") - return 1 + return 0 if len(sys.argv) == 2 and sys.argv[1] in {"-h", "--help"} else 1 if __name__ == "__main__": raise SystemExit(main()) diff --git a/src/cuawright/webwright/skill_factory/build.py b/extensions/skill-factory/src/cuawright/webwright/skill_factory/build.py similarity index 100% rename from src/cuawright/webwright/skill_factory/build.py rename to extensions/skill-factory/src/cuawright/webwright/skill_factory/build.py diff --git a/src/cuawright/webwright/skill_factory/decide.py b/extensions/skill-factory/src/cuawright/webwright/skill_factory/decide.py similarity index 100% rename from src/cuawright/webwright/skill_factory/decide.py rename to extensions/skill-factory/src/cuawright/webwright/skill_factory/decide.py diff --git a/src/cuawright/webwright/skill_factory/entry_shim.py b/extensions/skill-factory/src/cuawright/webwright/skill_factory/entry_shim.py similarity index 100% rename from src/cuawright/webwright/skill_factory/entry_shim.py rename to extensions/skill-factory/src/cuawright/webwright/skill_factory/entry_shim.py diff --git a/src/cuawright/webwright/skill_factory/examples/README.md b/extensions/skill-factory/src/cuawright/webwright/skill_factory/examples/README.md similarity index 100% rename from src/cuawright/webwright/skill_factory/examples/README.md rename to extensions/skill-factory/src/cuawright/webwright/skill_factory/examples/README.md diff --git a/src/cuawright/webwright/skill_factory/examples/batch.example.json b/extensions/skill-factory/src/cuawright/webwright/skill_factory/examples/batch.example.json similarity index 100% rename from src/cuawright/webwright/skill_factory/examples/batch.example.json rename to extensions/skill-factory/src/cuawright/webwright/skill_factory/examples/batch.example.json diff --git a/src/cuawright/webwright/skill_factory/examples/flights.skill.yaml b/extensions/skill-factory/src/cuawright/webwright/skill_factory/examples/flights.skill.yaml similarity index 100% rename from src/cuawright/webwright/skill_factory/examples/flights.skill.yaml rename to extensions/skill-factory/src/cuawright/webwright/skill_factory/examples/flights.skill.yaml diff --git a/src/cuawright/webwright/skill_factory/examples/learned_library/what_is_the_earliest_nonstop_flight_from_2c8dab1/meta.json b/extensions/skill-factory/src/cuawright/webwright/skill_factory/examples/learned_library/what_is_the_earliest_nonstop_flight_from_2c8dab1/meta.json similarity index 100% rename from src/cuawright/webwright/skill_factory/examples/learned_library/what_is_the_earliest_nonstop_flight_from_2c8dab1/meta.json rename to extensions/skill-factory/src/cuawright/webwright/skill_factory/examples/learned_library/what_is_the_earliest_nonstop_flight_from_2c8dab1/meta.json diff --git a/src/cuawright/webwright/skill_factory/examples/learned_library/what_is_the_earliest_nonstop_flight_from_2c8dab1/replays.json b/extensions/skill-factory/src/cuawright/webwright/skill_factory/examples/learned_library/what_is_the_earliest_nonstop_flight_from_2c8dab1/replays.json similarity index 100% rename from src/cuawright/webwright/skill_factory/examples/learned_library/what_is_the_earliest_nonstop_flight_from_2c8dab1/replays.json rename to extensions/skill-factory/src/cuawright/webwright/skill_factory/examples/learned_library/what_is_the_earliest_nonstop_flight_from_2c8dab1/replays.json diff --git a/src/cuawright/webwright/skill_factory/examples/learned_library/what_is_the_earliest_nonstop_flight_from_2c8dab1/skill.py b/extensions/skill-factory/src/cuawright/webwright/skill_factory/examples/learned_library/what_is_the_earliest_nonstop_flight_from_2c8dab1/skill.py similarity index 100% rename from src/cuawright/webwright/skill_factory/examples/learned_library/what_is_the_earliest_nonstop_flight_from_2c8dab1/skill.py rename to extensions/skill-factory/src/cuawright/webwright/skill_factory/examples/learned_library/what_is_the_earliest_nonstop_flight_from_2c8dab1/skill.py diff --git a/src/cuawright/webwright/skill_factory/examples/model_gateway.example.yaml b/extensions/skill-factory/src/cuawright/webwright/skill_factory/examples/model_gateway.example.yaml similarity index 100% rename from src/cuawright/webwright/skill_factory/examples/model_gateway.example.yaml rename to extensions/skill-factory/src/cuawright/webwright/skill_factory/examples/model_gateway.example.yaml diff --git a/src/cuawright/webwright/skill_factory/examples/solve_with_library.sh b/extensions/skill-factory/src/cuawright/webwright/skill_factory/examples/solve_with_library.sh similarity index 100% rename from src/cuawright/webwright/skill_factory/examples/solve_with_library.sh rename to extensions/skill-factory/src/cuawright/webwright/skill_factory/examples/solve_with_library.sh diff --git a/src/cuawright/webwright/skill_factory/examples/tasks.example.json b/extensions/skill-factory/src/cuawright/webwright/skill_factory/examples/tasks.example.json similarity index 100% rename from src/cuawright/webwright/skill_factory/examples/tasks.example.json rename to extensions/skill-factory/src/cuawright/webwright/skill_factory/examples/tasks.example.json diff --git a/src/cuawright/webwright/skill_factory/examples/trajectories/README.md b/extensions/skill-factory/src/cuawright/webwright/skill_factory/examples/trajectories/README.md similarity index 97% rename from src/cuawright/webwright/skill_factory/examples/trajectories/README.md rename to extensions/skill-factory/src/cuawright/webwright/skill_factory/examples/trajectories/README.md index 31c503f9..d64d4b91 100644 --- a/src/cuawright/webwright/skill_factory/examples/trajectories/README.md +++ b/extensions/skill-factory/src/cuawright/webwright/skill_factory/examples/trajectories/README.md @@ -6,7 +6,7 @@ minutes solving before you had anything to distil. These are three real solves o template, so you don't have to: ```bash -cd src/cuawright/webwright/skill_factory/examples +cd extensions/skill-factory/src/cuawright/webwright/skill_factory/examples python -m cuawright.webwright.skill_factory learn trajectories --library ./library --verify off ``` diff --git a/src/cuawright/webwright/skill_factory/examples/trajectories/lax_ord/agent_response.json b/extensions/skill-factory/src/cuawright/webwright/skill_factory/examples/trajectories/lax_ord/agent_response.json similarity index 100% rename from src/cuawright/webwright/skill_factory/examples/trajectories/lax_ord/agent_response.json rename to extensions/skill-factory/src/cuawright/webwright/skill_factory/examples/trajectories/lax_ord/agent_response.json diff --git a/src/cuawright/webwright/skill_factory/examples/trajectories/lax_ord/final_script.py b/extensions/skill-factory/src/cuawright/webwright/skill_factory/examples/trajectories/lax_ord/final_script.py similarity index 100% rename from src/cuawright/webwright/skill_factory/examples/trajectories/lax_ord/final_script.py rename to extensions/skill-factory/src/cuawright/webwright/skill_factory/examples/trajectories/lax_ord/final_script.py diff --git a/src/cuawright/webwright/skill_factory/examples/trajectories/lax_ord/task.json b/extensions/skill-factory/src/cuawright/webwright/skill_factory/examples/trajectories/lax_ord/task.json similarity index 100% rename from src/cuawright/webwright/skill_factory/examples/trajectories/lax_ord/task.json rename to extensions/skill-factory/src/cuawright/webwright/skill_factory/examples/trajectories/lax_ord/task.json diff --git a/src/cuawright/webwright/skill_factory/examples/trajectories/sea_jfk/agent_response.json b/extensions/skill-factory/src/cuawright/webwright/skill_factory/examples/trajectories/sea_jfk/agent_response.json similarity index 100% rename from src/cuawright/webwright/skill_factory/examples/trajectories/sea_jfk/agent_response.json rename to extensions/skill-factory/src/cuawright/webwright/skill_factory/examples/trajectories/sea_jfk/agent_response.json diff --git a/src/cuawright/webwright/skill_factory/examples/trajectories/sea_jfk/final_script.py b/extensions/skill-factory/src/cuawright/webwright/skill_factory/examples/trajectories/sea_jfk/final_script.py similarity index 100% rename from src/cuawright/webwright/skill_factory/examples/trajectories/sea_jfk/final_script.py rename to extensions/skill-factory/src/cuawright/webwright/skill_factory/examples/trajectories/sea_jfk/final_script.py diff --git a/src/cuawright/webwright/skill_factory/examples/trajectories/sea_jfk/task.json b/extensions/skill-factory/src/cuawright/webwright/skill_factory/examples/trajectories/sea_jfk/task.json similarity index 100% rename from src/cuawright/webwright/skill_factory/examples/trajectories/sea_jfk/task.json rename to extensions/skill-factory/src/cuawright/webwright/skill_factory/examples/trajectories/sea_jfk/task.json diff --git a/src/cuawright/webwright/skill_factory/examples/trajectories/sfo_bos/agent_response.json b/extensions/skill-factory/src/cuawright/webwright/skill_factory/examples/trajectories/sfo_bos/agent_response.json similarity index 100% rename from src/cuawright/webwright/skill_factory/examples/trajectories/sfo_bos/agent_response.json rename to extensions/skill-factory/src/cuawright/webwright/skill_factory/examples/trajectories/sfo_bos/agent_response.json diff --git a/src/cuawright/webwright/skill_factory/examples/trajectories/sfo_bos/final_script.py b/extensions/skill-factory/src/cuawright/webwright/skill_factory/examples/trajectories/sfo_bos/final_script.py similarity index 100% rename from src/cuawright/webwright/skill_factory/examples/trajectories/sfo_bos/final_script.py rename to extensions/skill-factory/src/cuawright/webwright/skill_factory/examples/trajectories/sfo_bos/final_script.py diff --git a/src/cuawright/webwright/skill_factory/examples/trajectories/sfo_bos/task.json b/extensions/skill-factory/src/cuawright/webwright/skill_factory/examples/trajectories/sfo_bos/task.json similarity index 100% rename from src/cuawright/webwright/skill_factory/examples/trajectories/sfo_bos/task.json rename to extensions/skill-factory/src/cuawright/webwright/skill_factory/examples/trajectories/sfo_bos/task.json diff --git a/src/cuawright/webwright/skill_factory/execute.py b/extensions/skill-factory/src/cuawright/webwright/skill_factory/execute.py similarity index 100% rename from src/cuawright/webwright/skill_factory/execute.py rename to extensions/skill-factory/src/cuawright/webwright/skill_factory/execute.py diff --git a/src/cuawright/webwright/skill_factory/fill.py b/extensions/skill-factory/src/cuawright/webwright/skill_factory/fill.py similarity index 100% rename from src/cuawright/webwright/skill_factory/fill.py rename to extensions/skill-factory/src/cuawright/webwright/skill_factory/fill.py diff --git a/src/cuawright/webwright/skill_factory/gate.py b/extensions/skill-factory/src/cuawright/webwright/skill_factory/gate.py similarity index 100% rename from src/cuawright/webwright/skill_factory/gate.py rename to extensions/skill-factory/src/cuawright/webwright/skill_factory/gate.py diff --git a/src/cuawright/webwright/skill_factory/init.py b/extensions/skill-factory/src/cuawright/webwright/skill_factory/init.py similarity index 100% rename from src/cuawright/webwright/skill_factory/init.py rename to extensions/skill-factory/src/cuawright/webwright/skill_factory/init.py diff --git a/src/cuawright/webwright/skill_factory/learn.py b/extensions/skill-factory/src/cuawright/webwright/skill_factory/learn.py similarity index 100% rename from src/cuawright/webwright/skill_factory/learn.py rename to extensions/skill-factory/src/cuawright/webwright/skill_factory/learn.py diff --git a/src/cuawright/webwright/skill_factory/library.py b/extensions/skill-factory/src/cuawright/webwright/skill_factory/library.py similarity index 100% rename from src/cuawright/webwright/skill_factory/library.py rename to extensions/skill-factory/src/cuawright/webwright/skill_factory/library.py diff --git a/src/cuawright/webwright/skill_factory/llm.py b/extensions/skill-factory/src/cuawright/webwright/skill_factory/llm.py similarity index 100% rename from src/cuawright/webwright/skill_factory/llm.py rename to extensions/skill-factory/src/cuawright/webwright/skill_factory/llm.py diff --git a/src/cuawright/webwright/skill_factory/prompt.py b/extensions/skill-factory/src/cuawright/webwright/skill_factory/prompt.py similarity index 100% rename from src/cuawright/webwright/skill_factory/prompt.py rename to extensions/skill-factory/src/cuawright/webwright/skill_factory/prompt.py diff --git a/src/cuawright/webwright/skill_factory/retrieve.py b/extensions/skill-factory/src/cuawright/webwright/skill_factory/retrieve.py similarity index 100% rename from src/cuawright/webwright/skill_factory/retrieve.py rename to extensions/skill-factory/src/cuawright/webwright/skill_factory/retrieve.py diff --git a/src/cuawright/webwright/skill_factory/route.py b/extensions/skill-factory/src/cuawright/webwright/skill_factory/route.py similarity index 100% rename from src/cuawright/webwright/skill_factory/route.py rename to extensions/skill-factory/src/cuawright/webwright/skill_factory/route.py diff --git a/src/cuawright/webwright/skill_factory/update.py b/extensions/skill-factory/src/cuawright/webwright/skill_factory/update.py similarity index 100% rename from src/cuawright/webwright/skill_factory/update.py rename to extensions/skill-factory/src/cuawright/webwright/skill_factory/update.py diff --git a/src/cuawright/webwright/tools/skill_use.py b/extensions/skill-factory/src/cuawright/webwright/tools/skill_use.py similarity index 100% rename from src/cuawright/webwright/tools/skill_use.py rename to extensions/skill-factory/src/cuawright/webwright/tools/skill_use.py diff --git a/pyproject.toml b/pyproject.toml index 1d0db803..699ede81 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,7 @@ dependencies = [ ] [project.optional-dependencies] +skill-factory = ["cuawright-skill-factory==0.2.0"] desktop = ["openai>=2.0,<3; python_version >= '3.12'"] test = ["pytest>=8", "black==25.1.0"] build = ["build>=1", "wheel"] diff --git a/release/osworld/tests/test_release.py b/release/osworld/tests/test_release.py index 188f0415..bc88be0d 100644 --- a/release/osworld/tests/test_release.py +++ b/release/osworld/tests/test_release.py @@ -1053,6 +1053,10 @@ def test_installed_wheel_origin_and_contents_when_available(): for path in (ROOT / "src").rglob("*.py") } assert set(python_files) == expected + assert not any( + "skill_factory/" in name or name.endswith("skill_use.py") + for name in python_files + ) assert all( name.startswith(("cuawright/", "webwright/")) or ".dist-info/" in name for name in wheel.namelist() @@ -1079,6 +1083,9 @@ def test_root_metadata_includes_web_and_desktop(): assert project["optional-dependencies"]["desktop"] == [ "openai>=2.0,<3; python_version >= '3.12'" ] + assert project["optional-dependencies"]["skill-factory"] == [ + "cuawright-skill-factory==0.2.0" + ] assert metadata["tool"]["pytest"]["ini_options"]["testpaths"] == [ "tests", "release/osworld/tests", diff --git a/src/cuawright/__init__.py b/src/cuawright/__init__.py index f52c1210..ee7cce4a 100644 --- a/src/cuawright/__init__.py +++ b/src/cuawright/__init__.py @@ -1,3 +1,7 @@ """CUAWright: browser and desktop agents.""" __version__ = "0.2.0" + +from pkgutil import extend_path + +__path__ = extend_path(__path__, __name__) diff --git a/src/cuawright/webwright/__init__.py b/src/cuawright/webwright/__init__.py index 689df525..6ef6ed47 100644 --- a/src/cuawright/webwright/__init__.py +++ b/src/cuawright/webwright/__init__.py @@ -1,5 +1,9 @@ from __future__ import annotations +from pkgutil import extend_path + +__path__ = extend_path(__path__, __name__) + import os from pathlib import Path from typing import Any, Protocol diff --git a/src/cuawright/webwright/tools/__init__.py b/src/cuawright/webwright/tools/__init__.py index e69de29b..b36383a6 100644 --- a/src/cuawright/webwright/tools/__init__.py +++ b/src/cuawright/webwright/tools/__init__.py @@ -0,0 +1,3 @@ +from pkgutil import extend_path + +__path__ = extend_path(__path__, __name__) diff --git a/tests/conftest.py b/tests/conftest.py index 5c8135e9..a9392ad4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,3 +4,5 @@ from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "extensions/skill-factory/src")) diff --git a/tests/packaging/check_wheels.py b/tests/packaging/check_wheels.py new file mode 100644 index 00000000..06142fe3 --- /dev/null +++ b/tests/packaging/check_wheels.py @@ -0,0 +1,68 @@ +"""Verify optional Skill Factory installation using the two built wheels.""" + +import argparse +import subprocess +import sys +import tempfile +import venv +from pathlib import Path + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("core", type=Path) + parser.add_argument("extension", type=Path) + options = parser.parse_args() + core = options.core.resolve() + extension = options.extension.resolve() + with tempfile.TemporaryDirectory(prefix="cuawright-packaging-") as scratch: + env = Path(scratch) / "venv" + venv.EnvBuilder(with_pip=True).create(env) + python = env / ( + "Scripts/python.exe" if sys.platform == "win32" else "bin/python" + ) + + def run(*args): + subprocess.run([str(python), *args], check=True, cwd=scratch) + + run("-m", "pip", "install", f"{core}[desktop]") + run( + "-I", + "-c", + "import importlib.util; " + "from cuawright.webwright.run.cli import app; " + "from cuawright.desktop.run.cli import parser; " + "assert importlib.util.find_spec('cuawright.webwright.skill_factory') is None; " + "assert importlib.util.find_spec('cuawright.webwright.tools.skill_use') is None", + ) + run("-I", "-m", "cuawright.webwright.run.cli", "--help") + run("-I", "-m", "cuawright.desktop", "--help") + run("-m", "pip", "install", str(extension)) + run( + "-I", + "-c", + "import cuawright.webwright.skill_factory as current; " + "import webwright.skill_factory as legacy; " + "from cuawright.webwright.tools.skill_use import recommend; " + "from cuawright.webwright.skill_factory.library import Library; " + "assert current is legacy; " + "assert Library('.') is not None", + ) + run("-I", "-m", "cuawright.webwright.skill_factory", "--help") + run("-m", "pip", "uninstall", "-y", "cuawright-skill-factory") + run( + "-I", + "-c", + "import importlib.util; " + "assert importlib.util.find_spec('cuawright.webwright.skill_factory') is None; " + "assert importlib.util.find_spec('cuawright.webwright.tools.skill_use') is None", + ) + run("-I", "-m", "cuawright.webwright.run.cli", "--help") + run("-I", "-m", "cuawright.desktop", "--help") + print( + "Base install, optional extension, legacy imports, and uninstall verified." + ) + + +if __name__ == "__main__": + main() diff --git a/tests/skill_factory/test_build_init.py b/tests/skill_factory/test_build_init.py index df2bd3f0..06e7abad 100644 --- a/tests/skill_factory/test_build_init.py +++ b/tests/skill_factory/test_build_init.py @@ -297,7 +297,7 @@ def test_the_readme_spec_shows_every_key_init_writes(): knob they never learn they have β€” they'd think it was CLI-only. This drifted twice, silently: once when --draws was added, once when --verify-rounds was. Nothing pinned the docs to the code, so pin them.""" - readme = Path(__file__).resolve().parents[2] / "src" / "cuawright" / "webwright" / "skill_factory" / "README.md" + readme = Path(__file__).resolve().parents[2] / "extensions" / "skill-factory" / "src" / "cuawright" / "webwright" / "skill_factory" / "README.md" text = readme.read_text(encoding="utf-8") start = text.index("build:", text.index("# skill.yaml")) shown = set(yaml.safe_load(text[start:text.index("```", start)])["build"]) diff --git a/tests/skill_factory/test_learned_example.py b/tests/skill_factory/test_learned_example.py index 18e8f40c..926a993d 100644 --- a/tests/skill_factory/test_learned_example.py +++ b/tests/skill_factory/test_learned_example.py @@ -3,7 +3,7 @@ import json from pathlib import Path -ROOT = Path(__file__).resolve().parents[2] / "src/cuawright/webwright/skill_factory/examples/learned_library" +ROOT = Path(__file__).resolve().parents[2] / "extensions/skill-factory/src/cuawright/webwright/skill_factory/examples/learned_library" def run(): From 5006d0cee40335bdc553ba8e9e8d7d4515586854 Mon Sep 17 00:00:00 2001 From: GitHub Copilot <223556219+Copilot@users.noreply.github.com> Date: Mon, 5 Oct 2026 09:05:12 -0700 Subject: [PATCH 4/8] Add clean CUAWright web and OSWorld workflows Preserve separate browser and desktop contracts, add pinned OSWorld setup and verification, support custom and official desktop runs, and redact transport identity from public artifacts. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3dd553c9-070f-4969-ad26-3dffb49d4178 --- AGENTS.md | 35 +++ README.md | 76 +++-- REPRODUCE.md | 120 ++++++++ SUPPORT.md | 28 +- docs/desktop.md | 91 +++--- pyproject.toml | 3 +- release/osworld/tests/test_release.py | 69 ++++- src/cuawright/__main__.py | 3 + src/cuawright/cli.py | 49 +++ src/cuawright/core/__init__.py | 1 + src/cuawright/core/responses.py | 27 ++ .../desktop/environments/osworld/source.py | 17 +- .../desktop/models/openai_response_model.py | 5 +- src/cuawright/desktop/osworld_release.json | 38 +++ .../desktop/run/benchmarks/osworld.py | 208 ++++++++++--- src/cuawright/desktop/run/cli.py | 127 ++++++-- src/cuawright/desktop/setup.py | 282 ++++++++++++++++++ src/cuawright/webwright/config/__init__.py | 48 ++- .../webwright/config/local_browser.yaml | 4 +- .../webwright/config/model_openai.yaml | 2 +- src/cuawright/webwright/models/base.py | 144 +++++++-- .../webwright/models/openai_model.py | 77 ++++- .../webwright/tools/self_reflection.py | 1 - tests/unit/test_cuawright_entrypoints.py | 26 ++ tests/unit/test_responses_and_setup.py | 139 +++++++++ .../unit/test_self_reflection_reliability.py | 1 + 26 files changed, 1395 insertions(+), 226 deletions(-) create mode 100644 AGENTS.md create mode 100644 REPRODUCE.md create mode 100644 src/cuawright/__main__.py create mode 100644 src/cuawright/cli.py create mode 100644 src/cuawright/core/__init__.py create mode 100644 src/cuawright/core/responses.py create mode 100644 src/cuawright/desktop/osworld_release.json create mode 100644 src/cuawright/desktop/setup.py create mode 100644 tests/unit/test_responses_and_setup.py diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..dc9d4d57 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,35 @@ +# Working in CUAWright + +Use Python 3.12 for desktop work. Install with: + +```bash +pip install -e ".[desktop,test,build]" +playwright install chromium +``` + +Keep the browser and desktop runtimes separate: + +- `src/cuawright/webwright/` owns browser messages, tools, compaction, and completion. +- `src/cuawright/desktop/` owns the OSWorld VM, guest bridge, explicit submit, and evaluation. +- `src/cuawright/core/` is only for small contracts that are genuinely identical. + +Do not introduce a universal agent/model/environment abstraction or duplicate the +browser runtime under another package. Preserve `webwright` compatibility aliases. +Public code and artifacts must contain only standard Responses-compatible API +configuration; never persist credentials or endpoint identities. + +Before changing desktop prompts, read `src/cuawright/desktop/config/prompts.py` in +full. Plain final text never completes a desktop task; the Actor must execute the +standalone submit control. Official reproduction must retain the pinned release, +task setup, evaluator, provenance, and score. + +Validate focused changes first, then run: + +```bash +pytest -q tests/unit release/osworld/tests +python -m build +python tests/packaging/check_wheels.py +``` + +Keep net changes within 900 production Python lines and 600 test lines. No newly +added production file should exceed 300 lines. diff --git a/README.md b/README.md index eea8984b..c3d76b65 100644 --- a/README.md +++ b/README.md @@ -3,10 +3,10 @@ > **Webwright is now CUAWright.** The project has expanded from browser automation > to browser and desktop agents. Webwright remains the browser subsystem, now at > `cuawright.webwright`; OSWorld 2.0 desktop support lives at `cuawright.desktop`. -> Use `cuawright-web` for browser tasks and `cuawright-desktop` for desktop tasks. +> Use `cuawright web` for browser tasks and `cuawright desktop` for desktop tasks. > Existing `webwright` commands and Python imports remain supported. -> The separate browser and desktop runtimes are temporary to preserve backward -> compatibility; we plan to merge them into one unified runtime soon. +> Browser and desktop keep their distinct message, tool, completion, and +> environment contracts while sharing only small identical utilities.

CUAWright logo @@ -25,14 +25,17 @@ CUAWright combines **Webwright browser automation** and a **persistent desktop Actor** for official OSWorld-V2 tasks. -The browser and desktop runtimes are currently separate to preserve backward -compatibility during the transition. We plan to merge them into one unified -runtime soon. +The browser and desktop runtimes intentionally remain separate because browser +script completion and OSWorld explicit submission/evaluation are different contracts. | Command | Runtime | | --- | --- | +| `cuawright web` | Browser tasks with Playwright and the Webwright harness | +| `cuawright desktop run` | Arbitrary task in the pinned OSWorld Ubuntu VM, without a score | +| `cuawright desktop reproduce osworld` | Official pinned OSWorld task and evaluator | +| `cuawright setup osworld` | Download or register the pinned OSWorld release | | `cuawright-web` | Browser tasks with Playwright and the Webwright harness | -| `cuawright-desktop` | One official OSWorld-V2 Linux task on Ubuntu via Docker | +| `cuawright-desktop` | Compatibility desktop entrypoint | | `webwright` | Compatibility alias for `cuawright-web` | ```bash @@ -75,7 +78,7 @@ Already got your favorite agents, and wonder how to make Claude Code, Codex, Her ## πŸ“° News -- **2026-10-03** β€” **Webwright β†’ CUAWright:** renamed the project to bring browser and desktop agents together. The Webwright browser runtime is now `cuawright.webwright`, with `cuawright-web` as its command. Added `cuawright-desktop` for official OSWorld-V2 Linux tasks on Ubuntu via Docker. Existing `webwright` commands and imports remain compatible. The separate browser and desktop runtimes are a temporary compatibility arrangement; a unified runtime is planned soon. See [desktop setup](docs/desktop.md). +- **2026-10-03** β€” **Webwright β†’ CUAWright:** renamed the project to bring browser and desktop agents together. The Webwright browser runtime is now `cuawright.webwright`, and OSWorld desktop support lives at `cuawright.desktop`. Existing `webwright` commands and imports remain compatible. See [desktop setup](docs/desktop.md). - **2026-09-01** β€” Persistent step-by-step browsing and native `run_command` tool calls improve performance to **88.1% on Online-Mind2Web** and **77.5% on Odysseys**. - **2026-07-21** β€” Skill Factory: every solve leaves a script behind, distilled into reusable, verified, parameterized code skills that rerun standalone with no model (~40 s, zero tokens). On WebArena, reuse lifts held-out accuracy 55% β†’ 70% (+15 pp). See the optional [Skill Factory](#-skill-factory-turn-solved-tasks-into-runnable-code-skills). - **2026-05-11** β€” Support Task2UI mode: Webwright completes the task and renders task results into an HTML-based web app you can easily view and reuse. @@ -162,6 +165,7 @@ CUAWright/ β”œβ”€β”€ setup.py # desktop provenance in wheels and source archives β”œβ”€β”€ src/ β”‚ β”œβ”€β”€ cuawright/ +β”‚ β”‚ β”œβ”€β”€ core/ # small identical cross-runtime contracts β”‚ β”‚ β”œβ”€β”€ webwright/ # browser runtime β”‚ β”‚ β”‚ β”œβ”€β”€ agents/ # browser agent loop β”‚ β”‚ β”‚ β”œβ”€β”€ environments/ # browser and terminal workspaces @@ -175,7 +179,8 @@ CUAWright/ β”‚ β”‚ β”œβ”€β”€ environments/osworld/ # guest commands and image/user/submit controls β”‚ β”‚ β”œβ”€β”€ models/ # standard Responses API transport and tools β”‚ β”‚ β”œβ”€β”€ config/prompts.py # Actor, compaction, and phase prompts -β”‚ β”‚ β”œβ”€β”€ run/cli.py # cuawright-desktop CLI +β”‚ β”‚ β”œβ”€β”€ setup.py # pinned OSWorld setup and verification +β”‚ β”‚ β”œβ”€β”€ run/cli.py # custom and official desktop workflows β”‚ β”‚ β”œβ”€β”€ run/benchmarks/ # official task setup and evaluation β”‚ β”‚ └── utils/ # artifacts and provenance β”‚ └── webwright/__init__.py # legacy Python import compatibility @@ -195,8 +200,7 @@ CUAWright/ └── NOTICE # imported runtime attribution ``` -The browser and desktop runtimes remain separate temporarily for backward -compatibility. They will be merged into one unified runtime soon. +The browser and desktop runtimes share only the canonical Responses URL contract. --- @@ -328,51 +332,61 @@ python -m cuawright.webwright.run.cli main \ ## πŸ–₯️ Desktop Quick Start -`cuawright-desktop` drives an Ubuntu desktop through a persistent terminal Actor. +`cuawright desktop` drives an Ubuntu desktop through a persistent terminal Actor. It can inspect application state, run shell commands and automation, read screenshots, -ask the task's simulated user for missing information, and submit the saved result -for official evaluation. Desktop tasks currently run through the OSWorld-V2 adapter; -this release does not connect to an arbitrary personal desktop. +ask an official task's simulated user for missing information, and explicitly submit +the saved result. It runs inside the pinned OSWorld-V2 Ubuntu VM; there is no +arbitrary personal-desktop mode. ### Requirements and installation - **Python 3.12+** and a Responses-compatible model API key. -- A clean external **OSWorld-V2 v2026.08.08** checkout at commit - `d578d2d4e0dc82b43e270fdaa7fa89d9708cd154`, with its dependencies installed in the same interpreter. -- Docker, a pre-provisioned Ubuntu VM, official Linux V2 tasks and assets, and any - websites or proxies required by the selected task. +- Docker and access to the gated official task and asset snapshots. ```bash pip install -e ".[desktop]" -cuawright-desktop --help +cuawright setup osworld --root ~/.cache/cuawright/osworld-v2-2026.08.08 +cuawright verify osworld \ + --setup ~/.cache/cuawright/osworld-v2-2026.08.08/setup.json ``` -The installer does not download VM images, tasks, or assets. Put the API key in a +Setup downloads the pinned source, task classes, assets, and VM by default; existing +resources can be registered without copying. Put the API key in a user-owned file with mode `0600`, outside source, task, asset, and result directories. Choose a new result directory whose parent already exists. -### Run one desktop task +### Run an arbitrary desktop task ```bash -cuawright-desktop \ - --source /absolute/path/to/OSWorld-V2 \ - --tasks /absolute/path/to/tasks \ - --assets /absolute/path/to/assets \ - --vm /absolute/path/to/Ubuntu.qcow2 \ +cuawright desktop run \ + --setup /absolute/path/to/setup.json \ + --credentials /absolute/path/to/api-key \ + --results /absolute/path/to/results/custom \ + --model YOUR_MODEL \ + --instruction "" +``` + +This workflow has no evaluator or score. For an official scored task: + +```bash +cuawright desktop reproduce osworld \ + --setup /absolute/path/to/setup.json \ --credentials /absolute/path/to/api-key \ --results /absolute/path/to/results/task-001 \ --task-id 001 --model YOUR_MODEL --reasoning high ``` -Use `--base-url` for your own Responses-compatible endpoint and `--proxy-config` -for tasks that request a proxy. The default budget is 300 model calls with context +Use `--responses-url` for a standard Responses-compatible full URL and +`--proxy-config` for official tasks that request a proxy. The default budget is 300 model calls with context compaction every 40 calls; multi-phase tasks share the same budget and VM state. Runs save `manifest.json`, `trace.jsonl`, `result.json`, and screenshot evidence. -Submitting triggers official evaluation. Setup, transport, evaluation, persistence, +Submitting triggers official evaluation only in `reproduce`; custom runs stop after +verified submission. Setup, transport, evaluation, persistence, and cleanup failures are explicit; credentials, evaluator payloads, and custom API endpoints are excluded from public settings and diagnostics. -See [desktop setup and execution](docs/desktop.md) for the full contract. +See [REPRODUCE.md](REPRODUCE.md) and +[desktop setup and execution](docs/desktop.md) for the full contract. --- diff --git a/REPRODUCE.md b/REPRODUCE.md new file mode 100644 index 00000000..056f59fb --- /dev/null +++ b/REPRODUCE.md @@ -0,0 +1,120 @@ +# Reproduce OSWorld-V2 + +CUAWright supports two Ubuntu VM workflows: + +1. `desktop run`: an arbitrary task in the pinned OSWorld VM. Submission saves the + verified outcome; no official evaluator runs and no score is produced. +2. `desktop reproduce osworld`: an official task with the pinned task class, assets, + setup, evaluator, and benchmark score. + +There is no host-desktop mode. + +## Install and prepare + +Use Python 3.12+, Docker, and preferably KVM: + +```bash +pip install -e ".[desktop]" +``` + +Accept access to both gated datasets, then authenticate: + +- +- + +```bash +uvx --from huggingface_hub hf auth login +``` + +The default setup clones the pinned OSWorld source, installs its dependencies, +downloads the exact gated task/assets snapshots, verifies the task hash manifest, +and downloads the official 0624 VM artifact reused by the 0808 release: + +```bash +cuawright setup osworld --root ~/.cache/cuawright/osworld-v2-2026.08.08 +cuawright verify osworld \ + --setup ~/.cache/cuawright/osworld-v2-2026.08.08/setup.json +``` + +The VM archive is about 14.2 GB. To register already provisioned resources without +copying them, pass all existing paths: + +```bash +cuawright setup osworld \ + --root /path/to/cuawright-osworld \ + --source /path/to/clean/OSWorld-V2 \ + --tasks /path/to/task-classes \ + --assets /path/to/assets \ + --task-manifest /path/to/task_hashes.json \ + --vm /path/to/Ubuntu.qcow2 \ + --skip-install +``` + +`setup.json` records the selected VM hash. A repaired or expanded local VM is valid +when registered explicitly, but it is not claimed to be byte-identical to the +compressed release artifact. + +Put one Responses API key in a user-owned mode-`0600` file. Keep it outside the +source, tasks, assets, and result directories. + +## Smoke and custom task + +Run a paid end-to-end VM smoke: + +```bash +cuawright smoke osworld \ + --setup /path/to/setup.json \ + --credentials /path/to/api-key \ + --results /new/path/results/smoke \ + --model YOUR_MODEL \ + --responses-url https://api.example/v1/responses +``` + +Run any other desktop task: + +```bash +cuawright desktop run \ + --setup /path/to/setup.json \ + --credentials /path/to/api-key \ + --results /new/path/results/custom \ + --model YOUR_MODEL \ + --responses-url https://api.example/v1/responses \ + --instruction "Create, save, verify, and submit the requested artifact." +``` + +## Official scored reproduction + +```bash +cuawright desktop reproduce osworld \ + --setup /path/to/setup.json \ + --credentials /path/to/api-key \ + --results /new/path/results/task-001 \ + --task-id 001 \ + --model YOUR_MODEL \ + --responses-url https://api.example/v1/responses \ + --reasoning high +``` + +The result directory must not exist before launch. `manifest.json` pins harness, +source, task, assets, VM, prompts, tools, and settings. `trace.jsonl` records model +requests, responses, commands, retries, and cleanup. `result.json` records explicit +failure stages or the completed outcome; official reproduction additionally records +the evaluator score. + +## Browser sample + +Browser execution remains an independent Webwright workflow: + +```bash +cuawright web main \ + -c base.yaml -c model_openai.yaml -c local_browser.yaml \ + -c environment.browser_mode=local_launch \ + -c environment.headless=true \ + -c model.responses_url=https://api.example/v1/responses \ + -c model.model_name=YOUR_MODEL \ + -t "Open the page and report its title." \ + --start-url https://example.com \ + --task-id sample +``` + +The existing `cuawright-web` and `webwright` commands remain compatible. diff --git a/SUPPORT.md b/SUPPORT.md index eaf439ae..a445ffed 100644 --- a/SUPPORT.md +++ b/SUPPORT.md @@ -1,25 +1,15 @@ -# TODO: The maintainer of this repo has not yet edited this file - -**REPO OWNER**: Do you want Customer Service & Support (CSS) support for this product/project? - -- **No CSS support:** Fill out this template with information about how to file issues and get help. -- **Yes CSS support:** Fill out an intake form at [aka.ms/onboardsupport](https://aka.ms/onboardsupport). CSS will work with/help you to determine next steps. -- **Not sure?** Fill out an intake as though the answer were "Yes". CSS will help you decide. - -*Then remove this first heading from this SUPPORT.MD file before publishing your repo.* - # Support -## How to file issues and get help - -This project uses GitHub Issues to track bugs and feature requests. Please search the existing -issues before filing new issues to avoid duplicates. For new issues, file your bug or -feature request as a new Issue. +Use [GitHub Issues](https://github.com/microsoft/Webwright/issues) for reproducible +bugs and feature requests. Search existing issues first. Include the CUAWright +version, operating system, command shape with secrets removed, and the smallest +reproduction you can share. -For help and questions about using this project, please **REPO MAINTAINER: INSERT INSTRUCTIONS HERE -FOR HOW TO ENGAGE REPO OWNERS OR COMMUNITY FOR HELP. COULD BE A STACK OVERFLOW TAG OR OTHER -CHANNEL. WHERE WILL YOU HELP PEOPLE?**. +Do not post API keys, endpoint URLs, proxy configuration, gated task contents, +evaluator details, or private run artifacts. Security vulnerabilities should be +reported through the process in [SECURITY.md](SECURITY.md), not a public issue. ## Microsoft Support Policy -Support for this **PROJECT or PRODUCT** is limited to the resources listed above. +This project is provided as open-source software without Microsoft Customer +Service and Support coverage. Support is limited to the community resources above. diff --git a/docs/desktop.md b/docs/desktop.md index b53eb015..1b68910c 100644 --- a/docs/desktop.md +++ b/docs/desktop.md @@ -1,69 +1,54 @@ # CUAWright desktop -`cuawright-desktop` runs one official OSWorld-V2 Linux task using a persistent -terminal Actor in an Ubuntu Docker VM. It preserves the imported release's -prompts, standard Responses API requests, submission controls, shared call budget, -compaction, proxy handling, evaluation, and multi-phase lifecycle. +Desktop execution uses the pinned OSWorld-V2 Ubuntu Docker VM and a persistent +terminal Actor. Browser execution remains under `cuawright.webwright`; the two +runtimes do not share their loops, messages, compaction, or completion semantics. -## Install +## Workflows -Use Python 3.12 or newer: +`cuawright desktop run` accepts an arbitrary instruction. It provisions the same +VM, guest bridge, screenshot controls, Actor prompt, call budget, compaction, and +standalone submit control as benchmark execution. It does not load a task class, +run an evaluator, or emit a score. -```bash -pip install -e ".[desktop]" -cuawright-desktop --help -``` - -Provision OSWorld externally. Supported source: **OSWorld-V2 v2026.08.08**, commit -`d578d2d4e0dc82b43e270fdaa7fa89d9708cd154`. The source checkout must be clean. -The interpreter running CUAWright must also have the official OSWorld dependencies -installed. Docker, the VM, Linux V2 task classes, task assets, websites, and any -required proxy must already be provisioned; execution does not download or repair them. - -The API credential file must contain one key, belong to the invoking user, and -have mode `0600`. Keep it outside the source, task, asset, and result directories. -Use a new result directory whose parent already exists. +`cuawright desktop reproduce osworld` loads an official gated task class, applies +its exact setup, preserves proxy and multi-phase behavior, runs its evaluator after +submission, and emits a score in `[0, 1]`. -## Run +Plain model text never completes either workflow. The Actor must execute: ```bash -cuawright-desktop \ - --source /absolute/path/to/OSWorld-V2 \ - --tasks /absolute/path/to/tasks \ - --assets /absolute/path/to/assets \ - --vm /absolute/path/to/Ubuntu.qcow2 \ - --credentials /absolute/path/to/api-key \ - --results /absolute/path/to/results/task-001 \ - --task-id 001 --model YOUR_MODEL --reasoning high +python /opt/cuawright-tools/submit.py ``` -For task-requested proxies, supply `--proxy-config /absolute/path/to/proxy.json`. -`--base-url` selects a user-supplied Responses-compatible endpoint. -Only standard Responses API fields are sent. The default budget is 300 model calls; compaction -occurs every 40 calls. One invocation owns one task; batch scheduling stays external. +## Setup -Commands execute inside the guest VM. Screenshot, simulated-user, and submission -controls remain under `/opt/cuawright-tools/`. Submission triggers official -evaluation; a plain final answer does not substitute for explicit submission. -Task 069's phases share one model-call budget. +See [REPRODUCE.md](../REPRODUCE.md) for pinned setup, existing-resource +registration, VM smoke, browser sample, and official reproduction commands. +`cuawright verify osworld` checks the clean source commit, all 108 task classes +against the official hash manifest, nonempty assets, and the registered VM hash. -## Artifacts and validation +The setup lock pins: -Each run writes `manifest.json`, `trace.jsonl`, and `result.json`, plus screenshot -artifacts. Setup, transport, evaluation, persistence, and cleanup failures are explicit. -Evaluator payloads, hidden task state, and credentials are excluded from diagnostics. +- OSWorld-V2 `v2026.08.08` at + `d578d2d4e0dc82b43e270fdaa7fa89d9708cd154` +- tasks and assets at `v2026.08.08` +- website release `v2026.08.08` +- Docker VM artifact `v2026.06.24`, reused by the 0808 release -```bash -pip install -e ".[desktop,test,build]" -pytest -q tests/unit release/osworld/tests -python -m build --wheel -``` +The task and asset repositories are gated. Setup fails with upstream access +instructions if Hugging Face authorization has not been accepted. + +## API and artifacts + +`--responses-url` is always the full POST URL ending in `/responses`. CUAWright +derives the OpenAI SDK API root by removing exactly that final segment, so browser, +desktop Actor, evaluator, and user simulator target the same gateway without +double-appending `/responses`. -Wheels include desktop runtime provenance, generated by `setup.py` without editing -the source tree. The combined package retains the MIT browser license and the -Apache-2.0 imported desktop license; see `NOTICE` and `licenses/`. +Credentials must be a user-owned mode-`0600` file. Credentials, proxy paths, and +endpoint identities are excluded from public settings and browser snapshots. -The supported external OSWorld source is available at -. API keys and custom endpoint URLs must -be supplied locally; credentials, proxy configuration paths, and endpoint URLs -are excluded from saved public settings. +Every run writes `manifest.json`, `trace.jsonl`, and `result.json`. Failures record +an explicit stage without exception text or hidden evaluator state. Official runs +also record task/assets/VM provenance and the evaluator score. diff --git a/pyproject.toml b/pyproject.toml index 699ede81..f77e83cf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,7 @@ test = ["pytest>=8", "black==25.1.0"] build = ["build>=1", "wheel"] [project.scripts] +cuawright = "cuawright.cli:main" cuawright-web = "cuawright.webwright.run.cli:app" cuawright-desktop = "cuawright.desktop.run.cli:main" webwright = "cuawright.webwright.run.cli:app" @@ -37,7 +38,7 @@ where = ["src"] [tool.setuptools.package-data] "cuawright.webwright" = ["config/*.yaml", "config/**/*.yaml"] -"cuawright.desktop" = ["build_provenance.json"] +"cuawright.desktop" = ["build_provenance.json", "osworld_release.json"] [tool.pytest.ini_options] testpaths = ["tests", "release/osworld/tests"] diff --git a/release/osworld/tests/test_release.py b/release/osworld/tests/test_release.py index bc88be0d..2bfe9f6c 100644 --- a/release/osworld/tests/test_release.py +++ b/release/osworld/tests/test_release.py @@ -785,6 +785,9 @@ def test_task_selection_and_config_are_host_only(workspace, official): prepare_task(options) task, instruction, provenance = source.load_task(options.tasks, "001") assert task is official + assert provenance["class_sha256"] == storage.file_hash( + options.tasks / "task_001.py" + ) assert instruction == official["instruction"] assert set(provenance) == {"id", "class_sha256", "source_tree", "config_sha256"} assert "HIDDEN-EVALUATOR-ANSWER" not in json.dumps(provenance) @@ -817,6 +820,7 @@ def test_unsupported_task_surfaces_fail_before_vm( official_type = type(official) official_type.evaluate = official_type.__bases__[0].evaluate else: + (options.tasks / "task_001.py").unlink() monkeypatch.setattr( sys.modules["task_loader"], "find_task_class_path", lambda *_: None ) @@ -909,9 +913,13 @@ def test_credentials_permissions_symlinks_and_safe_settings(workspace): with pytest.raises(OSError): storage.credentials(link) with pytest.raises(exceptions.ReleaseError, match="credentials"): - replace(options, base_url="https://secret@example.com/v1").validate() + replace( + options, responses_url="https://secret@example.com/v1/responses" + ).validate() with pytest.raises(exceptions.ReleaseError, match="credentials"): - replace(options, base_url="https://example.com/v1?key=secret").validate() + replace( + options, responses_url="https://example.com/v1/responses?key=secret" + ).validate() options.results.mkdir() with pytest.raises(exceptions.ReleaseError, match="already exist"): options.validate() @@ -1001,7 +1009,10 @@ def test_runtime_closure_line_threshold_and_no_legacy_imports(): ) elif isinstance(node, ast.ImportFrom): if node.level: - assert node.level <= len(path.relative_to(package).parts) + assert node.level <= len(path.relative_to(package).parts) or ( + node.level == len(path.relative_to(package).parts) + 1 + and node.module == "core.responses" + ) else: assert not (node.module or "").startswith("cuawright.") print("Runtime physical lines:", sum(counts.values()), counts) @@ -1011,7 +1022,7 @@ def test_help_does_not_import_osworld_or_openai(): command = ( "import sys; from cuawright.desktop.run.cli import main; " "assert 'desktop_env' not in sys.modules; assert 'openai' not in sys.modules; " - "main(['--help'])" + "main(['reproduce', '--help'])" ) result = subprocess.run( [ @@ -1076,6 +1087,7 @@ def test_root_metadata_includes_web_and_desktop(): project = metadata["project"] assert project["name"] == "cuawright" assert project["scripts"] == { + "cuawright": "cuawright.cli:main", "cuawright-web": "cuawright.webwright.run.cli:app", "cuawright-desktop": "cuawright.desktop.run.cli:main", "webwright": "cuawright.webwright.run.cli:app", @@ -1095,12 +1107,53 @@ def test_root_metadata_includes_web_and_desktop(): def test_custom_endpoint_and_credentials_are_not_public_settings(workspace): - options = settings(workspace, base_url="https://private-provider.invalid/v1") + options = settings( + workspace, + responses_url="https://gateway.example/v1/responses", + ) public = options.public() - assert "base_url" not in public + assert "responses_url" not in public assert "credentials" not in public assert "proxy_config" not in public - assert "private-provider.invalid" not in json.dumps(public) + assert "gateway.example" not in json.dumps(public) + + +def test_custom_task_uses_same_actor_vm_and_submit_contract(workspace, official): + baseline = settings(workspace, steps=2, compact_every=0) + options = runner.CustomSettings( + source=baseline.source, + vm=baseline.vm, + credentials=baseline.credentials, + results=baseline.results, + model=baseline.model, + instruction="Create and verify a text file.", + steps=2, + compact_every=0, + ) + client = FakeClient([[function("python /opt/cuawright-tools/submit.py")]]) + outcome = runner.run_custom( + options, client_factory=lambda *_: client, guest_factory=FakeGuest + ) + assert outcome == { + "stop_reason": "submitted", + "model_calls": 1, + "commands": 1, + "compactions": 0, + } + environment = FakeEnv.instances[-1] + assert environment.closed and client.closed + assert environment.task == { + "id": "custom", + "instruction": options.instruction, + "platform": "linux", + "config": [], + "disable_recording": True, + } + result = json.loads((options.results / "result.json").read_text()) + manifest = json.loads((options.results / "manifest.json").read_text()) + assert "score" not in result + assert manifest["condition"] == "osworld-custom-task-v1" + assert manifest["settings"]["instruction"] == options.instruction def test_desktop_source_contains_only_public_or_loopback_urls(): @@ -1108,7 +1161,7 @@ def test_desktop_source_contains_only_public_or_loopback_urls(): import re from urllib.parse import urlsplit - allowed_hosts = {"api.openai.com", "github.com", "127.0.0.1"} + allowed_hosts = {"api.openai.com", "github.com", "huggingface.co", "127.0.0.1"} for path in (ROOT / "src/cuawright/desktop").rglob("*.py"): text = path.read_text() for url in re.findall(r"https?://[^\s\"'<>`]+", text): diff --git a/src/cuawright/__main__.py b/src/cuawright/__main__.py new file mode 100644 index 00000000..eb53e2f3 --- /dev/null +++ b/src/cuawright/__main__.py @@ -0,0 +1,3 @@ +from .cli import main + +raise SystemExit(main()) diff --git a/src/cuawright/cli.py b/src/cuawright/cli.py new file mode 100644 index 00000000..6d3c79e0 --- /dev/null +++ b/src/cuawright/cli.py @@ -0,0 +1,49 @@ +import argparse +import sys + + +SMOKE_TASK = ( + "Create /home/user/Desktop/cuawright-smoke.txt containing exactly " + "'CUAWright OSWorld smoke passed', read it back to verify the content, " + "then submit." +) + + +def parser(): + result = argparse.ArgumentParser( + prog="cuawright", description="CUAWright browser and desktop agents." + ) + result.add_argument( + "command", choices=("web", "desktop", "setup", "verify", "smoke") + ) + result.add_argument("target", nargs="?") + return result + + +def main(argv=None): + argv = list(sys.argv[1:] if argv is None else argv) + if not argv or argv[0] in ("-h", "--help"): + parser().print_help() + return 0 + command = argv.pop(0) + if command == "web": + from .webwright.run.cli import app + + return app(args=argv, prog_name="cuawright web") + if command == "desktop": + from .desktop.run.cli import main as desktop_main + + return desktop_main(argv) + if command in ("setup", "verify"): + if not argv or argv.pop(0) != "osworld": + raise SystemExit(f"usage: cuawright {command} osworld ...") + from .desktop.setup import main as setup_main + + return setup_main([command, *argv]) + if command == "smoke": + if not argv or argv.pop(0) != "osworld": + raise SystemExit("usage: cuawright smoke osworld ...") + from .desktop.run.cli import main as desktop_main + + return desktop_main(["run", "--instruction", SMOKE_TASK, *argv]) + parser().error(f"unknown command: {command}") diff --git a/src/cuawright/core/__init__.py b/src/cuawright/core/__init__.py new file mode 100644 index 00000000..92ada301 --- /dev/null +++ b/src/cuawright/core/__init__.py @@ -0,0 +1 @@ +"""Small contracts shared across CUAWright runtimes.""" diff --git a/src/cuawright/core/responses.py b/src/cuawright/core/responses.py new file mode 100644 index 00000000..57ac5447 --- /dev/null +++ b/src/cuawright/core/responses.py @@ -0,0 +1,27 @@ +from urllib.parse import urlsplit, urlunsplit + + +def responses_url(value: str) -> str: + endpoint = urlsplit(value) + if ( + endpoint.scheme not in ("http", "https") + or not endpoint.hostname + or endpoint.username + or endpoint.password + or endpoint.query + or endpoint.fragment + or any(character.isspace() for character in value) + or endpoint.path.rstrip("/").split("/")[-1] != "responses" + ): + raise ValueError( + "responses URL must be an HTTP URL ending in /responses without credentials" + ) + return urlunsplit( + (endpoint.scheme, endpoint.netloc, endpoint.path.rstrip("/"), "", "") + ) + + +def sdk_base_url(value: str) -> str: + endpoint = urlsplit(responses_url(value)) + path = endpoint.path.rsplit("/", 1)[0] or "/" + return urlunsplit((endpoint.scheme, endpoint.netloc, path, "", "")) diff --git a/src/cuawright/desktop/environments/osworld/source.py b/src/cuawright/desktop/environments/osworld/source.py index 77107bd3..b931c05d 100644 --- a/src/cuawright/desktop/environments/osworld/source.py +++ b/src/cuawright/desktop/environments/osworld/source.py @@ -46,8 +46,13 @@ def load_task(tasks, task_id): if not re.fullmatch(r"[A-Za-z0-9_-]+", task_id): raise ReleaseError("invalid task identity") tasks = Path(tasks).resolve(strict=True) + direct = tasks / f"task_{task_id}.py" loader = importlib.import_module("task_loader") - path = loader.find_task_class_path(task_id, str(tasks), "tasks", "v2") + path = ( + str(direct) + if direct.is_file() + else loader.find_task_class_path(task_id, str(tasks), "tasks", "v2") + ) if not path: raise ReleaseError("only official V2 Python class tasks are supported") path = Path(path).resolve(strict=True) @@ -85,9 +90,13 @@ def load_task(tasks, task_id): return task, instruction, task_provenance -def configure_external(assets, key, base_url, website_host_suffix, proxy_config=None): +def configure_external( + assets, key, responses_url, website_host_suffix, proxy_config=None +): + from ....core.responses import sdk_base_url + + base_url = sdk_base_url(responses_url) values = { - "OSWORLD_FILE_BASE_URL": str(assets), "WEBSITE_HOST_SUFFIX": website_host_suffix, "OPENAI_API_KEY": key, "OPENAI_BASE_URL": base_url, @@ -97,6 +106,8 @@ def configure_external(assets, key, base_url, website_host_suffix, proxy_config= "OSWORLD_USER_SIM_API_KEY": key, "OSWORLD_USER_SIM_BASE_URL": base_url, } + if assets is not None: + values["OSWORLD_FILE_BASE_URL"] = str(assets) names = (*values, "PROXY_CONFIG_FILE") previous = {name: os.environ.get(name) for name in names} os.environ.update(values) diff --git a/src/cuawright/desktop/models/openai_response_model.py b/src/cuawright/desktop/models/openai_response_model.py index 04207820..17ca07e5 100644 --- a/src/cuawright/desktop/models/openai_response_model.py +++ b/src/cuawright/desktop/models/openai_response_model.py @@ -1,9 +1,12 @@ +from ...core.responses import sdk_base_url + + def create_client(key, settings): from openai import OpenAI return OpenAI( api_key=key, - base_url=settings.base_url, + base_url=sdk_base_url(settings.responses_url), max_retries=0, timeout=600, ) diff --git a/src/cuawright/desktop/osworld_release.json b/src/cuawright/desktop/osworld_release.json new file mode 100644 index 00000000..cf866ed2 --- /dev/null +++ b/src/cuawright/desktop/osworld_release.json @@ -0,0 +1,38 @@ +{ + "schema_version": 1, + "release": "osworld-v2-2026.08.08", + "source": { + "repository": "https://github.com/xlang-ai/OSWorld-V2.git", + "tag": "v2026.08.08", + "commit": "d578d2d4e0dc82b43e270fdaa7fa89d9708cd154" + }, + "website": { + "repository": "https://github.com/Task-Web/OSWorld-web.git", + "tag": "v2026.08.08", + "host_suffix": "site.hku.icu" + }, + "tasks": { + "repository": "xlangai/osworld_v2_tasks", + "repo_type": "dataset", + "tag": "v2026.08.08", + "count": 108, + "manifest_path": "manifests/task_hashes.json", + "manifest_sha256": "42f8f6f8939b8712997d5891456a575f8a2a5f53465e9e3e6747af5d6efd0915" + }, + "assets": { + "repository": "xlangai/osworld_v2_assets_gated", + "repo_type": "dataset", + "tag": "v2026.08.08", + "commit": "acad110ef3136405f95434b54862bf9066176c2a", + "file_count": 1061 + }, + "vm": { + "repository": "xlangai/v2-image", + "repo_type": "dataset", + "tag": "v2026.06.24", + "artifact": "osworld-v2-ubuntu-x86.qcow2.zip", + "artifact_size": 14189763267, + "artifact_sha256": "eb737ae70b49849e24af407de6a518439a23de05a8497096a948334ce0a909aa", + "runtime_image": "happysixd/osworld-docker" + } +} diff --git a/src/cuawright/desktop/run/benchmarks/osworld.py b/src/cuawright/desktop/run/benchmarks/osworld.py index fca6edfc..ca4d92ed 100644 --- a/src/cuawright/desktop/run/benchmarks/osworld.py +++ b/src/cuawright/desktop/run/benchmarks/osworld.py @@ -7,9 +7,9 @@ import time from dataclasses import asdict, dataclass from pathlib import Path -from urllib.parse import urlsplit from ...agents.default import Actor, RETRY_DELAYS +from ....core.responses import responses_url from ...utils.artifacts import ( Store, credentials, @@ -33,25 +33,21 @@ from ...models.utils.actions_toolcall_response import TOOLS -@dataclass(frozen=True) -class Settings: +@dataclass(frozen=True, kw_only=True) +class RuntimeSettings: source: Path - tasks: Path - assets: Path vm: Path credentials: Path results: Path - task_id: str model: str - base_url: str = "https://api.openai.com/v1" + responses_url: str = "https://api.openai.com/v1/responses" reasoning: str = "high" steps: int = 300 compact_every: int = 40 max_output_tokens: int = 32768 website_host_suffix: str = "" - proxy_config: Path | None = None - def validate(self): + def validate_runtime(self): if type(self.steps) is not int or not 1 <= self.steps <= 500: raise ReleaseError("steps must be in 1..500") if ( @@ -61,33 +57,50 @@ def validate(self): or not 1 <= self.max_output_tokens <= 32768 ): raise ReleaseError("invalid compaction or output token limit") - endpoint = urlsplit(self.base_url) - if ( - endpoint.scheme not in ("http", "https") - or not endpoint.hostname - or endpoint.username - or endpoint.password - or endpoint.query - or endpoint.fragment - or any(character.isspace() for character in self.base_url) - ): - raise ReleaseError("base URL must be an HTTP endpoint without credentials") + try: + responses_url(self.responses_url) + except ValueError as exc: + raise ReleaseError(str(exc)) from None if not self.model.strip(): raise ReleaseError("model must be nonempty") - for path in (self.source, self.tasks, self.assets): - if not path.is_dir() or path.is_symlink(): - raise ReleaseError("source, tasks, and assets must be real directories") + if not self.source.is_dir() or self.source.is_symlink(): + raise ReleaseError("source must be a real directory") for path in (self.vm, self.credentials): if not path.is_file() or path.is_symlink(): raise ReleaseError("VM and credentials must be nonsymlink files") - if self.proxy_config is not None and ( - not self.proxy_config.is_file() or self.proxy_config.is_symlink() - ): - raise ReleaseError("proxy config must be a nonsymlink file") if self.results.exists() or self.results.is_symlink(): raise ReleaseError("results directory must not already exist") if not self.results.parent.is_dir(): raise ReleaseError("results parent must exist") + if self.credentials.resolve().is_relative_to(self.results.resolve()): + raise ReleaseError("credentials must remain external") + + def public(self): + values = asdict(self) + values.pop("credentials") + values.pop("responses_url") + return { + key: str(value) if isinstance(value, Path) else value + for key, value in values.items() + } + + +@dataclass(frozen=True, kw_only=True) +class Settings(RuntimeSettings): + tasks: Path + assets: Path + task_id: str + proxy_config: Path | None = None + + def validate(self): + self.validate_runtime() + for path in (self.tasks, self.assets): + if not path.is_dir() or path.is_symlink(): + raise ReleaseError("tasks and assets must be real directories") + if self.proxy_config is not None and ( + not self.proxy_config.is_file() or self.proxy_config.is_symlink() + ): + raise ReleaseError("proxy config must be a nonsymlink file") protected = [path.resolve() for path in (self.source, self.tasks, self.assets)] if any(self.results.resolve().is_relative_to(path) for path in protected): raise ReleaseError( @@ -99,14 +112,21 @@ def validate(self): raise ReleaseError("credentials must be outside source, tasks, and assets") def public(self): - values = asdict(self) - values.pop("credentials") + values = super().public() values.pop("proxy_config") - values.pop("base_url") - return { - key: str(value) if isinstance(value, Path) else value - for key, value in values.items() - } + return values + + +@dataclass(frozen=True, kw_only=True) +class CustomSettings(RuntimeSettings): + instruction: str + + def validate(self): + self.validate_runtime() + if not self.instruction.strip() or len(self.instruction) > 20_000: + raise ReleaseError("instruction must contain 1..20000 characters") + if self.results.resolve().is_relative_to(self.source.resolve()): + raise ReleaseError("results must be separate from source") @contextlib.contextmanager @@ -269,7 +289,7 @@ def run(settings, client_factory=create_client, guest_factory=Guest): previous = configure_external( settings.assets.resolve(), key, - settings.base_url, + settings.responses_url, settings.website_host_suffix, settings.proxy_config, ) @@ -410,3 +430,121 @@ def run(settings, client_factory=create_client, guest_factory=Guest): if failure is not None: raise ReleaseError(f"release failed during {stage}") from None return outcome + + +def run_custom(settings, client_factory=create_client, guest_factory=Guest): + settings.validate() + store = Store(settings.results) + env = None + client = None + actor = None + previous = {} + failure = None + outcome = None + stage = "preflight" + try: + external = activate(settings.source) + key = credentials(settings.credentials) + previous = configure_external( + None, key, settings.responses_url, settings.website_host_suffix + ) + install_compatibility() + manifest = { + "condition": "osworld-custom-task-v1", + "harness": harness_provenance(), + "external": external, + "vm_sha256": file_hash(settings.vm), + "settings": settings.public(), + "prompts": { + "actor": digest(prompts.ACTOR.encode()), + "compaction": digest(prompts.COMPACTION.encode()), + "initial": digest(prompts.initial(settings.instruction).encode()), + "module": file_hash(Path(prompts.__file__)), + }, + "tools": digest(json.dumps(TOOLS, sort_keys=True).encode()), + "guest_clients": CLIENT_HASHES, + } + store.write("manifest.json", manifest) + stage = "desktop_initialization" + with quiet_external(): + desktop_class = importlib.import_module( + "desktop_env.desktop_env" + ).DesktopEnv + env = desktop_class.__new__(desktop_class) + desktop_class.__init__( + env, + provider_name="docker", + path_to_vm=str(settings.vm.resolve()), + os_type="Ubuntu", + action_space="pyautogui", + cache_dir=str(store.root / "runtime-cache"), + headless=True, + require_a11y_tree=True, + enable_proxy=False, + volume_size=80, + force_disable_recording=True, + ) + guest = guest_factory(env) + guest.website_host_suffix = settings.website_host_suffix + stage = "control_provision" + guest.provision_controls() + stage = "task_setup" + task = { + "id": "custom", + "instruction": settings.instruction, + "platform": "linux", + "config": [], + "disable_recording": True, + } + with quiet_external(): + env.reset(task_config=task) + stage = "support_provision" + guest.provision_support() + client = client_factory(key, settings) + stage = "actor" + actor = Actor(client, guest, store, settings.instruction, settings) + outcome = actor.run() + stage = "persistence" + except BaseException as exc: + failure = exc + finally: + cleanup_failed = False + if env is not None: + try: + with quiet_external(): + env.close() + except BaseException: + cleanup_failed = True + if client is not None: + try: + client.close() + except BaseException: + cleanup_failed = True + restore_external(previous) + if cleanup_failed: + failure = ReleaseError("resource cleanup failed") + stage = "cleanup" + if failure is None: + try: + store.event("cleanup", {"status": "completed"}) + store.write("result.json", {"status": "completed", **outcome}) + except BaseException as exc: + failure = exc + stage = "persistence" + if failure is not None: + try: + store.write( + "result.json", + { + "status": "failed", + "stage": stage, + "error_type": type(failure).__name__, + "cleanup_failed": cleanup_failed, + "actor": actor.outcome("failed") if actor is not None else None, + }, + ) + except BaseException: + raise ReleaseError("failure result persistence failed") from None + if failure is not None: + raise ReleaseError(f"custom task failed during {stage}") from None + return outcome diff --git a/src/cuawright/desktop/run/cli.py b/src/cuawright/desktop/run/cli.py index 9f245209..60679b35 100644 --- a/src/cuawright/desktop/run/cli.py +++ b/src/cuawright/desktop/run/cli.py @@ -1,46 +1,131 @@ import argparse +import json import sys from pathlib import Path -from .benchmarks.osworld import Settings, run +from .benchmarks.osworld import CustomSettings, Settings, run, run_custom + + +def common(parser): + parser.add_argument("--setup", type=Path) + parser.add_argument("--source", type=Path) + parser.add_argument("--vm", type=Path) + parser.add_argument("--credentials", required=True, type=Path) + parser.add_argument("--results", required=True, type=Path) + parser.add_argument("--model", required=True) + parser.add_argument( + "--responses-url", default="https://api.openai.com/v1/responses" + ) + parser.add_argument( + "--reasoning", choices=("low", "medium", "high", "xhigh", "max"), default="high" + ) + parser.add_argument("--steps", type=int, default=300) + parser.add_argument("--compact-every", type=int, default=40) + parser.add_argument("--max-output-tokens", type=int, default=32768) + parser.add_argument("--website-host-suffix") def parser(): result = argparse.ArgumentParser( - description="CUAWright desktop: one official OSWorld-V2 task on Ubuntu via Docker." + description="CUAWright desktop tasks in the pinned OSWorld Ubuntu VM." ) - for flag in ("source", "tasks", "assets", "vm", "credentials", "results"): - result.add_argument("--" + flag, required=True, type=Path) - result.add_argument("--proxy-config", type=Path) - result.add_argument("--task-id", required=True) - result.add_argument("--model", required=True) - result.add_argument("--base-url", default="https://api.openai.com/v1") - result.add_argument( - "--reasoning", choices=("low", "medium", "high", "xhigh", "max"), default="high" + commands = result.add_subparsers(dest="command", required=True) + custom = commands.add_parser( + "run", help="Run an arbitrary task without an official evaluator." + ) + common(custom) + instruction = custom.add_mutually_exclusive_group(required=True) + instruction.add_argument("--instruction") + instruction.add_argument("--instruction-file", type=Path) + reproduce = commands.add_parser( + "reproduce", help="Run and score an official OSWorld-V2 task." ) - result.add_argument("--steps", type=int, default=300) - result.add_argument("--compact-every", type=int, default=40) - result.add_argument("--max-output-tokens", type=int, default=32768) - result.add_argument("--website-host-suffix", default="") + common(reproduce) + reproduce.add_argument( + "benchmark", choices=("osworld",), nargs="?", default="osworld" + ) + reproduce.add_argument("--tasks", type=Path) + reproduce.add_argument("--assets", type=Path) + reproduce.add_argument("--proxy-config", type=Path) + reproduce.add_argument("--task-id", required=True) return result +def setup_values(path): + if path is None: + return {} + return json.loads(path.read_text(encoding="utf-8")) + + +def required_path(args, setup, name): + value = getattr(args, name, None) or setup.get(name) + if value is None: + raise ValueError(f"--{name.replace('_', '-')} or --setup is required") + return Path(value) + + +def settings(args): + configured = setup_values(args.setup) + shared = { + "source": required_path(args, configured, "source"), + "vm": required_path(args, configured, "vm"), + "credentials": args.credentials, + "results": args.results, + "model": args.model, + "responses_url": args.responses_url, + "reasoning": args.reasoning, + "steps": args.steps, + "compact_every": args.compact_every, + "max_output_tokens": args.max_output_tokens, + "website_host_suffix": ( + args.website_host_suffix + if args.website_host_suffix is not None + else configured.get("website_host_suffix", "") + ), + } + if args.command == "run": + instruction = ( + args.instruction + if args.instruction is not None + else args.instruction_file.read_text(encoding="utf-8") + ) + return CustomSettings(instruction=instruction, **shared) + return Settings( + tasks=required_path(args, configured, "tasks"), + assets=required_path(args, configured, "assets"), + task_id=args.task_id, + proxy_config=args.proxy_config, + **shared, + ) + + def main(argv=None): + argv = list(sys.argv[1:] if argv is None else argv) + if not argv or argv[0] in ("-h", "--help"): + parser().print_help() + return 0 + if argv and argv[0].startswith("-"): + argv.insert(0, "reproduce") if sys.version_info < (3, 12): print("CUAWright desktop requires Python 3.12 or newer.", file=sys.stderr) return 1 - settings = Settings(**vars(parser().parse_args(argv))) try: - outcome = run(settings) + options = settings(parser().parse_args(argv)) + outcome = ( + run_custom(options) if isinstance(options, CustomSettings) else run(options) + ) except Exception: print( - "OSWorld release failed; inspect result.json if created. " + "Desktop run failed; inspect result.json if created. " "External diagnostics are deliberately not logged.", file=sys.stderr, ) return 1 - print( - f"{outcome['stop_reason']}; score={outcome['score']}; " - f"model_calls={outcome['model_calls']}" - ) + fields = [ + outcome["stop_reason"], + f"model_calls={outcome['model_calls']}", + ] + if "score" in outcome: + fields.append(f"score={outcome['score']}") + print("; ".join(fields)) return 0 diff --git a/src/cuawright/desktop/setup.py b/src/cuawright/desktop/setup.py new file mode 100644 index 00000000..1af5a891 --- /dev/null +++ b/src/cuawright/desktop/setup.py @@ -0,0 +1,282 @@ +import argparse +import hashlib +import json +import shutil +import subprocess +import sys +import zipfile +from pathlib import Path + +from .exceptions import ReleaseError + +RELEASE_PATH = Path(__file__).with_name("osworld_release.json") +HF_ACCESS_HELP = ( + "Hugging Face access is required. Accept access to " + "https://huggingface.co/datasets/xlangai/osworld_v2_tasks and " + "https://huggingface.co/datasets/xlangai/osworld_v2_assets_gated, then run " + "`uvx --from huggingface_hub hf auth login` and retry." +) + + +def release(): + return json.loads(RELEASE_PATH.read_text(encoding="utf-8")) + + +def file_hash(path): + sha = hashlib.sha256() + with Path(path).open("rb") as stream: + for block in iter(lambda: stream.read(1024 * 1024), b""): + sha.update(block) + return sha.hexdigest() + + +def run(command, cwd=None): + result = subprocess.run(command, cwd=cwd, check=False) + if result.returncode: + raise ReleaseError(f"command failed: {command[0]}") + + +def clone_source(path, metadata): + if path.exists(): + return + path.parent.mkdir(parents=True, exist_ok=True) + run(["git", "clone", metadata["source"]["repository"], str(path)]) + run(["git", "checkout", "--detach", metadata["source"]["commit"]], cwd=path) + + +def install_source(path): + run([sys.executable, "-m", "pip", "install", "-e", str(path)]) + + +def download_data(source, tasks, assets, metadata): + run( + [ + sys.executable, + str(source / "scripts/tools/download_osworld_v2_tasks.py"), + "--benchmark-release", + metadata["release"], + "--target-dir", + str(tasks), + ] + ) + run( + [ + sys.executable, + str(source / "scripts/tools/download_osworld_v2_assets.py"), + "--benchmark-release", + metadata["release"], + "--target-dir", + str(assets), + "--clean", + ] + ) + + +def download_manifest(root, metadata): + from huggingface_hub import hf_hub_download + + tasks = metadata["tasks"] + try: + downloaded = hf_hub_download( + repo_id=tasks["repository"], + repo_type=tasks["repo_type"], + revision=tasks["tag"], + filename=tasks["manifest_path"], + ) + except Exception as exc: + if type(exc).__name__ in { + "GatedRepoError", + "HfHubHTTPError", + "RepositoryNotFoundError", + }: + raise ReleaseError(HF_ACCESS_HELP) from None + raise + target = root / "task_hashes.json" + shutil.copy2(downloaded, target) + return target + + +def check_asset_access(metadata): + from huggingface_hub import HfApi + + assets = metadata["assets"] + try: + HfApi().list_repo_files( + repo_id=assets["repository"], + repo_type=assets["repo_type"], + revision=assets["tag"], + ) + except Exception as exc: + if type(exc).__name__ in { + "GatedRepoError", + "HfHubHTTPError", + "RepositoryNotFoundError", + }: + raise ReleaseError(HF_ACCESS_HELP) from None + raise + + +def download_vm(root, metadata): + from huggingface_hub import hf_hub_download + + vm = metadata["vm"] + archive = Path( + hf_hub_download( + repo_id=vm["repository"], + repo_type=vm["repo_type"], + revision=vm["tag"], + filename=vm["artifact"], + ) + ) + if ( + archive.stat().st_size != vm["artifact_size"] + or file_hash(archive) != vm["artifact_sha256"] + ): + raise ReleaseError("downloaded OSWorld VM artifact does not match release") + target = root / "vm" / "Ubuntu.qcow2" + target.parent.mkdir(parents=True, exist_ok=True) + stage = target.with_suffix(".qcow2.pending") + with zipfile.ZipFile(archive) as bundle: + members = [ + item + for item in bundle.infolist() + if not item.is_dir() and item.filename.endswith(".qcow2") + ] + if len(members) != 1: + raise ReleaseError("official VM archive must contain one qcow2 image") + with bundle.open(members[0]) as source, stage.open("wb") as output: + shutil.copyfileobj(source, output, 1024 * 1024) + stage.replace(target) + return target + + +def write_setup(root, paths, metadata): + payload = { + "release": metadata["release"], + "source": str(paths["source"].resolve()), + "tasks": str(paths["tasks"].resolve()), + "assets": str(paths["assets"].resolve()), + "task_manifest": str(paths["task_manifest"].resolve()), + "vm": str(paths["vm"].resolve()), + "vm_sha256": file_hash(paths["vm"]), + "website_host_suffix": metadata["website"]["host_suffix"], + } + target = root / "setup.json" + target.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + return target + + +def verify_assets(path, metadata): + expected = metadata["assets"] + files = [ + item + for item in path.rglob("*") + if item.is_file() and ".cache" not in item.relative_to(path).parts + ] + metadata_files = sorted((path / ".cache/huggingface/download").rglob("*.metadata")) + if len(files) != expected["file_count"] or len(metadata_files) != len(files): + raise ReleaseError("official asset snapshot is incomplete") + for item in metadata_files: + if item.read_text(encoding="utf-8").splitlines()[0] != expected["commit"]: + raise ReleaseError("asset snapshot does not match release lock") + + +def verify(config_path): + metadata = release() + config = json.loads(Path(config_path).read_text(encoding="utf-8")) + if config.get("release") != metadata["release"]: + raise ReleaseError("setup release does not match CUAWright release lock") + paths = {name: Path(config[name]) for name in ("source", "tasks", "assets", "vm")} + if ( + subprocess.run( + ["git", "-C", str(paths["source"]), "rev-parse", "HEAD"], + capture_output=True, + text=True, + check=False, + ).stdout.strip() + != metadata["source"]["commit"] + ): + raise ReleaseError("OSWorld source commit does not match release lock") + status = subprocess.run( + ["git", "-C", str(paths["source"]), "status", "--porcelain"], + capture_output=True, + text=True, + check=False, + ) + if status.returncode or status.stdout: + raise ReleaseError("OSWorld source must be a clean Git checkout") + manifest = Path(config["task_manifest"]) + if file_hash(manifest) != metadata["tasks"]["manifest_sha256"]: + raise ReleaseError("task hash manifest does not match release lock") + expected = json.loads(manifest.read_text(encoding="utf-8"))["files"] + actual = sorted(paths["tasks"].glob("task_*.py")) + if len(actual) != metadata["tasks"]["count"] or set(expected) != { + path.name for path in actual + }: + raise ReleaseError("official task set is incomplete") + for path in actual: + item = expected[path.name] + if path.stat().st_size != item["size"] or file_hash(path) != item["sha256"]: + raise ReleaseError(f"official task file does not match: {path.name}") + if not paths["assets"].is_dir(): + raise ReleaseError("OSWorld assets directory is missing") + verify_assets(paths["assets"], metadata) + if not paths["vm"].is_file() or paths["vm"].is_symlink(): + raise ReleaseError("OSWorld VM must be a nonsymlink file") + if file_hash(paths["vm"]) != config.get("vm_sha256"): + raise ReleaseError("selected OSWorld VM changed after setup") + return config + + +def setup(args): + metadata = release() + root = args.root.expanduser().resolve() + root.mkdir(parents=True, exist_ok=True) + paths = { + "source": (args.source or root / "source").resolve(), + "tasks": (args.tasks or root / "tasks").resolve(), + "assets": (args.assets or root / "assets").resolve(), + "vm": args.vm.resolve() if args.vm else None, + "task_manifest": (args.task_manifest.resolve() if args.task_manifest else None), + } + if args.dry_run: + print(json.dumps({key: str(value) for key, value in paths.items()}, indent=2)) + return 0 + clone_source(paths["source"], metadata) + if not args.skip_install: + install_source(paths["source"]) + if paths["task_manifest"] is None: + paths["task_manifest"] = download_manifest(root, metadata) + if args.tasks is None or args.assets is None: + if args.assets is None: + check_asset_access(metadata) + download_data(paths["source"], paths["tasks"], paths["assets"], metadata) + if paths["vm"] is None: + paths["vm"] = download_vm(root, metadata) + target = write_setup(root, paths, metadata) + verify(target) + print(target) + return 0 + + +def parser(): + result = argparse.ArgumentParser(description="Prepare or verify pinned OSWorld-V2.") + commands = result.add_subparsers(dest="command", required=True) + prepare = commands.add_parser("setup") + prepare.add_argument("--root", required=True, type=Path) + for name in ("source", "tasks", "assets", "vm", "task-manifest"): + prepare.add_argument("--" + name, type=Path) + prepare.add_argument("--skip-install", action="store_true") + prepare.add_argument("--dry-run", action="store_true") + check = commands.add_parser("verify") + check.add_argument("--setup", required=True, type=Path) + return result + + +def main(argv=None): + args = parser().parse_args(argv) + if args.command == "setup": + return setup(args) + verify(args.setup) + print("OSWorld setup verified.") + return 0 diff --git a/src/cuawright/webwright/config/__init__.py b/src/cuawright/webwright/config/__init__.py index 4aa12f71..3725ee73 100644 --- a/src/cuawright/webwright/config/__init__.py +++ b/src/cuawright/webwright/config/__init__.py @@ -1,7 +1,6 @@ from __future__ import annotations import json -import shutil from pathlib import Path from typing import Any @@ -10,6 +9,15 @@ from cuawright.webwright import package_dir builtin_config_dir = package_dir / "config" +_PRIVATE_CONFIG_KEYS = { + "api_key", + "credentials", + "openai_endpoint", + "anthropic_endpoint", + "openrouter_endpoint", + "responses_url", + "base_url", +} def _nest_key_value(key: str, value: Any) -> dict[str, Any]: @@ -43,6 +51,34 @@ def get_config_from_spec(spec: str) -> dict[str, Any]: return _nest_key_value(key, yaml.safe_load(raw_value)) +def public_config(value: Any) -> Any: + if isinstance(value, dict): + return { + key: ( + "[redacted]" + if key.lower() in _PRIVATE_CONFIG_KEYS + or key.lower().endswith(("_api_key", "_endpoint", "_base_url")) + else public_config(item) + ) + for key, item in value.items() + } + if isinstance(value, list): + return [public_config(item) for item in value] + return value + + +def public_spec(spec: str) -> str: + if "=" not in spec: + return spec + key, _ = spec.split("=", 1) + name = key.rsplit(".", 1)[-1].lower() + if name in _PRIVATE_CONFIG_KEYS or name.endswith( + ("_api_key", "_endpoint", "_base_url") + ): + return f"{key}=[redacted]" + return spec + + def snapshot_config_specs( config_spec: list[str], output_dir: str | Path, @@ -56,14 +92,18 @@ def snapshot_config_specs( for index, spec in enumerate(config_spec): entry: dict[str, Any] = { "index": index, - "spec": spec, + "spec": public_spec(spec), } resolved_path = _resolve_config_path(spec) if resolved_path is None: entry["kind"] = "inline_override" else: saved_copy = snapshot_dir / f"{index:02d}_{resolved_path.name}" - shutil.copy2(resolved_path, saved_copy) + loaded = yaml.safe_load(resolved_path.read_text()) or {} + saved_copy.write_text( + yaml.safe_dump(public_config(loaded), sort_keys=False), + encoding="utf-8", + ) entry.update( { "kind": "file", @@ -79,7 +119,7 @@ def snapshot_config_specs( ) if merged_config is not None: (snapshot_dir / "merged_config.yaml").write_text( - yaml.safe_dump(merged_config, sort_keys=False), + yaml.safe_dump(public_config(merged_config), sort_keys=False), encoding="utf-8", ) return snapshot_dir diff --git a/src/cuawright/webwright/config/local_browser.yaml b/src/cuawright/webwright/config/local_browser.yaml index 8f960fac..3f5f1dbb 100644 --- a/src/cuawright/webwright/config/local_browser.yaml +++ b/src/cuawright/webwright/config/local_browser.yaml @@ -2,7 +2,7 @@ # modifier, for example: # # python -m cuawright.webwright.run.cli \ -# -c base.yaml -c local_browser.yaml -c model_openai.yaml \ +# -c base.yaml -c model_openai.yaml -c local_browser.yaml \ # -t "Open example.com and report the title" \ # --start-url https://example.com # @@ -33,6 +33,8 @@ # the real browser window size instead of forcing a Playwright viewport. model: + # Live browser actions are async Python snippets, not shell run_command calls. + response_mode: json_schema action_field: python_code observation_template: | Observation: diff --git a/src/cuawright/webwright/config/model_openai.yaml b/src/cuawright/webwright/config/model_openai.yaml index 430ee874..4c13cc15 100644 --- a/src/cuawright/webwright/config/model_openai.yaml +++ b/src/cuawright/webwright/config/model_openai.yaml @@ -11,4 +11,4 @@ model: # Legacy response_mode: json_schema is deprecated. response_mode: run_command_tool model_name: gpt-5.4 - openai_endpoint: https://api.openai.com/v1/responses + responses_url: https://api.openai.com/v1/responses diff --git a/src/cuawright/webwright/models/base.py b/src/cuawright/webwright/models/base.py index 5db2af07..965613de 100644 --- a/src/cuawright/webwright/models/base.py +++ b/src/cuawright/webwright/models/base.py @@ -65,7 +65,9 @@ def _is_rate_limit_error(exc: BaseException | None) -> bool: text = str(current).lower() if "rate limit" in text or "ratelimit" in text or "too many requests" in text: return True - current = current.__cause__ if isinstance(current.__cause__, BaseException) else None + current = ( + current.__cause__ if isinstance(current.__cause__, BaseException) else None + ) return False @@ -76,14 +78,33 @@ def _is_transient_http_error(exc: BaseException | None) -> bool: """ current: BaseException | None = exc while current is not None: - if isinstance(current, (httpx.TimeoutException, httpx.NetworkError, httpx.RemoteProtocolError)): + if isinstance( + current, + (httpx.TimeoutException, httpx.NetworkError, httpx.RemoteProtocolError), + ): return True status_code = getattr(current, "status_code", None) - if isinstance(status_code, int) and status_code in {408, 409, 425, 500, 502, 503, 504}: + if isinstance(status_code, int) and status_code in { + 408, + 409, + 425, + 500, + 502, + 503, + 504, + }: return True response = getattr(current, "response", None) response_status = getattr(response, "status_code", None) - if isinstance(response_status, int) and response_status in {408, 409, 425, 500, 502, 503, 504}: + if isinstance(response_status, int) and response_status in { + 408, + 409, + 425, + 500, + 502, + 503, + 504, + }: return True text = str(current).lower() if any( @@ -100,11 +121,15 @@ def _is_transient_http_error(exc: BaseException | None) -> bool: ) ): return True - current = current.__cause__ if isinstance(current.__cause__, BaseException) else None + current = ( + current.__cause__ if isinstance(current.__cause__, BaseException) else None + ) return False -def parse_json_output(raw: str, *, action_field: str = "bash_command") -> dict[str, Any]: +def parse_json_output( + raw: str, *, action_field: str = "bash_command" +) -> dict[str, Any]: try: parsed = json.loads(raw) except json.JSONDecodeError as exc: @@ -140,7 +165,9 @@ def text_part(text: str) -> dict[str, Any]: return {"type": "input_text", "text": text} -def image_part_from_path(path: Path, *, media_type: str | None = None) -> dict[str, Any]: +def image_part_from_path( + path: Path, *, media_type: str | None = None +) -> dict[str, Any]: mime_type, _ = mimetypes.guess_type(str(path)) encoded = base64.b64encode(path.read_bytes()).decode("ascii") return { @@ -157,7 +184,9 @@ def _safe_int(value: Any) -> int: return 0 -def _request_metrics_from_serialized_input(serialized_input: list[dict[str, Any]]) -> dict[str, int]: +def _request_metrics_from_serialized_input( + serialized_input: list[dict[str, Any]], +) -> dict[str, int]: message_count = len(serialized_input) text_part_count = 0 image_part_count = 0 @@ -218,7 +247,9 @@ class BaseModelConfig(PydanticBaseModel): def validate_action_field(cls, value: str) -> str: normalized = value.strip() if normalized not in ACTION_FIELDS: - raise ValueError(f"action_field must be one of: {', '.join(sorted(ACTION_FIELDS))}") + raise ValueError( + f"action_field must be one of: {', '.join(sorted(ACTION_FIELDS))}" + ) return normalized @@ -246,25 +277,38 @@ class BaseModel: def __init__(self, *, config_class: type | None = None, **kwargs): self.config = (config_class or self._DEFAULT_CONFIG_CLASS)(**kwargs) - self._last_request_metrics: dict[str, int] = {k: 0 for k in _REQUEST_METRIC_KEYS} + self._last_request_metrics: dict[str, int] = { + k: 0 for k in _REQUEST_METRIC_KEYS + } self._last_usage_metrics: dict[str, int] = {k: 0 for k in _USAGE_METRIC_KEYS} - self._cumulative_request_metrics: dict[str, int] = dict(self._last_request_metrics) + self._cumulative_request_metrics: dict[str, int] = dict( + self._last_request_metrics + ) self._cumulative_usage_metrics: dict[str, int] = dict(self._last_usage_metrics) if self._API_KEY_FIELD: if not getattr(self.config, self._API_KEY_FIELD, ""): - setattr(self.config, self._API_KEY_FIELD, os.environ.get(self._ENV_VAR, "")) + setattr( + self.config, self._API_KEY_FIELD, os.environ.get(self._ENV_VAR, "") + ) if not getattr(self.config, self._API_KEY_FIELD, ""): raise RuntimeError(f"Missing {self._ENV_VAR}.") if getattr(self.config, "response_mode", "") == "run_command_tool": if self.config.action_field != "bash_command": - raise ValueError("run_command_tool requires the local workspace bash_command action field") - from cuawright.webwright.models.tool_calls import DEFAULT_TOOL_FORMAT_ERROR_TEMPLATE + raise ValueError( + "run_command_tool requires the local workspace bash_command action field" + ) + from cuawright.webwright.models.tool_calls import ( + DEFAULT_TOOL_FORMAT_ERROR_TEMPLATE, + ) + self.config.format_error_template = DEFAULT_TOOL_FORMAT_ERROR_TEMPLATE def _parse_response(self, payload: dict[str, Any]) -> dict[str, Any]: - return parse_json_output(self._extract_text(payload), action_field=self.config.action_field) + return parse_json_output( + self._extract_text(payload), action_field=self.config.action_field + ) def _response_extra(self, payload: dict[str, Any]) -> dict[str, Any]: return {} @@ -344,14 +388,25 @@ def _usage_snapshot(self) -> dict[str, dict[str, int]]: "cumulative_request": { "message_count": self._cumulative_request_metrics["message_count"], "text_part_count": self._cumulative_request_metrics["text_part_count"], - "image_part_count": self._cumulative_request_metrics["image_part_count"], + "image_part_count": self._cumulative_request_metrics[ + "image_part_count" + ], "input_tokens": self._cumulative_usage_metrics["input_tokens"], - "cached_input_tokens": self._cumulative_usage_metrics["cached_input_tokens"], + "cached_input_tokens": self._cumulative_usage_metrics[ + "cached_input_tokens" + ], }, "cumulative_response": dict(self._cumulative_usage_metrics), } - def _log_gateway_error(self, *, event: str, attempt: int, error: BaseException) -> None: + def _log_gateway_error( + self, *, event: str, attempt: int, error: BaseException + ) -> None: + endpoint = self._post_url() + + def redact(value: str) -> str: + return value.replace(endpoint, "[redacted endpoint]") + response = getattr(error, "response", None) response_text = "" if response is not None: @@ -361,16 +416,17 @@ def _log_gateway_error(self, *, event: str, attempt: int, error: BaseException) response_text = "" if len(response_text) > 4000: response_text = response_text[:4000] + response_text = redact(response_text) append_runtime_log( self.config.error_log_path, source=self._LOG_SOURCE, event=event, model_name=self.config.model_name, - endpoint=self._post_url(), + endpoint="[redacted]", attempt=attempt, error_type=type(error).__name__, - error=str(error), + error=redact(str(error)), status_code=getattr(response, "status_code", None), response_text=response_text, ) @@ -395,7 +451,9 @@ def format_observation_messages( observation_messages: list[dict[str, Any]] = [] for output in outputs: observation = output.get("observation", {}) - content = Template(self.config.observation_template, undefined=StrictUndefined).render( + content = Template( + self.config.observation_template, undefined=StrictUndefined + ).render( output=output, observation=observation, **(template_vars or {}), @@ -425,7 +483,9 @@ def format_observation_messages( parts.append(image_part_from_path(path)) observation_messages.append( - self.format_message(role="user", content=parts, extra={"observation": observation}) + self.format_message( + role="user", content=parts, extra={"observation": observation} + ) ) tool_call_id = (message.get("extra") or {}).get("tool_call_id") if observation_messages and isinstance(tool_call_id, str): @@ -437,7 +497,9 @@ def _format_error(self, *, raw_text: str, error: str) -> FormatError: return FormatError( self.format_message( role="user", - content=Template(self.config.format_error_template, undefined=StrictUndefined).render( + content=Template( + self.config.format_error_template, undefined=StrictUndefined + ).render( error=error, model_response=raw_text, **self.get_template_vars(), @@ -452,7 +514,9 @@ def _format_error(self, *, raw_text: str, error: str) -> FormatError: def _format_repair_message(self, *, raw_text: str, error: str) -> dict[str, Any]: return self.format_message( role="user", - content=Template(self.config.format_error_template, undefined=StrictUndefined).render( + content=Template( + self.config.format_error_template, undefined=StrictUndefined + ).render( error=error, model_response=raw_text, **self.get_template_vars(), @@ -466,9 +530,13 @@ def _format_repair_message(self, *, raw_text: str, error: str) -> dict[str, Any] async def _post_with_retries(self, payload: dict[str, Any]) -> dict[str, Any]: headers = self._request_headers() url = self._post_url() - for attempt in range(max(self._MAX_RATE_LIMIT_RETRIES, self._MAX_TRANSIENT_RETRIES) + 1): + for attempt in range( + max(self._MAX_RATE_LIMIT_RETRIES, self._MAX_TRANSIENT_RETRIES) + 1 + ): try: - async with httpx.AsyncClient(timeout=self.config.request_timeout_seconds) as client: + async with httpx.AsyncClient( + timeout=self.config.request_timeout_seconds + ) as client: response = await client.post(url, headers=headers, json=payload) response.raise_for_status() return response.json() @@ -501,7 +569,9 @@ async def _query_async(self, messages: list[dict[str, Any]]) -> dict[str, Any]: request_messages = list(messages) for attempt_index in range(MAX_JSON_PARSE_RETRIES + 1): payload = self._build_payload(request_messages) - request_metrics = _request_metrics_from_serialized_input(self._request_metrics_input(payload)) + request_metrics = _request_metrics_from_serialized_input( + self._request_metrics_input(payload) + ) self._last_request_metrics = dict(request_metrics) for key, value in request_metrics.items(): self._cumulative_request_metrics[key] += value @@ -554,7 +624,11 @@ async def _query_async(self, messages: list[dict[str, Any]]) -> dict[str, Any]: response_extra = self._response_extra(response_payload) content = parsed.get("thought", "") if response_extra: - content = parsed.get("final_response") or response_extra.get("reasoning_summary") or content + content = ( + parsed.get("final_response") + or response_extra.get("reasoning_summary") + or content + ) return self.format_message( role="assistant", content=content, @@ -579,7 +653,9 @@ async def _complete_text_async( self.config.max_output_tokens = max_output_tokens try: payload = self._build_text_payload(messages) - request_metrics = _request_metrics_from_serialized_input(self._request_metrics_input(payload)) + request_metrics = _request_metrics_from_serialized_input( + self._request_metrics_input(payload) + ) self._last_request_metrics = dict(request_metrics) for key, value in request_metrics.items(): self._cumulative_request_metrics[key] += value @@ -614,8 +690,14 @@ def query(self, messages: list[dict[str, Any]], **kwargs) -> dict[str, Any]: def serialize(self) -> dict[str, Any]: config_dump = self.config.model_dump(mode="json") - if self._API_KEY_FIELD: - config_dump[self._API_KEY_FIELD] = "" + for key in config_dump: + name = key.lower() + if ( + key == self._API_KEY_FIELD + or name in {"api_key", "credentials", "base_url", "responses_url"} + or name.endswith(("_api_key", "_endpoint", "_base_url")) + ): + config_dump[key] = "" return { "model": { "config": config_dump, diff --git a/src/cuawright/webwright/models/openai_model.py b/src/cuawright/webwright/models/openai_model.py index d46cfa4e..0b69498b 100644 --- a/src/cuawright/webwright/models/openai_model.py +++ b/src/cuawright/webwright/models/openai_model.py @@ -7,7 +7,12 @@ from pathlib import Path from typing import Any, Literal -from cuawright.webwright.models.tool_calls import RUN_COMMAND_TOOL, TOOL_RESPONSE_MODE, _normalize_response_items, parse_tool_call_output +from cuawright.webwright.models.tool_calls import ( + RUN_COMMAND_TOOL, + TOOL_RESPONSE_MODE, + _normalize_response_items, + parse_tool_call_output, +) from cuawright.webwright.models.base import ( BaseModel, BaseModelConfig, @@ -16,6 +21,7 @@ image_part_from_path, text_part, ) +from cuawright.core.responses import responses_url __all__ = [ "OpenAIModel", @@ -25,7 +31,9 @@ ] -def _serialize_response_content_part(part: dict[str, Any], *, role: str) -> dict[str, Any]: +def _serialize_response_content_part( + part: dict[str, Any], *, role: str +) -> dict[str, Any]: if part.get("type") == "input_image": return { "type": "input_image", @@ -71,7 +79,9 @@ def flush_pending() -> None: if not isinstance(item, dict): continue serialized.append(copy.deepcopy(item)) - if item.get("type") == "function_call" and isinstance(item.get("call_id"), str): + if item.get("type") == "function_call" and isinstance( + item.get("call_id"), str + ): pending_calls.append(item["call_id"]) continue raw_response = extra.get("raw_response") @@ -79,14 +89,20 @@ def flush_pending() -> None: # Keep the model's complete structured response in history. The # display content contains only its thought, while the command, # completion state, and final response live in raw_response. - content = json.dumps(raw_response, ensure_ascii=False, separators=(",", ":")) + content = json.dumps( + raw_response, ensure_ascii=False, separators=(",", ":") + ) if isinstance(content, str): serialized_content = [text_part(content)] else: serialized_content = [part for part in content if isinstance(part, dict)] tool_call_id = extra.get("tool_call_id") - if role == "user" and isinstance(tool_call_id, str) and tool_call_id in pending_calls: + if ( + role == "user" + and isinstance(tool_call_id, str) + and tool_call_id in pending_calls + ): pending_calls.remove(tool_call_id) serialized.append( { @@ -100,14 +116,17 @@ def flush_pending() -> None: } ) # A function_call_output carries text only; images follow as a user turn. - image_parts = [part for part in serialized_content if part.get("type") == "input_image"] + image_parts = [ + part for part in serialized_content if part.get("type") == "input_image" + ] if image_parts: serialized.append( { "type": "message", "role": "user", "content": [ - _serialize_response_content_part(part, role="user") for part in image_parts + _serialize_response_content_part(part, role="user") + for part in image_parts ], } ) @@ -178,6 +197,7 @@ class OpenAIModelConfig(BaseModelConfig): reasoning_effort: OptStr = "" openai_api_key: OptStr = "" openai_endpoint: OptStr = "https://api.openai.com/v1/responses" + responses_url: OptStr = "" class OpenAIModel(BaseModel): @@ -195,18 +215,34 @@ def _request_headers(self) -> dict[str, str]: } def _post_url(self) -> str: - return self.config.openai_endpoint + fields = getattr(self.config, "model_fields_set", set()) + value = ( + self.config.openai_endpoint + if "openai_endpoint" in fields + else self.config.responses_url or self.config.openai_endpoint + ) + return responses_url(value) def _build_payload(self, messages: list[dict[str, Any]]) -> dict[str, Any]: payload = self._build_text_payload(messages) if self.config.response_mode == TOOL_RESPONSE_MODE: - payload.update(tools=[copy.deepcopy(RUN_COMMAND_TOOL)], parallel_tool_calls=False, - include=["reasoning.encrypted_content"], store=False) + payload.update( + tools=[copy.deepcopy(RUN_COMMAND_TOOL)], + parallel_tool_calls=False, + include=["reasoning.encrypted_content"], + store=False, + ) if messages and (messages[-1].get("extra") or {}).get("disable_tools"): payload["tool_choice"] = "none" else: - payload["text"] = {"format": {"type": "json_schema", "name": "playwright_step", - "schema": self._response_schema(), "strict": True}} + payload["text"] = { + "format": { + "type": "json_schema", + "name": "playwright_step", + "schema": self._response_schema(), + "strict": True, + } + } return payload def _parse_response(self, payload: dict[str, Any]) -> dict[str, Any]: @@ -219,12 +255,18 @@ def _response_extra(self, payload: dict[str, Any]) -> dict[str, Any]: return {} items = _normalize_response_items(payload) extra = {"response_items": items} - call = next((item for item in items if item.get("type") == "function_call"), None) + call = next( + (item for item in items if item.get("type") == "function_call"), None + ) if call: extra["tool_call_id"] = call["call_id"] extra["reasoning_summary"] = "\n".join( - part.get("text", "") for item in items if item.get("type") == "reasoning" - for part in item.get("summary", []) if isinstance(part, dict)) + part.get("text", "") + for item in items + if item.get("type") == "reasoning" + for part in item.get("summary", []) + if isinstance(part, dict) + ) return extra def _build_text_payload(self, messages: list[dict[str, Any]]) -> dict[str, Any]: @@ -235,7 +277,10 @@ def _build_text_payload(self, messages: list[dict[str, Any]]) -> dict[str, Any]: } if self.config.reasoning_effort: - payload["reasoning"] = {"effort": self.config.reasoning_effort, "summary": "auto"} + payload["reasoning"] = { + "effort": self.config.reasoning_effort, + "summary": "auto", + } return payload def _request_metrics_input(self, payload: dict[str, Any]) -> list[dict[str, Any]]: diff --git a/src/cuawright/webwright/tools/self_reflection.py b/src/cuawright/webwright/tools/self_reflection.py index 952e20c2..db38674b 100644 --- a/src/cuawright/webwright/tools/self_reflection.py +++ b/src/cuawright/webwright/tools/self_reflection.py @@ -372,7 +372,6 @@ class SelfReflectionResult: def to_dict(self) -> dict[str, Any]: return { "model": self.model, - "endpoint": self.endpoint, "predicted_label": self.predicted_label, "final_response": self.final_response, "final_user_text": self.final_user_text, diff --git a/tests/unit/test_cuawright_entrypoints.py b/tests/unit/test_cuawright_entrypoints.py index a1e56264..69d418fd 100644 --- a/tests/unit/test_cuawright_entrypoints.py +++ b/tests/unit/test_cuawright_entrypoints.py @@ -2,6 +2,7 @@ import subprocess import sys +import os import pytest @@ -13,6 +14,10 @@ def test_public_module_help(module): result = subprocess.run( [sys.executable, "-m", module, "--help"], + env={ + **os.environ, + "PYTHONPATH": str(__import__("pathlib").Path(__file__).parents[2] / "src"), + }, capture_output=True, text=True, timeout=30, @@ -21,6 +26,27 @@ def test_public_module_help(module): assert "Usage" in result.stdout or "usage" in result.stdout +def test_top_level_cli_help(): + result = subprocess.run( + [sys.executable, "-m", "cuawright", "--help"], + env={ + **os.environ, + "PYTHONPATH": str(__import__("pathlib").Path(__file__).parents[2] / "src"), + }, + capture_output=True, + text=True, + timeout=30, + ) + assert result.returncode == 0 + assert "{web,desktop,setup,verify,smoke}" in result.stdout + + +def test_desktop_root_help_shows_both_workflows(): + from cuawright.desktop.run.cli import main + + assert main(["--help"]) == 0 + + def test_legacy_image_command_remains_brokered(): from cuawright.webwright.environments.local_workspace import ( LocalWorkspaceEnvironment, diff --git a/tests/unit/test_responses_and_setup.py b/tests/unit/test_responses_and_setup.py new file mode 100644 index 00000000..3642ac92 --- /dev/null +++ b/tests/unit/test_responses_and_setup.py @@ -0,0 +1,139 @@ +import json +from pathlib import Path +import sys +from types import SimpleNamespace + +import pytest +import yaml + +from cuawright.core.responses import responses_url, sdk_base_url +from cuawright.desktop import setup +from cuawright.desktop.exceptions import ReleaseError +from cuawright.webwright.config import get_config_from_spec, snapshot_config_specs +from cuawright.webwright.models.openai_model import OpenAIModel +from cuawright.webwright.utils.serialize import recursive_merge + + +@pytest.mark.parametrize( + ("value", "base"), + [ + ("https://api.openai.com/v1/responses", "https://api.openai.com/v1"), + ("https://gateway.example/api/responses/", "https://gateway.example/api"), + ("http://127.0.0.1:8000/responses", "http://127.0.0.1:8000/"), + ], +) +def test_responses_url_contract(value, base): + assert responses_url(value).endswith("/responses") + assert sdk_base_url(value) == base + + +@pytest.mark.parametrize( + "value", + [ + "https://example.com/v1", + "https://user@example.com/v1/responses", + "https://example.com/v1/responses?key=secret", + ], +) +def test_invalid_responses_url_is_rejected(value): + with pytest.raises(ValueError): + responses_url(value) + + +def test_local_browser_modifier_selects_python_response_contract(): + config = recursive_merge( + get_config_from_spec("base.yaml"), + get_config_from_spec("model_openai.yaml"), + get_config_from_spec("local_browser.yaml"), + ) + assert config["model"]["response_mode"] == "json_schema" + assert config["model"]["action_field"] == "python_code" + + +def test_model_artifacts_redact_endpoint_and_credentials(tmp_path): + endpoint = "https://gateway.example/v1/responses" + model = OpenAIModel( + openai_api_key="secret-value", + responses_url=endpoint, + error_log_path=tmp_path / "runtime_errors.jsonl", + ) + serialized = json.dumps(model.serialize()) + assert endpoint not in serialized + assert "secret-value" not in serialized + model._log_gateway_error( + event="request_failed", + attempt=1, + error=RuntimeError(f"POST {endpoint} failed"), + ) + logged = (tmp_path / "runtime_errors.jsonl").read_text() + assert endpoint not in logged + assert "[redacted endpoint]" in logged + + +def test_browser_config_snapshots_redact_transport_identity(tmp_path): + config = tmp_path / "model.yaml" + config.write_text( + yaml.safe_dump( + { + "model": { + "responses_url": "https://gateway.example/v1/responses", + "openai_api_key": "secret", + "model_name": "test-model", + } + } + ) + ) + snapshot = snapshot_config_specs( + [ + str(config), + "model.responses_url=https://inline.example/v1/responses", + "model.openai_api_key=inline-secret", + ], + tmp_path / "output", + merged_config=yaml.safe_load(config.read_text()), + ) + text = "\n".join(path.read_text() for path in snapshot.iterdir()) + assert "gateway.example" not in text + assert "inline.example" not in text + assert "secret" not in text + assert "test-model" in text + + +def test_setup_dry_run_uses_pinned_release(tmp_path, capsys): + assert setup.main(["setup", "--root", str(tmp_path), "--dry-run"]) == 0 + values = json.loads(capsys.readouterr().out) + assert Path(values["source"]) == tmp_path / "source" + assert setup.release()["source"]["commit"] == ( + "d578d2d4e0dc82b43e270fdaa7fa89d9708cd154" + ) + + +def test_gated_huggingface_failure_has_approval_instructions(tmp_path, monkeypatch): + HfHubHTTPError = type("HfHubHTTPError", (RuntimeError,), {}) + + def denied(**_): + raise HfHubHTTPError() + + monkeypatch.setitem( + sys.modules, + "huggingface_hub", + SimpleNamespace(hf_hub_download=denied), + ) + with pytest.raises(ReleaseError, match="hf auth login") as error: + setup.download_manifest(tmp_path, setup.release()) + assert "osworld_v2_tasks" in str(error.value) + assert "osworld_v2_assets_gated" in str(error.value) + + +def test_asset_snapshot_verification_requires_pinned_commit(tmp_path): + assets = tmp_path / "assets" + assets.mkdir() + (assets / "asset.bin").write_bytes(b"asset") + metadata_file = assets / ".cache/huggingface/download/asset.bin.metadata" + metadata_file.parent.mkdir(parents=True) + metadata_file.write_text("pinned-commit\netag\ntime\n") + release = {"assets": {"file_count": 1, "commit": "pinned-commit"}} + setup.verify_assets(assets, release) + metadata_file.write_text("different\netag\ntime\n") + with pytest.raises(ReleaseError, match="release lock"): + setup.verify_assets(assets, release) diff --git a/tests/unit/test_self_reflection_reliability.py b/tests/unit/test_self_reflection_reliability.py index 632267f5..28cd47d3 100644 --- a/tests/unit/test_self_reflection_reliability.py +++ b/tests/unit/test_self_reflection_reliability.py @@ -41,6 +41,7 @@ def test_malformed_verdict_is_retried(monkeypatch, tmp_path): ['Reasoning: satisfied\nScore: 5', 'unknown', 'Status: success']) result = asyncio.run(judge.run_self_reflection_async(**kwargs)) assert result.predicted_label == 1 + assert "endpoint" not in result.to_dict() assert len(calls) == 3 assert calls[-1]['user_content'][1]['type'] == 'input_image' From 24ebd821955bc5cd69043d900b417b1b69244d93 Mon Sep 17 00:00:00 2001 From: GitHub Copilot <223556219+Copilot@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:19:37 -0700 Subject: [PATCH 5/8] Clarify setup docs and fix private tool routing and VM pin --- AGENTS.md | 17 +- README.md | 156 ++++++++---------- REPRODUCE.md | 126 ++++++++++---- src/cuawright/desktop/osworld_release.json | 9 +- src/cuawright/desktop/setup.py | 2 +- src/cuawright/webwright/run/cli.py | 22 +-- .../webwright/tools/_model_config.py | 39 ++++- tests/unit/test_responses_and_setup.py | 34 ++++ tests/unit/test_tool_model_routing.py | 146 +++++++++++++++- 9 files changed, 397 insertions(+), 154 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index dc9d4d57..805bcf3e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,7 +3,7 @@ Use Python 3.12 for desktop work. Install with: ```bash -pip install -e ".[desktop,test,build]" +pip install -e ".[desktop,skill-factory,test,build]" -e extensions/skill-factory playwright install chromium ``` @@ -11,24 +11,25 @@ Keep the browser and desktop runtimes separate: - `src/cuawright/webwright/` owns browser messages, tools, compaction, and completion. - `src/cuawright/desktop/` owns the OSWorld VM, guest bridge, explicit submit, and evaluation. -- `src/cuawright/core/` is only for small contracts that are genuinely identical. +- `src/cuawright/core/` holds shared API helpers. Add code here only when both runtimes use it. Do not introduce a universal agent/model/environment abstraction or duplicate the browser runtime under another package. Preserve `webwright` compatibility aliases. -Public code and artifacts must contain only standard Responses-compatible API -configuration; never persist credentials or endpoint identities. +Keep provider-specific private integrations outside this repository. Never save +credentials or custom endpoint URLs in run artifacts, examples, or diagnostics. Before changing desktop prompts, read `src/cuawright/desktop/config/prompts.py` in full. Plain final text never completes a desktop task; the Actor must execute the -standalone submit control. Official reproduction must retain the pinned release, -task setup, evaluator, provenance, and score. +standalone command `python /opt/cuawright-tools/submit.py`. Official reproduction +must retain the pinned release, task setup, evaluator, provenance, and score. Validate focused changes first, then run: ```bash -pytest -q tests/unit release/osworld/tests +pytest -q python -m build -python tests/packaging/check_wheels.py +python -m build extensions/skill-factory +python tests/packaging/check_wheels.py dist/cuawright-*.whl extensions/skill-factory/dist/*.whl ``` Keep net changes within 900 production Python lines and 600 test lines. No newly diff --git a/README.md b/README.md index c3d76b65..e9c059d2 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,9 @@ # CUAWright -> **Webwright is now CUAWright.** The project has expanded from browser automation -> to browser and desktop agents. Webwright remains the browser subsystem, now at -> `cuawright.webwright`; OSWorld 2.0 desktop support lives at `cuawright.desktop`. -> Use `cuawright web` for browser tasks and `cuawright desktop` for desktop tasks. -> Existing `webwright` commands and Python imports remain supported. -> Browser and desktop keep their distinct message, tool, completion, and -> environment contracts while sharing only small identical utilities. +> **Webwright is now CUAWright.** Alongside browser automation, you can now run +> desktop tasks in an Ubuntu VM. Use `cuawright web` for browser tasks and +> `cuawright desktop` for desktop tasks. Existing `webwright` commands and Python +> imports still work.

CUAWright logo @@ -23,10 +20,9 @@ - πŸ“ **Blog:** [Webwright: A Terminal Is All You Need For Web Agents](https://www.microsoft.com/en-us/research/articles/webwright-a-terminal-is-all-you-need-for-web-agents/) - 🌐 **Project Page:** [microsoft.github.io/Webwright](https://microsoft.github.io/Webwright/) -CUAWright combines **Webwright browser automation** and a **persistent desktop Actor** for official OSWorld-V2 tasks. - -The browser and desktop runtimes intentionally remain separate because browser -script completion and OSWorld explicit submission/evaluation are different contracts. +CUAWright gives coding models a terminal to automate browsers and desktop applications. +Use Webwright to browse the web and build reusable Playwright scripts, or run your +own desktop task and reproduce OSWorld-V2 benchmarks in an Ubuntu VM. | Command | Runtime | | --- | --- | @@ -38,6 +34,9 @@ script completion and OSWorld explicit submission/evaluation are different contr | `cuawright-desktop` | Compatibility desktop entrypoint | | `webwright` | Compatibility alias for `cuawright-web` | +Start with the [browser quick start](#-browser-quick-start) or +[desktop quick start](#-desktop-quick-start). + ```bash pip install -e . playwright install chromium @@ -45,16 +44,14 @@ cuawright-web main -c base.yaml -c model_openai.yaml -t "" --start-url "