Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 90 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,16 +170,20 @@ python assets/task_showcase/app.py \
### Prerequisites

- Python 3.10+
- `uv`
- Chromium installed through Playwright
- An API key for your chosen backend (OpenAI, Anthropic, or OpenRouter)

### Install

```bash
pip install -e .
playwright install chromium
uv sync --dev
uv run playwright install chromium
```

`uv` will create a local `.venv/` for this repository. That directory is already
ignored by git.

### Run

Export credentials for the configured backend (for example, `OPENAI_API_KEY`
Expand All @@ -188,14 +192,54 @@ 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 \
uv run 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 \
--task-id demo_openai \
-o outputs/default
```

### Continue a Previous Run

Webwright can resume a prior run from its saved `trajectory.json` and append a
new follow-up instruction on top of the existing history.

Resume from a run directory:

```bash
uv run webwright.run.cli \
-c base.yaml -c model_openai.yaml \
--resume-from outputs/default/demo_openai_20260706_150300 \
--followup "Continue the task and summarize only unread results."
```

Resume from an explicit trajectory file:

```bash
uv run webwright.run.cli \
-c base.yaml -c model_openai.yaml \
--resume-from outputs/default/demo_openai_20260706_150300/trajectory.json \
--followup "Keep iterating from the saved state."
```

Resume behavior:

- `--resume-from` accepts either a run folder or a `trajectory.json` path.
- The resumed run reuses the original output folder instead of creating a new
timestamped subfolder.
- `trajectory.json` is rewritten as the new full snapshot, containing both the
old and new conversation history.
- `raw_responses.jsonl` and `runtime_errors.jsonl` continue appending to the
existing files.
- Step artifacts under `steps/`, `logs/`, and `screenshots/` continue from the
next available step number instead of overwriting prior files.

For `local_browser.yaml` with `browser_mode: local_cdp`, Webwright also tries
to reconnect to the previous browser tab. It first attempts to restore the
saved CDP target, then falls back to the last seen URL, then `start_url`, then
an existing open page, and finally opens a new page if nothing matches.

### 🚩 Flags

| Flag | Description |
Expand All @@ -204,19 +248,60 @@ python -m webwright.run.cli \
| `-t` | Task instruction. |
| `--start-url` | Initial page. |
| `--task-id` | Output subfolder name. |
| `--resume-from` | Resume from a prior run directory or `trajectory.json`. |
| `--followup` | Extra instruction appended when resuming a prior run. |
| `-o` | Output directory. |

---

## 🌐 HTTP Server (Web UI backend)

Webwright ships a local, no-auth HTTP API that wraps the exploration loop for
web UIs. It is designed around the headed `local_cdp` mode: a human and the
agent share the same visible browser (the human handles logins and other
sensitive steps), exploration steps are intermediate, and the deliverable is a
**reusable parameterized function** (`final_function.py` with a `PARAMS_SPEC`
dict and `async def run(page, context, browser, params)`) that can be
re-executed later with different parameters against the already-open browser.

```bash
uv sync --extra server
uv run webwright-server --host 127.0.0.1 --port 8787 --output-dir outputs
```

Endpoints:

| Method & Path | Purpose |
|---|---|
| `POST /runs` | Start an exploration run (`{task, task_id?, start_url?, config_spec?}`). Default config: `base.yaml + local_browser.yaml + crafted_browser_fn.yaml + model_claude.yaml`. |
| `GET /runs`, `GET /runs/{id}` | List runs / run detail (status, runtime state, `params_spec`). |
| `GET /runs/{id}/steps?since=N` | Step-by-step thought + generated code + observation. |
| `GET /runs/{id}/steps/{n}/code` | Raw code for one step. |
| `GET /runs/{id}/script` | The current artifact: `final_function.py` > `final_script.py` > `script.py`. |
| `GET /runs/{id}/events` | Server-Sent Events stream of live progress (`step` / `status` / `done`). |
| `GET /runs/{id}/screenshots[/{file}]` | Per-step screenshots. |
| `POST /runs/{id}/followup` | Continue the conversation on a run (reuses the same browser tab in `local_cdp` mode). |
| `POST /runs/{id}/cancel` | Cancel the active operation (leaves the browser window open). |
| `POST /runs/{id}/execute-script` | Re-execute the generated artifact with `{params: {...}}` — reconnects to the run's browser tab and calls `run()` with your params. |
| `GET /runs/{id}/executions[/{exec_id}]` | Execution history / results. |
| `GET /configs`, `GET /health` | Config presets and doctor-style environment checks. |

Exploration runs, follow-ups, and executions all contend for the same browser
tab, so exactly one operation may be active at a time — concurrent requests
get `409`. Run state lives entirely on disk under `--output-dir`, so history
survives server restarts.

---

## 🔌 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.

Common runtime deps (install once after either path):

```bash
pip install -e .
playwright install chromium
uv sync --dev
uv run playwright install chromium
```

<details>
Expand Down
14 changes: 14 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,24 @@ dependencies = [
"playwright>=1.45",
"python-dotenv>=1.0",
"platformdirs>=4.0",
"flask>=3.1.3",
]

[project.optional-dependencies]
server = [
"fastapi>=0.115",
"uvicorn[standard]>=0.30",
]

[dependency-groups]
dev = [
"pytest>=8.0",
"httpx>=0.27",
]

[project.scripts]
webwright = "webwright.run.cli:app"
webwright-server = "webwright.server.app:main"

[tool.setuptools.packages.find]
where = ["src"]
Expand Down
76 changes: 57 additions & 19 deletions src/webwright/agents/default.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,27 @@ def __init__(self, model: Model, env: Environment, *, config_class: type = Agent
self.n_calls = 0
self.n_format_errors = 0

def _run_loop(self) -> dict[str, Any]:
while True:
try:
self.step()
except InterruptAgentFlow as exc:
if isinstance(exc, FormatError):
self.n_format_errors += 1
self.add_messages(*exc.messages)
finally:
self.save(self.config.output_path)
if self.messages[-1].get("role") == "exit":
break
if (
self.config.summary_every_n_steps > 0
and self.n_calls > 0
and self.n_calls % self.config.summary_every_n_steps == 0
):
self._compact_history()
self.save(self.config.output_path)
return self.messages[-1].get("extra", {})

def _debug_dir(self) -> Path | None:
if self.config.output_path is None:
return None
Expand Down Expand Up @@ -359,26 +380,43 @@ def run(self, task: str = "", **kwargs) -> dict[str, Any]:
+ "\n\n## End of Explore History",
),
)
return self._run_loop()

while True:
try:
self.step()
except InterruptAgentFlow as exc:
if isinstance(exc, FormatError):
self.n_format_errors += 1
self.add_messages(*exc.messages)
finally:
self.save(self.config.output_path)
if self.messages[-1].get("role") == "exit":
break
if (
self.config.summary_every_n_steps > 0
and self.n_calls > 0
and self.n_calls % self.config.summary_every_n_steps == 0
):
self._compact_history()
self.save(self.config.output_path)
return self.messages[-1].get("extra", {})
def resume(
self,
*,
prior_state: dict[str, Any],
followup: str | None = None,
task: str = "",
**kwargs,
) -> dict[str, Any]:
messages = copy.deepcopy(prior_state.get("messages", []))
if not messages:
raise ValueError("Cannot resume without prior messages.")

while messages and messages[-1].get("role") == "exit":
messages.pop()
if not messages:
raise ValueError("Cannot resume from a trajectory that only contains exit messages.")

inferred_task = task or str(prior_state.get("task", "") or "")
self.extra_template_vars |= {"task": inferred_task, **kwargs}
self.messages = messages
self.n_calls = int(prior_state.get("n_calls", 0) or 0)
self.n_format_errors = int(prior_state.get("n_format_errors", 0) or 0)

if followup:
self.add_messages(
self.model.format_message(
role="user",
content="## Follow-up Instruction\n"
"Continue the previous run using the saved history and artifacts.\n\n"
f"{followup}",
extra={"interrupt_type": "ResumeFollowup"},
)
)

return self._run_loop()

def step(self) -> list[dict[str, Any]]:
return self.execute_actions(self.query())
Expand Down
Loading