chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:28:42 +08:00
commit e09edc5f16
78 changed files with 12250 additions and 0 deletions
+83
View File
@@ -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.1.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",
]
+23
View File
@@ -0,0 +1,23 @@
from __future__ import annotations
import copy
import importlib
from webwright import Agent, Environment, Model
_AGENT_MAPPING = {
"default": "webwright.agents.default.DefaultAgent",
}
def get_agent_class(spec: str) -> type[Agent]:
full_path = _AGENT_MAPPING.get(spec, spec)
module_name, class_name = full_path.rsplit(".", 1)
module = importlib.import_module(module_name)
return getattr(module, class_name)
def get_agent(model: Model, env: Environment, config: dict, *, default_type: str = "default") -> Agent:
copied = copy.deepcopy(config)
agent_class = get_agent_class(copied.pop("agent_class", default_type))
return agent_class(model, env, **copied)
+467
View File
@@ -0,0 +1,467 @@
from __future__ import annotations
import copy
import json
from pathlib import Path
from typing import Any
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
DEFAULT_SUMMARY_USER_PROMPT = """You are about to have your working context compacted to save tokens.
Write a concise but COMPLETE summary of everything relevant from the conversation above so that a fresh
agent with only this summary (plus the original system prompt and task instructions) can continue the
task without losing progress. Include:
- The original task goal and all critical points / constraints.
- The workspace directory and key file paths (plan.md, self_reflect_config.json, final_script.py, final_runs/).
- Which critical points have been satisfied, which are still open, and any known blockers.
- Key findings from prior exploration (working selectors, URLs, ARIA labels, pitfalls to avoid).
- The latest final_runs/run_<id>/ state, most recent self_reflection verdict, and the next action to take.
Write the summary as plain prose and bullet lists. Do NOT issue a new bash_command. Do NOT set done=true.
Put the entire summary in the `thought` field (or equivalent text field) and leave action fields empty."""
class AgentConfig(BaseModel):
system_template: str
instance_template: str
step_limit: int = 15
debug_log: bool = True
attach_instance_template_after_observation: bool = False
attach_plan_md_after_observation: bool = False
require_self_reflection_success: bool = False
summary_every_n_steps: int = 0
summary_user_prompt: str = DEFAULT_SUMMARY_USER_PROMPT
# Strip the ARIA snapshot payload from observation messages older than the last N
# to bound context growth in browser-driven modes. Any value <= 0 disables pruning
# (default). Opt in per config (e.g. local_browser.yaml sets this to 1).
keep_last_n_observations: int = -1
output_path: Path | None = None
def _sanitize_message_for_disk(message: dict[str, Any]) -> dict[str, Any]:
cloned = copy.deepcopy(message)
content = cloned.get("content")
if isinstance(content, list):
for part in content:
if isinstance(part, dict) and part.get("type") == "input_image":
part["image_url"] = "<omitted:data-url>"
return cloned
def _observation_for_markdown(observation: dict[str, Any], *, model_usage: dict[str, Any] | None = None) -> dict[str, Any]:
cloned = copy.deepcopy(observation)
cloned.pop("aria_snapshot", None)
if model_usage:
cloned["model_usage"] = copy.deepcopy(model_usage)
return cloned
def _action_text(action: dict[str, Any]) -> str:
return str(action.get("bash_command") or action.get("command") or action.get("python_code") or "").strip()
def _python_action_text(action: dict[str, Any]) -> str:
return str(action.get("python_code") or "").strip()
def _markdown_code_fence_language(*, bash_command_text: str, python_code_text: str) -> str:
if bash_command_text:
return "bash"
if python_code_text:
return "python"
return ""
class DefaultAgent:
def __init__(self, model: Model, env: Environment, *, config_class: type = AgentConfig, **kwargs):
self.config = config_class(**kwargs)
self.messages: list[dict[str, Any]] = []
self.model = model
self.env = env
self.extra_template_vars: dict[str, Any] = {}
self.n_calls = 0
self.n_format_errors = 0
def _debug_dir(self) -> Path | None:
if self.config.output_path is None:
return None
return self.config.output_path.parent / "debug"
def _write_debug_step_artifact(
self,
*,
step_index: int,
assistant_message: dict[str, Any],
outputs: list[dict[str, Any]] | None = None,
) -> None:
if not self.config.debug_log:
return
debug_dir = self._debug_dir()
if debug_dir is None:
return
steps_dir = debug_dir / "steps"
steps_dir.mkdir(parents=True, exist_ok=True)
extra = assistant_message.get("extra", {})
actions = extra.get("actions", [])
action_text = "\n\n".join(_action_text(action) for action in actions if _action_text(action))
python_code_text = "\n\n".join(
_python_action_text(action) for action in actions if _python_action_text(action)
)
bash_command_text = "\n\n".join(
str(action.get("bash_command", "")).strip()
for action in actions
if str(action.get("bash_command", "")).strip()
)
code_fence_language = _markdown_code_fence_language(
bash_command_text=bash_command_text,
python_code_text=python_code_text,
)
payload = {
"step": step_index,
"thought": assistant_message.get("content", ""),
"python_code": python_code_text,
"bash_command": bash_command_text,
"command_text": action_text,
"raw_response": extra.get("raw_response", {}),
"done": extra.get("done", False),
"final_response": extra.get("final_response", ""),
"outputs": outputs or [],
}
(steps_dir / f"step_{step_index:04d}.json").write_text(json.dumps(payload, indent=2))
summary_path = debug_dir / "steps.md"
with summary_path.open("a", encoding="utf-8") as handle:
handle.write(f"## Step {step_index}\n\n")
# Attach the model input only for the first step
if step_index == 1:
user_input_text = ""
for msg in reversed(self.messages):
if msg.get("role") == "user":
content = msg.get("content", "")
if isinstance(content, list):
# Multi-part message: join text parts
parts = [p.get("text", "") for p in content if isinstance(p, dict) and p.get("type") in ("text", "input_text")]
user_input_text = "\n".join(p for p in parts if p)
else:
user_input_text = str(content)
break
if user_input_text:
handle.write("### Model Input\n\n")
handle.write(f"{user_input_text}\n\n")
handle.write("### Thought\n\n")
handle.write(f"{payload['thought']}\n\n")
handle.write("### Generated Code\n\n")
handle.write(f"```{code_fence_language}\n")
handle.write(f"{payload['command_text']}\n")
handle.write("```\n\n")
if outputs:
observation = outputs[0].get("observation", {})
markdown_observation = _observation_for_markdown(
observation,
model_usage=extra.get("usage"),
)
handle.write("### Observation\n\n")
handle.write("```json\n")
handle.write(f"{json.dumps(markdown_observation, indent=2)}\n")
handle.write("```\n\n")
def get_template_vars(self, **kwargs) -> dict[str, Any]:
return recursive_merge(
self.config.model_dump(),
self.env.get_template_vars(),
self.model.get_template_vars(),
{"n_model_calls": self.n_calls},
self.extra_template_vars,
kwargs,
)
def _render_template(self, template: str) -> str:
return Template(template, undefined=StrictUndefined).render(**self.get_template_vars())
def _plan_md_message(self) -> dict[str, Any] | None:
workspace_dir = self.get_template_vars().get("workspace_dir")
if not workspace_dir:
return None
plan_path = Path(workspace_dir) / "plan.md"
if not plan_path.exists() or not plan_path.is_file():
return None
plan_text = plan_path.read_text(encoding="utf-8").strip()
if not plan_text:
return None
return self.model.format_message(role="user", content=f"Current plan.md:\n\n{plan_text}")
def _self_reflection_gate_error(self) -> str | None:
"""Return an error string if done=true should be blocked pending judge success."""
if not self.config.require_self_reflection_success:
return None
return self._tool_gate_error()
def _tool_gate_error(self) -> str | None:
"""Require final_runs/run_<latest>/self_reflect_result.json with predicted_label == 1."""
workspace_dir = self.get_template_vars().get("workspace_dir")
if not workspace_dir:
return (
"Completion blocked: require_self_reflection_success is enabled but no workspace_dir is "
"available. Cannot locate final_runs/run_<id>/self_reflect_result.json. Do not set done=true."
)
final_runs_dir = Path(workspace_dir) / "final_runs"
if not final_runs_dir.is_dir():
return (
"Completion blocked: no final_runs/ directory exists yet. You must run final_script.py "
"in a final_runs/run_<id>/ folder and then run "
"`python -m webwright.tools.self_reflection --config self_reflect_config.json "
"--workspace-dir \"{0}\" --output final_runs/run_<id>/self_reflect_result.json` with "
"predicted_label == 1 before setting done=true."
).format(workspace_dir)
run_dirs: list[tuple[int, Path]] = []
for entry in final_runs_dir.iterdir():
if not entry.is_dir() or not entry.name.startswith("run_"):
continue
suffix = entry.name[len("run_"):]
try:
run_id = int(suffix)
except ValueError:
continue
run_dirs.append((run_id, entry))
if not run_dirs:
return (
"Completion blocked: final_runs/ contains no run_<id>/ folders. Create "
"final_runs/run_<id>/, execute final_script.py there, then run self_reflection and "
"only set done=true after self_reflect_result.json reports predicted_label == 1."
)
run_dirs.sort(key=lambda item: item[0])
latest_run_id, latest_run_dir = run_dirs[-1]
judge_path = latest_run_dir / "self_reflect_result.json"
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"--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."
)
try:
judge_data = json.loads(judge_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
return (
f"Completion blocked: could not parse {judge_path}: {exc}. Re-run self_reflection "
f"against run_{latest_run_id} and only set done=true after predicted_label == 1."
)
predicted_label = judge_data.get("predicted_label")
if predicted_label != 1:
return (
f"Completion blocked: {judge_path} has predicted_label={predicted_label!r} "
f"(expected 1). Diagnose the failure from self_reflect_result.json, fix final_script.py, "
f"re-run it in a new final_runs/run_{latest_run_id + 1}/ folder, and re-run "
f"self_reflection. Only set done=true after self_reflection exits 0 with "
f"predicted_label == 1."
)
return None
def add_messages(self, *messages: dict[str, Any]) -> list[dict[str, Any]]:
self.messages.extend(messages)
self._prune_old_observation_aria_snapshots()
return list(messages)
def _prune_old_observation_aria_snapshots(self) -> None:
n = self.config.keep_last_n_observations
if n <= 0:
return
obs_indices = [
i for i, m in enumerate(self.messages)
if m.get("extra", {}).get("observation")
]
if len(obs_indices) <= n:
return
placeholder = "(ARIA snapshot pruned; see most recent observation)"
for idx in obs_indices[:-n]:
msg = self.messages[idx]
obs = msg["extra"]["observation"]
aria = obs.get("aria_snapshot", "")
if not aria:
continue
content = msg.get("content")
if isinstance(content, list):
for part in content:
if isinstance(part, dict) and part.get("type") in ("text", "input_text"):
text = part.get("text", "")
if aria in text:
part["text"] = text.replace(aria, placeholder)
elif isinstance(content, str) and aria in content:
msg["content"] = content.replace(aria, placeholder)
obs["aria_snapshot"] = ""
def _compact_history(self) -> None:
"""Summarize the running transcript via an LLM call and reset messages to [system, summary].
Preserves the original system message. Replaces every non-system message with a single user
message containing the summary. The summarization call is made with the current messages
plus a user prompt instructing the model to produce a complete compact summary.
"""
if not self.messages:
return
system_message = next((m for m in self.messages if m.get("role") == "system"), None)
if system_message is None:
return
summary_request = self.model.format_message(
role="user",
content=self.config.summary_user_prompt,
extra={"interrupt_type": "HistoryCompactionRequest"},
)
summary_messages = list(self.messages) + [summary_request]
try:
response = self.model.query(summary_messages)
except Exception: # noqa: BLE001 - never fail the run due to compaction
return
summary_text = (response.get("content") or "").strip()
if not summary_text:
extra = response.get("extra", {})
summary_text = (extra.get("final_response") or "").strip() or "(empty summary)"
summary_message = self.model.format_message(
role="user",
content=(
"## Compacted History Summary\n"
f"(context was compacted after step {self.n_calls}; earlier turns have been replaced "
"by the summary below)\n\n"
f"{summary_text}\n\n## End of Compacted Summary"
),
extra={"interrupt_type": "HistoryCompactionSummary"},
)
self.messages = [system_message, summary_message]
def run(self, task: str = "", **kwargs) -> dict[str, Any]:
self.extra_template_vars |= {"task": task, **kwargs}
self.messages = []
self.n_calls = 0
self.n_format_errors = 0
self.add_messages(
self.model.format_message(role="system", content=self._render_template(self.config.system_template)),
self.model.format_message(role="user", content=self._render_template(self.config.instance_template)),
)
if self.extra_template_vars.get("explore_history"):
self.add_messages(
self.model.format_message(
role="user",
content="## Previous Explore History\n"
"Below is the message log from a prior live-browser exploration of this exact task.\n"
"Use it to understand the site layout, available controls, aria snapshots, and pitfalls.\n"
"Do NOT repeat failed approaches. Build on what was learned.\n\n"
+ self.extra_template_vars["explore_history"]
+ "\n\n## End of Explore History",
),
)
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 step(self) -> list[dict[str, Any]]:
return self.execute_actions(self.query())
def query(self) -> dict[str, Any]:
if 0 < self.config.step_limit <= self.n_calls:
raise LimitsExceeded(
self.model.format_message(
role="exit",
content="Step limit exceeded.",
extra={"exit_status": "LimitsExceeded", "submission": ""},
)
)
message = self.model.query(self.messages)
self.n_calls += 1
self.add_messages(message)
return message
def execute_actions(self, message: dict[str, Any]) -> list[dict[str, Any]]:
extra = message.get("extra", {})
if extra.get("done"):
gate_error = self._self_reflection_gate_error()
if gate_error is not None:
extra["done"] = False
return self.add_messages(
self.model.format_message(
role="user",
content=gate_error,
extra={"interrupt_type": "SelfReflectionGate"},
)
)
self._write_debug_step_artifact(step_index=self.n_calls, assistant_message=message, outputs=[])
return self.add_messages(
self.model.format_message(
role="exit",
content=extra.get("final_response", "Task completed."),
extra={
"exit_status": "Submitted",
"submission": extra.get("final_response", ""),
"final_response": extra.get("final_response", ""),
},
)
)
outputs = [self.env.execute(action) for action in extra.get("actions", [])]
self._write_debug_step_artifact(step_index=self.n_calls, assistant_message=message, outputs=outputs)
observation_messages = self.model.format_observation_messages(message, outputs, self.get_template_vars())
if self.config.attach_instance_template_after_observation:
observation_messages.append(
self.model.format_message(role="user", content=self._render_template(self.config.instance_template))
)
if self.config.attach_plan_md_after_observation:
plan_message = self._plan_md_message()
if plan_message is not None:
observation_messages.append(plan_message)
return self.add_messages(*observation_messages)
def serialize(self, *extra_dicts) -> dict[str, Any]:
last_message = self.messages[-1] if self.messages else {}
last_extra = last_message.get("extra", {})
return recursive_merge(
{
"info": {
"config": {
"agent": self.config.model_dump(mode="json"),
"agent_type": f"{self.__class__.__module__}.{self.__class__.__name__}",
},
"mini_version": __version__,
"exit_status": last_extra.get("exit_status", ""),
"submission": last_extra.get("submission", ""),
"api_calls": self.n_calls,
"format_errors": self.n_format_errors,
},
"messages": [_sanitize_message_for_disk(message) for message in self.messages],
"trajectory_format": "webwright-0.1",
},
self.model.serialize(),
self.env.serialize(),
*extra_dicts,
)
def save(self, path: Path | None, *extra_dicts) -> dict[str, Any]:
data = self.serialize(*extra_dicts)
if path is not None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(data, indent=2))
return data
+85
View File
@@ -0,0 +1,85 @@
from __future__ import annotations
import json
import shutil
from pathlib import Path
from typing import Any
import yaml
from webwright import package_dir
builtin_config_dir = package_dir / "config"
def _nest_key_value(key: str, value: Any) -> dict[str, Any]:
parts = key.split(".")
nested: dict[str, Any] = value
for part in reversed(parts):
nested = {part: nested}
return nested
def _resolve_config_path(spec: str) -> Path | None:
path = Path(spec).expanduser()
if path.exists():
return path
builtin_path = builtin_config_dir / spec
if builtin_path.exists():
return builtin_path
return None
def get_config_from_spec(spec: str) -> dict[str, Any]:
resolved_path = _resolve_config_path(spec)
if resolved_path is not None:
loaded = yaml.safe_load(resolved_path.read_text())
return loaded or {}
if "=" not in spec:
raise ValueError(f"Unsupported config spec: {spec!r}")
key, raw_value = spec.split("=", 1)
return _nest_key_value(key, yaml.safe_load(raw_value))
def snapshot_config_specs(
config_spec: list[str],
output_dir: str | Path,
*,
merged_config: dict[str, Any] | None = None,
) -> Path:
snapshot_dir = Path(output_dir).expanduser() / "config_snapshot"
snapshot_dir.mkdir(parents=True, exist_ok=True)
manifest: list[dict[str, Any]] = []
for index, spec in enumerate(config_spec):
entry: dict[str, Any] = {
"index": index,
"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)
entry.update(
{
"kind": "file",
"resolved_path": str(resolved_path.resolve()),
"saved_copy": str(saved_copy),
}
)
manifest.append(entry)
(snapshot_dir / "config_spec_manifest.json").write_text(
json.dumps(manifest, indent=2),
encoding="utf-8",
)
if merged_config is not None:
(snapshot_dir / "merged_config.yaml").write_text(
yaml.safe_dump(merged_config, sort_keys=False),
encoding="utf-8",
)
return snapshot_dir
+410
View File
@@ -0,0 +1,410 @@
# Base agent config — model-agnostic.
#
# This file contains every setting that is shared between the OpenAI and
# Anthropic variants. Stack a model modifier on top via repeated -c flags:
#
# source ~/cred.sh
# python -m 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
#
# Or for Claude:
# -c base.yaml -c model_claude.yaml
#
# Required env (provided by ~/cred.sh):
# - OPENAI_API_KEY (only when the configured agent or tool model_class is openai)
# - ANTHROPIC_API_KEY (only when stacking model_claude.yaml)
# - BROWSERBASE_API_KEY + BROWSERBASE_PROJECT_ID (only when browser_mode=browserbase)
model:
# model_class / model_name / endpoint come from the model modifier yaml.
request_timeout_seconds: 120
max_output_tokens: 4000
attach_observation_screenshot: false
observation_template: |
Observation:
Status: {{ 'ok' if observation.success else 'error' }}
Workspace: {{ observation.workspace_dir }}
Working directory: {{ observation.cwd }}
Command: {{ observation.command }}
Return code: {{ observation.returncode }}
{% if observation.exception %}Exception:
{{ observation.exception }}
{% endif %}{% if observation.command_output %}Command output:
{{ observation.command_output }}
{% endif %}{% if observation.final_script_path %}final_script.py: {{ observation.final_script_path }}
{% endif %}
format_error_template: |
Format error:
{{ error }}
Please respond with a single strict JSON object (no prose, no code fences) containing exactly these fields:
{
"thought": "<short reasoning about the next step>",
"bash_command": "<exactly one shell command, or empty string when declaring done>",
"done": false,
"final_response": ""
}
environment:
environment_class: local_workspace
start_url:
output_dir: outputs/default
command_timeout_seconds: 240
shell: /bin/bash
# Path to a shell file that exports credentials (BROWSERBASE_API_KEY,
# BROWSERBASE_PROJECT_ID, ANTHROPIC_API_KEY, OPENAI_API_KEY, ...). Leave
# empty to read these from the parent process environment instead.
credentials_file:
# Set to "local" to make the agent's generated scripts launch a local
# Playwright browser; "browserbase" uses a Browserbase cloud session.
browser_mode: local
task_metadata_filename: task.json
final_script_name: final_script.py
output_truncation_chars: 24000
final_script_preview_chars: 4000
recent_files_limit: 40
env:
PAGER: cat
MANPAGER: cat
LESS: -R
PIP_PROGRESS_BAR: 'off'
TQDM_DISABLE: '1'
run:
# Optional default values that can be overridden via the CLI.
task:
task_id:
start_url:
agent:
agent_class: default
debug_log: true
output_path: outputs/default/trajectory.json
step_limit: 100
require_self_reflection_success: true
summary_every_n_steps: 20
system_template: |
You are a web agent operating through a local terminal + workspace harness.
Your response must be a single strict JSON object (no prose, no markdown, no code fences) with exactly these fields:
{
"thought": "<your observation, reasoning, and next step>",
"bash_command": "<exactly one shell command, or empty string when declaring done>",
"done": false,
"final_response": ""
}
Emit exactly ONE JSON object per turn. Never output multiple JSON objects, never wrap the object in prose or code fences.
Global constraints:
- 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.
- There is NO persistent browser state. Every Playwright run must create a fresh browser session, navigate from scratch, and reconstruct state via code.
- 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.
## Browser Mode
The harness exposes `BROWSER_MODE` to your scripts (value: `browserbase` or `local`).
- When `BROWSER_MODE=browserbase` (default): create a Browserbase cloud session via the
`BROWSERBASE_API_KEY` / `BROWSERBASE_PROJECT_ID` env vars and connect over CDP.
- When `BROWSER_MODE=local`: launch a local Playwright Chromium browser
(`playwright.chromium.launch(...)`) instead. No external credentials required.
## Playwright Examples
Example response (rendered for readability — in practice you emit a single JSON object on one logical message):
```
{
"thought": "Run a Playwright script inside one bash command, capture screenshots, and print aria evidence for the next step.",
"bash_command": "python - <<'PY'
import asyncio
import os
from pathlib import Path
from playwright.async_api import async_playwright
WORKSPACE = Path(os.environ[\"WORKSPACE_DIR\"])
SCREENSHOTS = WORKSPACE / \"screenshots\"
SCREENSHOTS.mkdir(parents=True, exist_ok=True)
async def main():
async with async_playwright() as playwright:
browser = await playwright.chromium.launch(headless=True)
context = await browser.new_context(viewport={\"width\": 1280, \"height\": 1800})
page = await context.new_page()
await page.goto(\"{{ start_url }}\", wait_until=\"domcontentloaded\")
await page.screenshot(path=str(SCREENSHOTS / \"final_execution_1_open_start_page.png\"))
print(\"URL:\", page.url)
print(\"TITLE:\", await page.title())
# Expand the filter section
await page.get_by_role(\"button\", name=\"xxx (name from the aria tree)\").click()
await asyncio.sleep(1)
snapshot = await page.get_by_role(\"button\", name=\"xxx (name from the aria tree)\").first.locator(\"..\").aria_snapshot()
print(snapshot)
# Apply a filter
await page.get_by_role(\"checkbox\", name=\"yyy (name from the aria tree)\").check()
await asyncio.sleep(1)
await page.screenshot(path=str(SCREENSHOTS / \"final_execution_2_apply_yyy_filter.png\"))
print(\"ARIA:\", await page.locator(\"body\").aria_snapshot())
await browser.close()
asyncio.run(main())
PY",
"done": false,
"final_response": ""
}
```
(The `bash_command` value above is shown with literal newlines for readability; in an actual JSON response those newlines must be encoded as `\n` so the object remains valid JSON.)
## Helpful Command Patterns
- Inspect a script:
```
sed -n '1,220p' final_script.py
```
- Prefer incremental edits once the file exists, keeping patch, execution, and verification in one `<bash_command>`.
- Inspect the latest run artifacts:
```
ls -R final_runs && sed -n '1,200p' final_runs/run_003/final_script_log.txt
```
- 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?"
```
- 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?"
```
## Rules
- **Always Avoid taking full page screenshot using Playwright, use viewport 1280x1800 ** (exploration, debugging, and final-run screenshots alike). Never do `page.screenshot(full_page=True)`.
- After a file already exists, prefer incremental edits over rewriting the whole file.
- Use stable selectors and current-run evidence.
- If the site exposes a dedicated control for a requirement, you must use that control. Search terms alone do not satisfy a filter, sort, style, or attribute requirement.
- Ranking language such as `best-selling`, `most reviewed`, `highest-rated`, `lowest`, and `cheapest` must be grounded in the site's actual metric or control.
- If a selected state becomes hidden after a drawer, accordion, modal, or dropdown closes, reopen that control or capture a visible chip/summary before treating the state as verified.
- Treat numeric, date, quantity, and unit constraints as exact. Wider buckets or broader defaults are failures unless the site offers no exacter control.
- If the task asks for a final datum (code, price, quote, review, winner, benefit list), state that datum explicitly in `<final_response>`.
- For blocker claims (Access Denied, unavailable controls), only stop after repeated evidence from the actual site UI.
- Make sure to save as more critical points as possible, especially those that show the application of required filters or constraints, and the final result display. The more evidence you save, the higher chance the judge will verify the successful completion of the task.
It also needs to save the final response of the task in `final_runs/run_<id>/final_script_log.txt` in the end.
## Task Reflection Tool
Do NOT hand-roll a `judge.py` that loops `image_qa`. Use the built-in
`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
`Reasoning: <text>` from each response and retries on parse failure.
2. Stage 2 — drop every per-image `Reasoning` into the final user prompt template
via `{image_reasonings}`, inject the latest run's `final_script_log.txt` via
`{action_history_log}`, attach EVERY screenshot (no filtering), and make ONE
aggregated call that must end with `Status: success` or `Status: failure`.
Your job is to AUTHOR the four prompts ONCE and reuse them for every
self_reflection invocation in this run. The tool handles parallel per-image
scoring, final aggregation, and verdict parsing.
**CLI interface:**
```
python -m webwright.tools.self_reflection \
--config {{ workspace_dir }}/self_reflect_config.json \
--workspace-dir "{{ workspace_dir }}" \
--output {{ workspace_dir }}/final_runs/run_<id>/self_reflect_result.json
```
Exit code is 0 on PASS, 1 on FAIL / unparsed. The `--output` file is a JSON document
containing per-image records (`Score`, `Reasoning`, `Response`), the image path
list, the full final-stage prompt, the model's final response, and
`predicted_label` (1=success, 0=failure, null=unparsed). You MUST run self_reflection before
declaring done.
**self_reflect_config.json schema (authored once, prompts only):**
```json
{
"image_judge_system_prompt": "...see below...",
"image_judge_user_prompt": "...see below...",
"final_verdict_system_prompt": "...see below...",
"final_verdict_user_prompt": "...{action_history_log}...{image_reasonings}..."
}
```
Any `<field>_file` variant (e.g. `image_judge_user_prompt_file`) may point to a
text file on disk instead of inlining the prompt — useful when prompts contain
many curly braces or embedded JSON.
**Required prompt content (you MUST include all of this in the JSON you write):**
- `image_judge_system_prompt`: instruct the model to act as a harsh evaluator and
return ONLY two labelled lines:
```
Reasoning: <1-2 sentences describing what the screenshot shows and which critical
points it provides evidence for or against>
Score: <integer 1-5, where 5 = this screenshot clearly evidences a critical point
and 1 = this screenshot contains no relevant evidence>
```
Do NOT ask for JSON. The tool parses the labelled lines directly.
- `image_judge_user_prompt`: embed the task description and the full numbered
critical-point list from `plan.md`. Tell the model to consider ALL critical
points when scoring this single image and to be harsh when evidence is
ambiguous or partially occluded.
- `final_verdict_system_prompt`: instruct the model to be a harsh aggregated judge
and to end its reply with EXACTLY `Status: success` or `Status: failure` on its
own line. Require a `Thoughts:` block before that line that evaluates every
critical point. The tool extracts the verdict from the trailing `Status:` line.
- `final_verdict_user_prompt`: embed the task description and the numbered
critical-point list, and include the literal tokens `{action_history_log}` and
`{image_reasonings}` where you want the final run's `final_script_log.txt`
content and the per-image reasonings injected. Do NOT hard-code a specific
run's `final_script_log.txt`. The tool renders those tokens with Python
`str.format`, so any other literal curly braces in this string MUST be doubled
(write {% raw %}`{{`{% endraw %} and {% raw %}`}}`{% endraw %} in the JSON to
emit a literal `{` or `}`).
**Verdict extraction:** the tool parses `Status: success|failure` from the last
line of the final-stage response. Missing or malformed `Status:` counts as FAIL
(exit code 1). Keep the verdict line clean.
**Robustness:** per-image parse failures are retried up to 3 times and then
recorded with `Score: 0, ParseFailed: true` without failing the whole run.
Transient model-API HTTP errors are retried with exponential backoff.
## Completion Gate
Set `"done": true` ONLY if ALL of the following are true:
1. `plan.md` exists and every critical point is enumerated as a checklist item.
2. `self_reflect_config.json` exists at the workspace root with all four prompts populated
for `self_reflection`.
3. `final_script.py` was executed successfully from scratch inside a
`final_runs/run_<id>/` folder, producing `final_script_log.txt` and all
critical-point screenshots.
4. `python -m webwright.tools.self_reflection --config self_reflect_config.json
--workspace-dir "{{ workspace_dir }}" --output final_runs/run_<id>/self_reflect_result.json`
was executed against that run, exited 0, and wrote
`final_runs/run_<id>/self_reflect_result.json` with `"predicted_label": 1`.
5. You have run `ls -R final_runs/run_<id>`,
`ls -R final_runs/run_<id>/screenshots`, and
`cat final_runs/run_<id>/final_script_log.txt` to confirm the artifacts and
logs are in place.
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, if the
script failed to run, or if the checklist in `plan.md` is incomplete. If
`self_reflection` fails, diagnose the specific issue (wrong filter value, missing
control, missing confirmation, missing screenshot, etc.), fix `final_script.py`,
re-run it in a new `final_runs/run_<id+1>/` folder, and re-run `self_reflection`
against the new run. Do NOT edit `self_reflect_config.json` between attempts unless a
prompt itself is objectively wrong.
instance_template: |
Task: {{ task }}
{% if task_id %}Task ID: {{ task_id }}
{% endif %}{% if start_url %}Start URL: {{ start_url }}
{% endif %}Workspace root: {{ workspace_dir }}
Task metadata JSON: {{ task_metadata_path }}
Required final script path: {{ final_script_path }}
<instructions>
# Task Instructions
You're solving a user-specified web task through a stateless local terminal + workspace harness.
<IMPORTANT>
This is an interactive process where you reason, execute exactly one bash command, inspect the result, and then produce your next command. You have a single session — context is preserved across all steps, so there is no need to reload state between turns.
</IMPORTANT>
## Harness Rules
- Work only inside `{{ workspace_dir }}`.
- Keep generated code, screenshots, logs, scratch files, and notes **only** in `{{ workspace_dir }}`.
- The required final artifact is `{{ final_script_path }}`.
- Create `final_runs/run_<id>/` 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.
- The browser mode is `{{ browser_mode }}`. Match your generated scripts to that mode (Browserbase cloud session vs. local Playwright launch).
## Web Task Rules
- Do not guess UI interactions. Use printed evidence from the current run.
- Some required filters or options may be hidden behind expandable sections, drawers, dropdowns, or mobile filter panels. Open those controls and inspect again before deciding a filter is unavailable.
- A broad search query does not satisfy explicit filter constraints when the site exposes dedicated controls.
- Save final verification screenshots inside the active `final_runs/run_<id>/screenshots/` folder.
- Print concise ARIA snapshots, URLs, titles, visible labels, and any extracted state needed for the next step.
## Task Success Criteria
1. Filtered results must be displayed correctly. Missing selection, missing confirmation, or no visible effect = failure.
2. Specific filter conditions ("best," "highest," "cheapest," "latest," "lowest," etc.) must be applied using the filter/sort function.
3. Requirements must be applied through filters, not embedded in a broad search query.
4. Numeric ranges (money, years, beds/baths) must exactly match the task requirement — no broadening or narrowing.
5. Tasks requiring a submission action or results display need that action to be taken.
6. Empty results are OK if the correct action was performed.
7. All explicit filters must use site controls when those controls exist.
8. If a site control does not exist, verify the constraint directly from page content.
## 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"`
- Use multiple `--image` flags for combined visual verification.
- image_qa returns JSON with `answer`, `evidence`, `unknown`, and `confidence` fields.
## Recommended Workflow
1. **Planning**: Parse the task into a list of critical points — every explicit constraint, filter, sort, selection, or datum that must be satisfied. Write them to `plan.md` as a checklist:
```
# Critical Points
- [ ] CP1: <description of constraint/filter/action>
- [ ] CP2: <description of constraint/filter/action>
...
```
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.
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_<id>/` 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_<id>/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_<id+1>/` folder, and re-invoke `self_reflection` against the new run. Do NOT edit `self_reflect_config.json` between attempts.
6. **Declare done**: 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.
## Final Script Instrumentation
`final_script.py` must:
- be stored as `final_runs/run_<id>/final_script.py`
- save critical-point screenshots as `final_runs/run_<id>/screenshots/final_execution_<step_number>_<action>.png`
- create or reset `final_runs/run_<id>/final_script_log.txt` at the start of each clean run
- write `step <step_number> action: <reason and action description>` to the log for every constraint-relevant interaction
- each screenshot should correspond to a critical point from `plan.md` so that `self_reflection` can verify it
This instrumentation is mandatory because both `self_reflection` and the external judge evaluate those screenshots and action logs.
## Completion Gate
Set `"done": true` ONLY if ALL of the following are true:
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_<id>/` folder.
4. `python -m webwright.tools.self_reflection --config self_reflect_config.json --workspace-dir "{{ workspace_dir }}" --output final_runs/run_<id>/self_reflect_result.json` was executed against that run, exited 0, and wrote `final_runs/run_<id>/self_reflect_result.json` with `"predicted_label": 1`.
5. `ls -R final_runs/run_<id>` and `cat final_runs/run_<id>/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_<id>/`.
</instructions>
+404
View File
@@ -0,0 +1,404 @@
# Crafted CLI prompts modifier — system + instance templates only.
#
# Stack on top of base.yaml + a model modifier. This file overrides only the
# agent prompts so the final deliverable `final_script.py` must be a CLI tool
# that wraps a reusable function exposing the task's parameterizable
# requirements as command-line arguments (e.g. Make/Model/min_year/max_year/
# color for a car-search task).
#
# Usage:
# source ~/cred.sh
# python -m webwright.run.cli \
# -c base.yaml -c model_openai.yaml -c crafted_cli.yaml \
# -t "<task description>" \
# --start-url <start url> \
# --task-id <id> \
# -o outputs/default
agent:
system_template: |
You are a benchmark-oriented Online-Mind2Web agent operating through a local terminal + workspace harness.
Your response must be a single strict JSON object (no prose, no markdown, no code fences) with exactly these fields:
{
"thought": "<your observation, reasoning, and next step>",
"bash_command": "<exactly one shell command, or empty string when declaring done>",
"done": false,
"final_response": ""
}
Emit exactly ONE JSON object per turn. Never output multiple JSON objects, never wrap the object in prose or code fences.
Global constraints:
- 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.
- There is NO persistent browser state. Every Playwright run must create a fresh Browserbase cloud session, navigate from scratch, and reconstruct state via code.
- 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.
## Final-Script Shape (CLI Tool, MANDATORY)
In this benchmark variant, `final_script.py` is NOT a one-shot script for the
literal task values. It must be a **reusable CLI tool** that generalises the
task to any comparable input:
1. Identify every requirement / filter / critical point in the task that can
reasonably be parameterised (e.g. Make, Model, min_year, max_year, color
for "Search for a red Toyota Corolla from 2018 to 2023 on CarMax"). List
these in `plan.md` under a `# Parameters` section BEFORE writing the
script, noting which task phrase each parameter comes from and its type.
2. Expose a single reusable Python function in `final_script.py` whose name
and signature reflect the task domain (e.g.
`def search_cars(Make, Model, min_year, max_year, color): ...`).
Requirements that are truly fixed for the site (start URL, selector
strategy, site name) stay hard-coded; everything the user could plausibly
vary becomes a function argument.
3. Write a complete Google-style docstring for that function. It MUST have
an `Args:` block with one entry per argument that documents:
- the argument name and type,
- what it represents in the task domain,
- accepted value format / units / allowed values,
- the default (if any).
Also include a short summary line and a `Returns:` description.
4. Wrap the function behind an `argparse`-based CLI in `if __name__ == "__main__":`.
Every function argument MUST have a matching `--<arg>` flag with `type=`,
`help=` (copied from the docstring), and a sensible default equal to the
concrete task value so that running `python final_script.py` with no
arguments reproduces the original task.
5. The CLI must still perform the full end-to-end run (Browserbase session,
screenshots, `final_script_log.txt`) using the provided arguments, and
the action log must echo the resolved parameter values on a line like
`step 0 params: Make=Toyota Model=Corolla min_year=2018 ...` so the judge
can see the effective inputs.
6. Keep the CLI side-effect-free at import time: the reusable function must
be importable from another Python process without triggering a run.
## Playwright Examples
Example response (rendered for readability — in practice you emit a single JSON object on one logical message):
```
{
"thought": "Run a Playwright script inside one bash command, capture screenshots, and print aria evidence for the next step.",
"bash_command": "python - <<'PY'
import asyncio
import os
from pathlib import Path
import httpx
from playwright.async_api import async_playwright
WORKSPACE = Path(os.environ["WORKSPACE_DIR"])
SCREENSHOTS = WORKSPACE / "screenshots"
SCREENSHOTS.mkdir(parents=True, exist_ok=True)
async def create_browserbase_session():
async with httpx.AsyncClient(timeout=30) as client:
response = await client.post(
"https://api.browserbase.com/v1/sessions",
headers={
"x-bb-api-key": os.environ["BROWSERBASE_API_KEY"],
"Content-Type": "application/json",
},
json={
"projectId": os.environ["BROWSERBASE_PROJECT_ID"],
"proxies": True,
"browserSettings": {"advancedStealth": True},
"timeout": 720,
},
)
response.raise_for_status()
return response.json()
async def main():
session = await create_browserbase_session()
async with async_playwright() as playwright:
browser = await playwright.chromium.connect_over_cdp(session["connectUrl"])
context = browser.contexts[0] if browser.contexts else await browser.new_context()
page = context.pages[0] if context.pages else await context.new_page()
page.set_viewport_size({"width": 1280, "height": 1800}) # use 1280x1800 viewport for better desktop site rendering and more visible content in screenshots
await page.goto("{{ start_url }}", wait_until="domcontentloaded")
await page.screenshot(path=str(SCREENSHOTS / "final_execution_1_open_start_page.png"))
print("URL:", page.url)
print("TITLE:", await page.title())
# Expand the filter section
await page.get_by_role("button", name="xxx (name from the aria tree)").click()
await asyncio.sleep(1)
snapshot = await page.get_by_role("button", name="xxx (name from the aria tree)").first.locator("..").aria_snapshot()
print(snapshot)
# Apply a filter
await page.get_by_role("checkbox", name="yyy (name from the aria tree)").check()
await asyncio.sleep(1)
await page.screenshot(path=str(SCREENSHOTS / "final_execution_2_apply_yyy_filter.png"))
print("ARIA:", await page.locator("body").aria_snapshot())
await browser.close()
asyncio.run(main())
PY",
"done": false,
"final_response": ""
}
```
(The `bash_command` value above is shown with literal newlines for readability; in an actual JSON response those newlines must be encoded as `\n` so the object remains valid JSON.)
## Helpful Command Patterns
- Inspect a script:
```
sed -n '1,220p' final_script.py
```
- Prefer incremental edits once the file exists, keeping patch, execution, and verification in one `<bash_command>`.
- Inspect the latest run artifacts:
```
ls -R final_runs && sed -n '1,200p' final_runs/run_003/final_script_log.txt
```
- 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?"
```
- 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?"
```
## Rules
- After a file already exists, prefer incremental edits over rewriting the whole file.
- Use stable selectors and current-run evidence.
- If the site exposes a dedicated control for a requirement, you must use that control. Search terms alone do not satisfy a filter, sort, style, or attribute requirement.
- Ranking language such as `best-selling`, `most reviewed`, `highest-rated`, `lowest`, and `cheapest` must be grounded in the site's actual metric or control.
- If a selected state becomes hidden after a drawer, accordion, modal, or dropdown closes, reopen that control or capture a visible chip/summary before treating the state as verified.
- Treat numeric, date, quantity, and unit constraints as exact. Wider buckets or broader defaults are failures unless the site offers no exacter control.
- If the task asks for a final datum (code, price, quote, review, winner, benefit list), state that datum explicitly in `<final_response>`.
- For blocker claims (Access Denied, unavailable controls), only stop after repeated evidence from the actual site UI.
- Make sure to save as more critical points as possible, especially those that show the application of required filters or constraints, and the final result display. The more evidence you save, the higher chance the judge will verify the successful completion of the task.
It also needs to save the final response of the task in `final_runs/run_<id>/final_script_log.txt` in the end.
## Task Reflection Tool
Do NOT hand-roll a `judge.py` that loops `image_qa`. Use the built-in
`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
`Reasoning: <text>` from each response and retries on parse failure.
2. Stage 2 — drop every per-image `Reasoning` into the final user prompt template
via `{image_reasonings}`, inject the latest run's `final_script_log.txt` via
`{action_history_log}`, attach EVERY screenshot (no filtering), and make ONE
aggregated call that must end with `Status: success` or `Status: failure`.
Your job is to AUTHOR the four prompts ONCE and reuse them for every
self_reflection invocation in this run. The tool handles parallel per-image
scoring, final aggregation, and verdict parsing.
**CLI interface:**
```
python -m webwright.tools.self_reflection \
--config {{ workspace_dir }}/judge_config.json \
--workspace-dir "{{ workspace_dir }}" \
--output {{ workspace_dir }}/final_runs/run_<id>/judge_result.json
```
Exit code is 0 on PASS, 1 on FAIL / unparsed. The `--output` file is a JSON document
containing per-image records (`Score`, `Reasoning`, `Response`), the image path
list, the full final-stage prompt, the model's final response, and
`predicted_label` (1=success, 0=failure, null=unparsed). You MUST run self_reflection before
declaring done.
**judge_config.json schema (authored once, prompts only):**
```json
{
"image_judge_system_prompt": "...see below...",
"image_judge_user_prompt": "...see below...",
"final_verdict_system_prompt": "...see below...",
"final_verdict_user_prompt": "...{action_history_log}...{image_reasonings}..."
}
```
Any `<field>_file` variant (e.g. `image_judge_user_prompt_file`) may point to a
text file on disk instead of inlining the prompt — useful when prompts contain
many curly braces or embedded JSON.
**Required prompt content (you MUST include all of this in the JSON you write):**
- `image_judge_system_prompt`: instruct the model to act as a harsh evaluator and
return ONLY two labelled lines:
```
Reasoning: <1-2 sentences describing what the screenshot shows and which critical
points it provides evidence for or against>
Score: <integer 1-5, where 5 = this screenshot clearly evidences a critical point
and 1 = this screenshot contains no relevant evidence>
```
Do NOT ask for JSON. The tool parses the labelled lines directly.
- `image_judge_user_prompt`: embed the task description and the full numbered
critical-point list from `plan.md`. Tell the model to consider ALL critical
points when scoring this single image and to be harsh when evidence is
ambiguous or partially occluded.
- `final_verdict_system_prompt`: instruct the model to be a harsh aggregated judge
and to end its reply with EXACTLY `Status: success` or `Status: failure` on its
own line. Require a `Thoughts:` block before that line that evaluates every
critical point. The tool extracts the verdict from the trailing `Status:` line.
- `final_verdict_user_prompt`: embed the task description and the numbered
critical-point list, and include the literal tokens `{action_history_log}` and
`{image_reasonings}` where you want the final run's `final_script_log.txt`
content and the per-image reasonings injected. Do NOT hard-code a specific
run's `final_script_log.txt`. The tool renders those tokens with Python
`str.format`, so any other literal curly braces in this string MUST be doubled
(write {% raw %}`{{`{% endraw %} and {% raw %}`}}`{% endraw %} in the JSON to
emit a literal `{` or `}`).
**Verdict extraction:** the tool parses `Status: success|failure` from the last
line of the final-stage response. Missing or malformed `Status:` counts as FAIL
(exit code 1). Keep the verdict line clean.
**Robustness:** per-image parse failures are retried up to 3 times and then
recorded with `Score: 0, ParseFailed: true` without failing the whole run. Gateway
HTTP errors are retried with exponential backoff.
## Completion Gate
Set `"done": true` ONLY if ALL of the following are true:
1. `plan.md` exists and every critical point is enumerated as a checklist item,
AND a `# Parameters` section lists every parameterisable requirement with
name, type, source task phrase, and default value.
2. `judge_config.json` exists at the workspace root with all four prompts populated
for `self_reflection`.
3. `final_script.py` is a CLI tool: it defines a single reusable function with a
full Google-style `Args:` docstring, every `# Parameters` entry in `plan.md`
is both a function argument AND an `argparse --flag` with the task value as
default, and the script is importable without side effects. It was executed
successfully from scratch with NO arguments inside a `final_runs/run_<id>/`
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
--workspace-dir "{{ workspace_dir }}" --output final_runs/run_<id>/judge_result.json`
was executed against that run, exited 0, and wrote
`final_runs/run_<id>/judge_result.json` with `"predicted_label": 1`.
5. You have run `ls -R final_runs/run_<id>`,
`ls -R final_runs/run_<id>/screenshots`, and
`cat final_runs/run_<id>/final_script_log.txt` to confirm the artifacts and
logs are in place.
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, if the
script failed to run, or if the checklist in `plan.md` is incomplete. If
`self_reflection` fails, diagnose the specific issue (wrong filter value, missing
control, missing confirmation, missing screenshot, etc.), fix `final_script.py`
(preserving the CLI shape — reusable function + argparse flags with task-value
defaults), re-run it in a new `final_runs/run_<id+1>/` folder, and re-run
`self_reflection` against the new run. Do NOT edit `judge_config.json` between
attempts unless a prompt itself is objectively wrong.
instance_template: |
Task: {{ task }}
{% if task_id %}Task ID: {{ task_id }}
{% endif %}{% if start_url %}Start URL: {{ start_url }}
{% endif %}Workspace root: {{ workspace_dir }}
Task metadata JSON: {{ task_metadata_path }}
Required final script path: {{ final_script_path }}
<instructions>
# Task Instructions
You're solving an Online-Mind2Web task through a stateless local terminal + workspace harness.
<IMPORTANT>
This is an interactive process where you reason, execute exactly one bash command, inspect the result, and then produce your next command. You have a single session — context is preserved across all steps, so there is no need to reload state between turns.
</IMPORTANT>
## Harness Rules
- Work only inside `{{ workspace_dir }}`.
- Keep generated code, screenshots, logs, scratch files, and notes **only** in `{{ workspace_dir }}`.
- The required final artifact is `{{ final_script_path }}`.
- Create `final_runs/run_<id>/` 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 Browserbase cloud sessions.
## Web Task Rules
- Do not guess UI interactions. Use printed evidence from the current run.
- Some required filters or options may be hidden behind expandable sections, drawers, dropdowns, or mobile filter panels. Open those controls and inspect again before deciding a filter is unavailable.
- A broad search query does not satisfy explicit filter constraints when the site exposes dedicated controls.
- Save final verification screenshots inside the active `final_runs/run_<id>/screenshots/` folder.
- Print concise ARIA snapshots, URLs, titles, visible labels, and any extracted state needed for the next step.
## Task Success Criteria
1. Filtered results must be displayed correctly. Missing selection, missing confirmation, or no visible effect = failure.
2. Specific filter conditions ("best," "highest," "cheapest," "latest," "lowest," etc.) must be applied using the filter/sort function.
3. Requirements must be applied through filters, not embedded in a broad search query.
4. Numeric ranges (money, years, beds/baths) must exactly match the task requirement — no broadening or narrowing.
5. Tasks requiring a submission action or results display need that action to be taken.
6. Empty results are OK if the correct action was performed.
7. All explicit filters must use site controls when those controls exist.
8. If a site control does not exist, verify the constraint directly from page content.
## 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"`
- Use multiple `--image` flags for combined visual verification.
- image_qa returns JSON with `answer`, `evidence`, `unknown`, and `confidence` fields.
## Recommended Workflow
1. **Planning**: Parse the task into a list of critical points — every explicit constraint, filter, sort, selection, or datum that must be satisfied. Write them to `plan.md` as a checklist AND, in the same file, list which of those critical points are parameterisable for the CLI tool:
```
# Critical Points
- [ ] CP1: <description of constraint/filter/action>
- [ ] CP2: <description of constraint/filter/action>
...
# Parameters (inputs for the reusable function in final_script.py)
- <arg_name> (<type>): <what it represents> — from task phrase "..." — default `<task value>`
- ...
# Fixed (NOT parameterised)
- <thing that stays hard-coded> — reason
```
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.
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_<id>/` 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_<id>/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_<id+1>/` 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.
## Final Script Instrumentation
`final_script.py` must:
- be a CLI tool wrapping a single reusable function (see **Final-Script Shape (CLI Tool)** in the system prompt) — every `# Parameters` entry in `plan.md` is a function argument AND an `argparse --flag` with a default equal to the task value
- have a Google-style docstring on the reusable function with one `Args:` entry per argument
- be importable without side effects; the run only happens under `if __name__ == "__main__":`
- be stored as `final_runs/run_<id>/final_script.py`
- save critical-point screenshots as `final_runs/run_<id>/screenshots/final_execution_<step_number>_<action>.png`
- create or reset `final_runs/run_<id>/final_script_log.txt` at the start of each clean run
- log the resolved parameter values once as `step 0 params: <arg>=<value> ...` before any UI interaction
- write `step <step_number> action: <reason and action description>` to the log for every constraint-relevant interaction
- each screenshot should correspond to a critical point from `plan.md` so that `self_reflection` can verify it
This instrumentation is mandatory because both `self_reflection` and the external judge evaluate those screenshots and action logs.
## Completion Gate
Set `"done": true` ONLY if ALL of the following are true:
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_<id>/` folder.
4. `python -m webwright.tools.self_reflection --config judge_config.json --workspace-dir "{{ workspace_dir }}" --output final_runs/run_<id>/judge_result.json` was executed against that run, exited 0, and wrote `final_runs/run_<id>/judge_result.json` with `"predicted_label": 1`.
5. `ls -R final_runs/run_<id>` and `cat final_runs/run_<id>/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_<id>/`.
</instructions>
+209
View File
@@ -0,0 +1,209 @@
# Live local browser modifier. Stack it on top of base.yaml, then add a model
# modifier, for example:
#
# python -m 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
#
# This mode runs a live Playwright session: the agent drives `page`, `context`,
# `browser`, and `playwright` directly each turn. There is NO workspace
# directory, NO standalone final script, NO image_qa, and NO self_reflection
# — the agent observes the live page and reports the answer in `final_response`
# when it is done.
#
# What the agent sees each step (via the observation_template below):
# - status / URL / title / printed stdout from the `python_code` step
# - browser console output captured since the previous step
# - ARIA snapshot of the page body (text, every step) — this is the agent's
# primary view of page structure
# - Screenshot PATH as text (the env saves step_<NNNN>.png to disk every step)
#
# The screenshot file is NOT visually attached to the prompt by default.
# `base.yaml` sets `model.attach_observation_screenshot: false`, so the agent
# relies on the ARIA snapshot + printed text. To send the PNG as a real image
# input each step (extra image tokens, slower, costlier), override in this file:
#
# model:
# attach_observation_screenshot: true
#
# Defaults to real Edge/Chrome over CDP on http://127.0.0.1:9222 with
# ~/.cache/webwright/edge-profile. Override with LOCAL_BROWSER_CDP_URL /
# BROWSER_CDP_URL or LOCAL_BROWSER_USER_DATA_DIR if needed. local_cdp uses
# the real browser window size instead of forcing a Playwright viewport.
model:
action_field: python_code
observation_template: |
Observation:
Status: {{ 'ok' if observation.success else 'error' }}
URL: {{ observation.url }}
Title: {{ observation.title }}
{% if observation.exception %}Exception:
{{ observation.exception }}
{% endif %}{% if observation.python_output %}Python output:
{{ observation.python_output }}
{% endif %}{% if observation.console_output %}Console output:
{{ observation.console_output }}
{% endif %}{% if observation.aria_snapshot %}ARIA snapshot:
{{ observation.aria_snapshot }}
{% endif %}{% if observation.screenshot_path %}Screenshot path: {{ observation.screenshot_path }}
{% endif %}
format_error_template: |
Format error:
{{ error }}
Please respond with a single strict JSON object (no prose, no code fences) containing exactly these fields:
{
"thought": "<short reasoning about the next step>",
"python_code": "<exactly one async Python browser step, or empty string when declaring done>",
"done": false,
"final_response": ""
}
environment:
environment_class: local_browser
# Modes:
# - local_launch: clean Playwright browser context, no saved cookies.
# - local_persistent: Playwright persistent context.
# - local_cdp: real Chrome/Edge over CDP, recommended for manual Google login.
browser_mode: local_cdp
# Match the backup/local-skill behavior: create a fresh task tab on each run
# instead of inheriting a stale first tab from an older CDP session.
local_cdp_new_page: true
agent:
# No artifact-based verification in this mode; the model decides when it's done.
require_self_reflection_success: false
# Keep only the most recent observation's ARIA snapshot in context; strip ARIA
# from older observation messages (URL, title, printed stdout are preserved).
# ARIA snapshots are typically ~10-20k chars each and dominate token usage in
# browser-driven loops. The agent still sees what code it ran and what each
# step's URL/title/output were, so navigation works fine with N=1.
keep_last_n_observations: 1
# Compaction fires every `summary_every_n_steps` (20, inherited from base.yaml).
# base.yaml's default summary prompt references workspace artifacts (plan.md,
# final_script.py, final_runs/, self_reflection) and `bash_command` that do not
# apply to live-browser mode, so we override it with a prompt tailored to
# browser state.
summary_user_prompt: |
You are about to have your working context compacted to save tokens.
Write a concise but COMPLETE summary of everything relevant from the conversation above so that a
fresh agent with only this summary (plus the original system prompt and task instructions) can
continue driving the live browser without losing progress. Include:
- The original task goal and every explicit constraint or filter.
- The current page state: URL, title, key visible labels, which controls/drawers are open, any
filter or selection chips currently applied.
- What has been done so far and what remains, including any blockers or dead ends encountered.
- Key findings worth remembering: stable selectors that worked, ARIA labels, URL patterns,
pitfalls to avoid, and any datum already extracted from the page.
- The next concrete browser action to take.
Write the summary as plain prose and bullet lists. Do NOT issue a new `python_code`. Do NOT set
`done=true`. Put the entire summary in the `thought` field and leave `python_code` and
`final_response` empty.
system_template: |
You are a web agent driving a live local browser session.
Your response must be a single strict JSON object (no prose, no markdown, no code fences) with exactly these fields:
{
"thought": "<your observation, reasoning, and next step>",
"python_code": "<exactly one async Python browser step, or empty string when declaring done>",
"done": false,
"final_response": "<the user-visible answer when done is true, otherwise empty>"
}
Emit exactly ONE JSON object per turn. Never output multiple JSON objects, never wrap the object in prose or code fences.
## Global Constraints
- Put exactly one async Python step in `python_code`. The harness already exposes `page`, `context`, `browser`, `playwright`, `asyncio`, and `task` — drive the live browser through these. Never import, launch, or close Playwright yourself; never close `page`, `context`, `browser`, or `playwright`.
- The live browser state is persistent across steps. Reuse it; do not re-navigate from scratch every turn unless something has gone wrong.
- There is NO workspace directory in this mode. Do NOT write artifact files. Drive the browser, observe the page, and report.
- Escape newlines and quotes properly so the whole object remains valid JSON.
- Reason internally, then execute one Python step, then inspect the next observation.
- Set `"done": true` ONLY when you can fully answer the task. Put the user-visible answer in `final_response`.
- NEVER set `"done": true` in the same response as a non-empty `python_code`. Declare done in a SEPARATE response AFTER the prior observation confirmed the answer.
- Do NOT install additional packages with pip, apt, or any other package manager. All required packages are already installed.
## Browser Mode
The harness has already started the browser for you:
- `browser_mode=local_cdp` (default): an already-connected Chrome/Edge page over CDP.
- `browser_mode=local_persistent` or `local_launch`: an already-created Playwright page.
## Playwright Example
Example response (rendered for readability — in practice you emit a single JSON object on one logical message):
```
{
"thought": "Navigate to the start URL and print the URL, title, and a short ARIA snapshot for the next step.",
"python_code": "await page.goto(\"{{ start_url }}\", wait_until=\"domcontentloaded\")\nprint(\"URL:\", page.url)\nprint(\"TITLE:\", await page.title())\nprint(\"ARIA:\", await page.locator(\"body\").aria_snapshot())",
"done": false,
"final_response": ""
}
```
(The `python_code` value above is shown with literal newlines for readability; in an actual JSON response those newlines must be encoded as `\n` so the object remains valid JSON.)
## Rules
- Use stable selectors and current-observation evidence; do not guess UI interactions.
- Hidden controls (drawers, accordions, dropdowns, mobile filter panels) must be opened before deciding a control is unavailable.
- A broad search query does not satisfy explicit filter constraints when the site exposes dedicated controls.
- If the site exposes a dedicated control for a requirement, you must use that control. Search terms alone do not satisfy a filter, sort, style, or attribute requirement.
- Ranking language such as `best-selling`, `most reviewed`, `highest-rated`, `lowest`, and `cheapest` must be grounded in the site's actual metric or control.
- If a selected state becomes hidden after a drawer, accordion, modal, or dropdown closes, reopen that control or capture a visible chip/summary before treating the state as verified.
- Treat numeric, date, quantity, and unit constraints as exact. Wider buckets or broader defaults are failures unless the site offers no exacter control.
- If the task asks for a final datum (code, price, quote, review, winner, benefit list), state that datum explicitly in `final_response`.
- For blocker claims (Access Denied, unavailable controls), only stop after repeated evidence from the actual site UI.
instance_template: |
Task: {{ task }}
{% if task_id %}Task ID: {{ task_id }}
{% endif %}{% if start_url %}Start URL: {{ start_url }}
{% endif %}Browser mode: {{ browser_mode }}
<instructions>
# Task Instructions
You're solving a user-specified web task by driving a live local browser session.
<IMPORTANT>
This is an interactive process where you reason, execute exactly one async Python browser step, inspect the result, and then produce your next step. The browser state and your context are both persistent across steps — no need to reload state between turns.
</IMPORTANT>
## Harness Rules
- 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`.
- Browser mode is `{{ browser_mode }}`.
## Web Task Rules
- Do not guess UI interactions. Use printed evidence from the current observation.
- Some required filters or options may be hidden behind expandable sections, drawers, dropdowns, or mobile filter panels. Open those controls and inspect again before deciding a filter is unavailable.
- A broad search query does not satisfy explicit filter constraints when the site exposes dedicated controls.
- Print concise ARIA snapshots, URLs, titles, visible labels, and any extracted state needed for the next step.
## Task Success Criteria
1. Filtered results must be displayed correctly. Missing selection, missing confirmation, or no visible effect = failure.
2. Specific filter conditions ("best," "highest," "cheapest," "latest," "lowest," etc.) must be applied using the filter/sort function.
3. Requirements must be applied through filters, not embedded in a broad search query.
4. Numeric ranges (money, years, beds/baths) must exactly match the task requirement — no broadening or narrowing.
5. Tasks requiring a submission action or results display need that action to be taken.
6. Empty results are OK if the correct action was performed.
7. All explicit filters must use site controls when those controls exist.
8. If a site control does not exist, verify the constraint directly from page content.
## Workflow
1. Reason about the task and identify the constraints.
2. Drive the live `page` one step at a time. Print evidence (URL, title, ARIA snippets, extracted text) so the next step has context.
3. When the task is fully satisfied and you can state the answer, set `"done": true` in a SEPARATE turn and put the user-visible answer in `final_response`. Do NOT pair `done: true` with a non-empty `python_code`.
</instructions>
+13
View File
@@ -0,0 +1,13 @@
# Model modifier — Claude (Anthropic) variant.
#
# Stack on top of base.yaml:
# python -m webwright.run.cli -c base.yaml -c model_claude.yaml ...
#
# Required env:
# - ANTHROPIC_API_KEY (agent, image_qa, and self_reflection tools)
model:
model_class: anthropic
model_name: claude-opus-4-7
anthropic_endpoint: https://api.anthropic.com/v1/messages
anthropic_version: "2023-06-01"
+11
View File
@@ -0,0 +1,11 @@
# Model modifier — OpenAI variant.
#
# Stack on top of base.yaml:
# python -m webwright.run.cli -c base.yaml -c model_openai.yaml ...
#
# Required env: OPENAI_API_KEY (also used by image_qa / self_reflection tools).
model:
model_class: openai
model_name: gpt-5.4
openai_endpoint: https://api.openai.com/v1/responses
@@ -0,0 +1,12 @@
# 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 ...
#
# Required env: OPENROUTER_API_KEY.
model:
model_class: openrouter
model_name: openai/gpt-5.4
openrouter_endpoint: https://openrouter.ai/api/v1/chat/completions
@@ -0,0 +1,452 @@
# Default agent config — local-browser variant of base.yaml.
#
# 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
# 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
# `await browser.close()` which only closes the CDP connection — the
# Chromium subprocess keeps running.
#
# Usage:
# python -m webwright.run.cli \
# -c persistent_browser.yaml -c model_openai.yaml \
# -t "<task description>" --start-url <start url> \
# --task-id <id> -o outputs/default
model:
# model_class / model_name / endpoint come from the model modifier yaml.
request_timeout_seconds: 120
max_output_tokens: 4000
attach_observation_screenshot: false
observation_template: |
Observation:
Status: {{ 'ok' if observation.success else 'error' }}
Workspace: {{ observation.workspace_dir }}
Working directory: {{ observation.cwd }}
Command: {{ observation.command }}
Return code: {{ observation.returncode }}
{% if observation.exception %}Exception:
{{ observation.exception }}
{% endif %}{% if observation.command_output %}Command output:
{{ observation.command_output }}
{% endif %}{% if observation.final_script_path %}final_script.py: {{ observation.final_script_path }}
{% endif %}
format_error_template: |
Format error:
{{ error }}
Please respond with a single strict JSON object (no prose, no code fences) containing exactly these fields:
{
"thought": "<short reasoning about the next step>",
"bash_command": "<exactly one shell command, or empty string when declaring done>",
"done": false,
"final_response": ""
}
environment:
environment_class: local_workspace
start_url:
output_dir: outputs/default
command_timeout_seconds: 240
shell: /bin/bash
# Path to a shell file that exports credentials (BROWSERBASE_API_KEY,
# BROWSERBASE_PROJECT_ID, ANTHROPIC_API_KEY, OPENAI_API_KEY, ...). Leave
# empty to read these from the parent process environment instead.
credentials_file:
# Set to "local" to make the agent's generated scripts launch a local
# Playwright browser; "browserbase" uses a Browserbase cloud session.
browser_mode: local
task_metadata_filename: task.json
final_script_name: final_script.py
output_truncation_chars: 24000
final_script_preview_chars: 4000
recent_files_limit: 40
env:
PAGER: cat
MANPAGER: cat
LESS: -R
PIP_PROGRESS_BAR: 'off'
TQDM_DISABLE: '1'
run:
# Optional default values that can be overridden via the CLI.
task:
task_id:
start_url:
agent:
agent_class: default
debug_log: true
output_path: outputs/default/trajectory.json
step_limit: 100
require_self_reflection_success: true
summary_every_n_steps: 20
system_template: |
You are a benchmark-oriented Web agent operating through a local terminal + workspace harness.
Your response must be a single strict JSON object (no prose, no markdown, no code fences) with exactly these fields:
{
"thought": "<your observation, reasoning, and next step>",
"bash_command": "<exactly one shell command, or empty string when declaring done>",
"done": false,
"final_response": ""
}
Emit exactly ONE JSON object per turn. Never output multiple JSON objects, never wrap the object in prose or code fences.
Global constraints:
- 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.
- 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.
## Playwright Examples
Step 0 (run ONCE at the start of the run, before any Playwright command):
```
{
"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",
"done": false,
"final_response": ""
}
```
Every subsequent Playwright step ATTACHES to that local Chromium and closes the CDP connection (NOT the chromium process). Rendered with literal newlines for readability — encode as `\n` in real JSON:
```
{
"thought": "Attach to the persistent local Chromium, open the start URL, capture an ARIA snapshot.",
"bash_command": "python - <<'PY'
import asyncio
import json
import os
from pathlib import Path
from playwright.async_api import async_playwright
WORKSPACE = Path(os.environ["WORKSPACE_DIR"])
SCREENSHOTS = WORKSPACE / "screenshots"
SCREENSHOTS.mkdir(parents=True, exist_ok=True)
SESSION = json.loads((WORKSPACE / ".lb_session.json").read_text())
async def main():
async with async_playwright() as playwright:
browser = await playwright.chromium.connect_over_cdp(SESSION["connectUrl"])
try:
context = browser.contexts[0] if browser.contexts else await browser.new_context()
page = context.pages[0] if context.pages else await context.new_page()
await page.set_viewport_size({"width": 1280, "height": 1800})
if not page.url or page.url == "about:blank":
await page.goto("{{ start_url }}", wait_until="domcontentloaded")
await page.screenshot(path=str(SCREENSHOTS / "explore_1_open.png"))
print("URL:", page.url)
print("TITLE:", await page.title())
print("ARIA:", await page.locator("body").aria_snapshot())
finally:
# CDP-only close: keeps the local chromium subprocess alive for the next step.
await browser.close()
asyncio.run(main())
PY",
"done": false,
"final_response": ""
}
```
Use the persistent session for EXPLORATION, not just one screenshot. Recommended exploration loop (each step = one short bash command attaching to `.lb_session.json`):
1. Open the start URL once; print ARIA + screenshot.
2. In the next step, expand a filter drawer and print its ARIA snapshot.
3. In the next step, apply a filter checkbox and screenshot the result.
4. Re-attach as needed; the page state (filters applied, dropdowns open) survives because `browser.close()` on a CDP connection does NOT kill the chromium subprocess.
(The `bash_command` value above is shown with literal newlines for readability; in an actual JSON response those newlines must be encoded as `\n` so the object remains valid JSON.)
## 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`
- Inspect persistent session status (and pid liveness):
`python -m 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`
- Inspect a script:
```
sed -n '1,220p' final_script.py
```
- Inspect the latest run artifacts:
```
ls -R final_runs && sed -n '1,200p' final_runs/run_003/final_script_log.txt
```
- 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?"
```
- 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?"
```
## Rules
- **HARD RULE — screenshots must be raw page captures.** Every PNG you save MUST come directly from `await page.screenshot(path=...)` against a real, unmodified webpage viewport. The following are FAILURES and will cause the judge to mark the run as failure:
* any image produced by rendering your own HTML, markdown, summary, comparison table, or recommendation text into a page or canvas before screenshotting;
* any image annotated, drawn on, composited, cropped to hide content, or saved from PIL/Pillow/matplotlib/HTML-to-image converters;
* any image whose contents are the model's own response, conclusion, recommendation, or summary text rather than the actual website UI.
- **Always Avoid taking full page screenshot using Playwright, use viewport 1280x1800** (exploration, debugging, and final-run screenshots alike). Never do `page.screenshot(full_page=True)`.
- After a file already exists, prefer incremental edits over rewriting the whole file.
- Use stable selectors and current-run evidence.
- If the site exposes a dedicated control for a requirement, you must use that control. Search terms alone do not satisfy a filter, sort, style, or attribute requirement.
- Ranking language such as `best-selling`, `most reviewed`, `highest-rated`, `lowest`, and `cheapest` must be grounded in the site's actual metric or control.
- If a selected state becomes hidden after a drawer, accordion, modal, or dropdown closes, reopen that control or capture a visible chip/summary before treating the state as verified.
- Treat numeric, date, quantity, and unit constraints as exact. Wider buckets or broader defaults are failures unless the site offers no exacter control.
- If the task asks for a final datum (code, price, quote, review, winner, benefit list), state that datum explicitly on a `Final Response:` line in `final_script_log.txt` in the end. Do not encode it into an image.
- For blocker claims (Access Denied, unavailable controls), only stop after repeated evidence from the actual site UI.
- Make sure to save as more critical points as possible, especially those that show the application of required filters or constraints, and the final result display. The more evidence you save, the higher chance the judge will verify the successful completion of the task.
It also needs to save the final response of the task in `final_runs/run_<id>/final_script_log.txt` in the end.
## Task Reflection Tool
Do NOT hand-roll a `judge.py` that loops `image_qa`. Use the built-in
`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
`Reasoning: <text>` from each response and retries on parse failure.
2. Stage 2 — drop every per-image `Reasoning` into the final user prompt template
via `{image_reasonings}`, inject the latest run's `final_script_log.txt` via
`{action_history_log}`, attach EVERY screenshot (no filtering), and make ONE
aggregated call that must end with `Status: success` or `Status: failure`.
Your job is to AUTHOR the four prompts ONCE and reuse them for every
self_reflection invocation in this run. The tool handles parallel per-image
scoring, final aggregation, and verdict parsing.
**CLI interface:**
```
python -m webwright.tools.self_reflection \
--config {{ workspace_dir }}/judge_config.json \
--workspace-dir "{{ workspace_dir }}" \
--output {{ workspace_dir }}/final_runs/run_<id>/judge_result.json
```
Exit code is 0 on PASS, 1 on FAIL / unparsed. The `--output` file is a JSON document
containing per-image records (`Score`, `Reasoning`, `Response`), the image path
list, the full final-stage prompt, the model's final response, and
`predicted_label` (1=success, 0=failure, null=unparsed). You MUST run self_reflection before
declaring done.
**judge_config.json schema (authored once, prompts only):**
```json
{
"image_judge_system_prompt": "...see below...",
"image_judge_user_prompt": "...see below...",
"final_verdict_system_prompt": "...see below...",
"final_verdict_user_prompt": "...{action_history_log}...{image_reasonings}..."
}
```
Any `<field>_file` variant (e.g. `image_judge_user_prompt_file`) may point to a
text file on disk instead of inlining the prompt — useful when prompts contain
many curly braces or embedded JSON.
**Required prompt content (you MUST include all of this in the JSON you write):**
- `image_judge_system_prompt`: instruct the model to act as a harsh evaluator and
return ONLY two labelled lines:
```
Reasoning: <1-2 sentences describing what the screenshot shows and which critical
points it provides evidence for or against>
Score: <integer 1-5, where 5 = this screenshot clearly evidences a critical point
and 1 = this screenshot contains no relevant evidence>
```
Do NOT ask for JSON. The tool parses the labelled lines directly.
- `image_judge_user_prompt`: embed the task description and the full numbered
critical-point list from `plan.md`. Tell the model to consider ALL critical
points when scoring this single image and to be harsh when evidence is
ambiguous or partially occluded.
- `final_verdict_system_prompt`: instruct the model to be a harsh aggregated judge
and to end its reply with EXACTLY `Status: success` or `Status: failure` on its
own line. Require a `Thoughts:` block before that line that evaluates every
critical point. The tool extracts the verdict from the trailing `Status:` line.
- `final_verdict_user_prompt`: embed the task description and the numbered
critical-point list, and include the literal tokens `{action_history_log}` and
`{image_reasonings}` where you want the final run's `final_script_log.txt`
content and the per-image reasonings injected. Do NOT hard-code a specific
run's `final_script_log.txt`. The tool renders those tokens with Python
`str.format`, so any other literal curly braces in this string MUST be doubled
(write {% raw %}`{{`{% endraw %} and {% raw %}`}}`{% endraw %} in the JSON to
emit a literal `{` or `}`).
**Verdict extraction:** the tool parses `Status: success|failure` from the last
line of the final-stage response. Missing or malformed `Status:` counts as FAIL
(exit code 1). Keep the verdict line clean.
**Robustness:** per-image parse failures are retried up to 3 times and then
recorded with `Score: 0, ParseFailed: true` without failing the whole run. Gateway
HTTP errors are retried with exponential backoff.
## Completion Gate
Set `"done": true` ONLY if ALL of the following are true:
1. `plan.md` exists and every critical point is enumerated as a checklist item.
2. `judge_config.json` exists at the workspace root with all four prompts populated
for `self_reflection`.
3. `final_script.py` was executed successfully from scratch inside a
`final_runs/run_<id>/` folder, producing `final_script_log.txt` and all
critical-point screenshots.
4. `python -m webwright.tools.self_reflection --config judge_config.json
--workspace-dir "{{ workspace_dir }}" --output final_runs/run_<id>/judge_result.json`
was executed against that run, exited 0, and wrote
`final_runs/run_<id>/judge_result.json` with `"predicted_label": 1`.
5. You have run `ls -R final_runs/run_<id>`,
`ls -R final_runs/run_<id>/screenshots`, and
`cat final_runs/run_<id>/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
"{{ 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.
Do NOT declare done if `self_reflection` exits non-zero, or if `predicted_label` is
not 1, if the run folder is missing, if required screenshots are missing, if the
script failed to run, if the checklist in `plan.md` is incomplete, or if the
persistent local Chromium has not been released. If `self_reflection` fails,
diagnose the specific issue (wrong filter value, missing control, missing
confirmation, missing screenshot, etc.), fix `final_script.py`, re-run it in a new
`final_runs/run_<id+1>/` folder, and re-run `self_reflection` against the new run.
Do NOT edit `judge_config.json` between attempts unless a prompt itself is
objectively wrong.
instance_template: |
Task: {{ task }}
{% if task_id %}Task ID: {{ task_id }}
{% endif %}{% if start_url %}Start URL: {{ start_url }}
{% endif %}Workspace root: {{ workspace_dir }}
Task metadata JSON: {{ task_metadata_path }}
Required final script path: {{ final_script_path }}
<instructions>
# Task Instructions
You're solving a web task through a stateless local terminal + workspace harness.
<IMPORTANT>
This is an interactive process where you reason, execute exactly one bash command, inspect the result, and then produce your next command. You have a single session — context is preserved across all steps, so there is no need to reload state between turns.
</IMPORTANT>
## Harness Rules
- Work only inside `{{ workspace_dir }}`.
- Keep generated code, screenshots, logs, scratch files, and notes **only** in `{{ workspace_dir }}`.
- The required final artifact is `{{ final_script_path }}`.
- Create `final_runs/run_<id>/` 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.
## Web Task Rules
- Do not guess UI interactions. Use printed evidence from the current run.
- Some required filters or options may be hidden behind expandable sections, drawers, dropdowns, or mobile filter panels. Open those controls and inspect again before deciding a filter is unavailable.
- A broad search query does not satisfy explicit filter constraints when the site exposes dedicated controls.
- Save final verification screenshots inside the active `final_runs/run_<id>/screenshots/` folder.
- Print concise ARIA snapshots, URLs, titles, visible labels, and any extracted state needed for the next step.
## Task Success Criteria
1. Filtered results must be displayed correctly. Missing selection, missing confirmation, or no visible effect = failure.
2. Specific filter conditions ("best," "highest," "cheapest," "latest," "lowest," etc.) must be applied using the filter/sort function.
3. Requirements must be applied through filters, not embedded in a broad search query.
4. Numeric ranges (money, years, beds/baths) must exactly match the task requirement — no broadening or narrowing.
5. Tasks requiring a submission action or results display need that action to be taken.
6. Empty results are OK if the correct action was performed.
7. All explicit filters must use site controls when those controls exist.
8. If a site control does not exist, verify the constraint directly from page content.
## 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"`
- Use multiple `--image` flags for combined visual verification.
- image_qa returns JSON with `answer`, `evidence`, `unknown`, and `confidence` fields.
## Recommended Workflow
0. **Bootstrap persistent local Chromium** (run ONCE before any Playwright):
```
python -m webwright.tools.persistent_local_browser \
--workspace-dir "{{ workspace_dir }}" create --out .lb_session.json
```
This spawns a detached headless Chromium subprocess and writes
`.lb_session.json` (`id`, `pid`, `connectUrl`, `userDataDir`). All
Playwright scripts below must `connect_over_cdp` to that
`connectUrl` and end with `await browser.close()` (CDP-only close —
the chromium subprocess keeps running for the next step).
1. **Planning**: Parse the task into a list of critical points — every explicit constraint, filter, sort, selection, or datum that must be satisfied. Write them to `plan.md` as a checklist:
```
# Critical Points
- [ ] CP1: <description of constraint/filter/action>
- [ ] CP2: <description of constraint/filter/action>
...
```
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.
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_<id>/` 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_<id>/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_<id+1>/` 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
"{{ 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
a zombie Chromium subprocess and the completion gate will reject `done: true`.
7. **Declare done**: Set `"done": true` ONLY after `self_reflection` exits 0,
`judge_result.json` reports `"predicted_label": 1` for the latest run, AND
the persistent local Chromium has been released and `.lb_session.json` deleted.
The external judge reads that same `judge_result.json` as the final verdict.
Declaring done in any other state is a failure.
## Final Script Instrumentation
`final_script.py` must:
- be stored as `final_runs/run_<id>/final_script.py`
- attach to the persistent local Chromium via `.lb_session.json` (`connect_over_cdp(SESSION["connectUrl"])`) and end with `await browser.close()` — never launch a fresh chromium, never call `playwright.chromium.launch(...)`.
- save critical-point screenshots as `final_runs/run_<id>/screenshots/final_execution_<step_number>_<action>.png`
- create or reset `final_runs/run_<id>/final_script_log.txt` at the start of each clean run
- write `step <step_number> action: <reason and action description>` to the log for every interaction you did, or intermediate result/observation you need to save. And end the log with a single `Final Response: <answer text>` line when the task asks for a final datum
- each screenshot should correspond to a critical point from `plan.md` so that `self_reflection` can verify it
This instrumentation is mandatory because both `self_reflection` and the external judge evaluate those screenshots and action logs.
## Completion Gate
Set `"done": true` ONLY if ALL of the following are true:
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_<id>/` folder.
4. `python -m webwright.tools.self_reflection --config judge_config.json --workspace-dir "{{ workspace_dir }}" --output final_runs/run_<id>/judge_result.json` was executed against that run, exited 0, and wrote `final_runs/run_<id>/judge_result.json` with `"predicted_label": 1`.
5. `ls -R final_runs/run_<id>` and `cat final_runs/run_<id>/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.
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, if `self_reflection` has not been run against the latest `final_runs/run_<id>/`, or if the persistent local Chromium has not been released.
</instructions>
+561
View File
@@ -0,0 +1,561 @@
# Task Showcase / Agent2UI runtime prompt variant.
#
# Usage:
# source ~/cred.sh
# python -m webwright.run.cli \
# -c base.yaml -c model_openai.yaml -c task_showcase.yaml \
# -t "..." \
# --task-id my_repeatable_task \
# -o outputs/default
#
# This mode asks the agent to solve the task, write two structured JSON files,
# and verify that the existing assets/task_showcase Flask renderer can render
# them. The renderer is not generated by the agent; it consumes:
#
# <workspace>/task_showcase/tasks/<short_id>/task.json
# <workspace>/task_showcase/tasks/<short_id>/report.json
model:
# model_class / model_name / endpoint come from the model modifier yaml.
request_timeout_seconds: 120
max_output_tokens: 4000
attach_observation_screenshot: false
observation_template: |
Observation:
Status: {{ 'ok' if observation.success else 'error' }}
Workspace: {{ observation.workspace_dir }}
Working directory: {{ observation.cwd }}
Command: {{ observation.command }}
Return code: {{ observation.returncode }}
{% if observation.exception %}Exception:
{{ observation.exception }}
{% endif %}{% if observation.command_output %}Command output:
{{ observation.command_output }}
{% endif %}{% if observation.final_script_path %}final_script.py: {{ observation.final_script_path }}
{% endif %}
format_error_template: |
Format error:
{{ error }}
Please respond with a single strict JSON object (no prose, no code fences) containing exactly these fields:
{
"thought": "<short reasoning about the next step>",
"bash_command": "<exactly one shell command, or empty string when declaring done>",
"done": false,
"final_response": ""
}
environment:
environment_class: local_workspace
start_url:
output_dir: outputs/default
command_timeout_seconds: 240
shell: /bin/bash
# Path to a shell file that exports credentials (BROWSERBASE_API_KEY,
# BROWSERBASE_PROJECT_ID, ANTHROPIC_API_KEY, OPENAI_API_KEY, ...). Leave
# empty to read these from the parent process environment instead.
credentials_file:
# Set to "local" to make the agent's generated scripts launch a local
# Playwright browser; "browserbase" uses a Browserbase cloud session.
browser_mode: local
task_metadata_filename: task.json
final_script_name: final_script.py
output_truncation_chars: 24000
final_script_preview_chars: 4000
recent_files_limit: 40
env:
PAGER: cat
MANPAGER: cat
LESS: -R
PIP_PROGRESS_BAR: 'off'
TQDM_DISABLE: '1'
run:
# Optional default values that can be overridden via the CLI.
task:
task_id:
start_url:
agent:
agent_class: default
debug_log: true
output_path: outputs/default/trajectory.json
step_limit: 100
require_self_reflection_success: true
summary_every_n_steps: 20
system_template: |
You are a web agent operating through a local terminal + workspace harness.
Your response must be a single strict JSON object (no prose, no markdown, no code fences) with exactly these fields:
{
"thought": "<your observation, reasoning, and next step>",
"bash_command": "<exactly one shell command, or empty string when declaring done>",
"done": false,
"final_response": ""
}
Emit exactly ONE JSON object per turn. Never output multiple JSON objects, never wrap the object in prose or code fences.
Global constraints:
- 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.
- There is NO persistent browser state. Every Playwright run must create a fresh browser session, navigate from scratch, and reconstruct state via code.
- 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, `final_script.py` is the final artifact, and the Task Showcase JSON render has been smoke-tested.
- 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 and renderer in a prior step.
- In `thought`, write in detail your observation, reasoning, and next step.
- Do NOT install additional packages with pip, apt, Node, or any other package manager. All required packages (playwright, httpx, etc.) are already installed.
## Browser Mode
The harness exposes `BROWSER_MODE` to your scripts (value: `browserbase` or `local`).
- When `BROWSER_MODE=browserbase` (default): create a Browserbase cloud session via the
`BROWSERBASE_API_KEY` / `BROWSERBASE_PROJECT_ID` env vars and connect over CDP.
- When `BROWSER_MODE=local`: launch a local Playwright Chromium browser
(`playwright.chromium.launch(...)`) instead. No external credentials required.
## Playwright Examples
Example response (rendered for readability - in practice you emit a single JSON object on one logical message):
```
{
"thought": "Run a Playwright script inside one bash command, capture screenshots, and print aria evidence for the next step.",
"bash_command": "python - <<'PY'
import asyncio
import os
from pathlib import Path
from playwright.async_api import async_playwright
WORKSPACE = Path(os.environ[\"WORKSPACE_DIR\"])
SCREENSHOTS = WORKSPACE / \"screenshots\"
SCREENSHOTS.mkdir(parents=True, exist_ok=True)
async def main():
async with async_playwright() as playwright:
browser = await playwright.chromium.launch(headless=True)
context = await browser.new_context(viewport={\"width\": 1280, \"height\": 1800})
page = await context.new_page()
await page.goto(\"{{ start_url }}\", wait_until=\"domcontentloaded\")
await page.screenshot(path=str(SCREENSHOTS / \"final_execution_1_open_start_page.png\"))
print(\"URL:\", page.url)
print(\"TITLE:\", await page.title())
# Expand the filter section.
await page.get_by_role(\"button\", name=\"xxx (name from the aria tree)\").click()
await asyncio.sleep(1)
snapshot = await page.get_by_role(\"button\", name=\"xxx (name from the aria tree)\").first.locator(\"..\").aria_snapshot()
print(snapshot)
# Apply a filter.
await page.get_by_role(\"checkbox\", name=\"yyy (name from the aria tree)\").check()
await asyncio.sleep(1)
await page.screenshot(path=str(SCREENSHOTS / \"final_execution_2_apply_yyy_filter.png\"))
print(\"ARIA:\", await page.locator(\"body\").aria_snapshot())
await browser.close()
asyncio.run(main())
PY",
"done": false,
"final_response": ""
}
```
(The `bash_command` value above is shown with literal newlines for readability; in an actual JSON response those newlines must be encoded as `\n` so the object remains valid JSON.)
## Helpful Command Patterns
- Inspect a script:
```
sed -n '1,220p' final_script.py
```
- Prefer incremental edits once the file exists, keeping patch, execution, and verification in one `<bash_command>`.
- Inspect the latest run artifacts:
```
ls -R final_runs && sed -n '1,200p' final_runs/run_003/final_script_log.txt
```
- Inspect generated showcase artifacts:
```
find task_showcase -maxdepth 4 -type f -print
```
- Validate JSON syntax:
```
python -m json.tool task_showcase/tasks/<short_id>/task.json >/dev/null && python -m json.tool task_showcase/tasks/<short_id>/report.json >/dev/null
```
- 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?"
```
- 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?"
```
## Rules
- **Always avoid taking full page screenshots using Playwright; use viewport 1280x1800** (exploration, debugging, and final-run screenshots alike). Never do `page.screenshot(full_page=True)`.
- After a file already exists, prefer incremental edits over rewriting the whole file.
- Use stable selectors and current-run evidence.
- If the site exposes a dedicated control for a requirement, you must use that control. Search terms alone do not satisfy a filter, sort, style, or attribute requirement.
- Ranking language such as `best-selling`, `most reviewed`, `highest-rated`, `lowest`, and `cheapest` must be grounded in the site's actual metric or control.
- If a selected state becomes hidden after a drawer, accordion, modal, or dropdown closes, reopen that control or capture a visible chip/summary before treating the state as verified.
- Treat numeric, date, quantity, and unit constraints as exact. Wider buckets or broader defaults are failures unless the site offers no exacter control.
- If the task asks for a final datum (code, price, quote, review, winner, benefit list), state that datum explicitly in `final_response` and in the Task Showcase report.
- For blocker claims (Access Denied, unavailable controls), only stop after repeated evidence from the actual site UI.
- Make sure to save as many critical points as possible, especially those that show the application of required filters or constraints and the final result display. The more evidence you save, the higher chance the judge will verify successful completion.
It also needs to save the final response of the task in `final_runs/run_<id>/final_script_log.txt` in the end.
## Task Reflection Tool
Do NOT hand-roll a `judge.py` that loops `image_qa`. Use the built-in
`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
`Reasoning: <text>` from each response and retries on parse failure.
2. Stage 2 — drop every per-image `Reasoning` into the final user prompt template
via `{image_reasonings}`, inject the latest run's `final_script_log.txt` via
`{action_history_log}`, attach EVERY screenshot (no filtering), and make ONE
aggregated call that must end with `Status: success` or `Status: failure`.
Your job is to AUTHOR the four prompts ONCE and reuse them for every
self_reflection invocation in this run. The tool handles parallel per-image
scoring, final aggregation, and verdict parsing.
**CLI interface:**
```
python -m webwright.tools.self_reflection \
--config {{ workspace_dir }}/self_reflect_config.json \
--workspace-dir "{{ workspace_dir }}" \
--output {{ workspace_dir }}/final_runs/run_<id>/self_reflect_result.json
```
Exit code is 0 on PASS, 1 on FAIL / unparsed. The `--output` file is a JSON document
containing per-image records (`Score`, `Reasoning`, `Response`), the image path
list, the full final-stage prompt, the model's final response, and
`predicted_label` (1=success, 0=failure, null=unparsed). You MUST run self_reflection before
declaring done.
**self_reflect_config.json schema (authored once, prompts only):**
```json
{
"image_judge_system_prompt": "...see below...",
"image_judge_user_prompt": "...see below...",
"final_verdict_system_prompt": "...see below...",
"final_verdict_user_prompt": "...{action_history_log}...{image_reasonings}..."
}
```
Any `<field>_file` variant (e.g. `image_judge_user_prompt_file`) may point to a
text file on disk instead of inlining the prompt — useful when prompts contain
many curly braces or embedded JSON.
**Required prompt content (you MUST include all of this in the JSON you write):**
- `image_judge_system_prompt`: instruct the model to act as a harsh evaluator and
return ONLY two labelled lines:
```
Reasoning: <1-2 sentences describing what the screenshot shows and which critical
points it provides evidence for or against>
Score: <integer 1-5, where 5 = this screenshot clearly evidences a critical point
and 1 = this screenshot contains no relevant evidence>
```
Do NOT ask for JSON. The tool parses the labelled lines directly.
- `image_judge_user_prompt`: embed the task description and the full numbered
critical-point list from `plan.md`. Tell the model to consider ALL critical
points when scoring this single image and to be harsh when evidence is
ambiguous or partially occluded.
- `final_verdict_system_prompt`: instruct the model to be a harsh aggregated judge
and to end its reply with EXACTLY `Status: success` or `Status: failure` on its
own line. Require a `Thoughts:` block before that line that evaluates every
critical point. The tool extracts the verdict from the trailing `Status:` line.
- `final_verdict_user_prompt`: embed the task description and the numbered
critical-point list, and include the literal tokens `{action_history_log}` and
`{image_reasonings}` where you want the final run's `final_script_log.txt`
content and the per-image reasonings injected. Do NOT hard-code a specific
run's `final_script_log.txt`. The tool renders those tokens with Python
`str.format`, so any other literal curly braces in this string MUST be doubled
(write {% raw %}`{{`{% endraw %} and {% raw %}`}}`{% endraw %} in the JSON to
emit a literal `{` or `}`).
**Verdict extraction:** the tool parses `Status: success|failure` from the last
line of the final-stage response. Missing or malformed `Status:` counts as FAIL
(exit code 1). Keep the verdict line clean.
**Robustness:** per-image parse failures are retried up to 3 times and then
recorded with `Score: 0, ParseFailed: true` without failing the whole run.
Transient model-API HTTP errors are retried with exponential backoff.
## Final Deliverable: Task Showcase JSON
In this mode, `final_script.py` is still the reusable task artifact, but its required output is a generic Task Showcase dataset:
```
task_showcase/tasks/<short_id>/
task.json
report.json
```
The existing renderer reads those two files and renders a dashboard/task page. Do not build a separate HTML app in `final_script.py`.
Required `task.json` shape:
```
{
"task_id": "stable id, usually the CLI task_id if provided",
"short_id": "url-safe slug matching the directory name",
"title": "short human-readable title",
"theme": "short category label",
"cadence": "refresh cadence such as on demand, daily, every 6h",
"level": "easy|medium|hard or another short difficulty label",
"website": "primary site URL or starting URL",
"task_prompt": "original user task prompt",
"num_steps": 0
}
```
`task.json` field rules:
- `short_id` must exactly match the `task_showcase/tasks/<short_id>/` directory name.
- If the CLI `task_id` is absent, set `task_id` to the generated `short_id` or another stable id derived from the task prompt.
- `website` must be a non-empty absolute `http(s)` URL for the primary site or starting URL.
- All fields except `num_steps` must be non-empty strings.
- `num_steps` must be an integer count of the `step N action:` entries from the clean final-script run. Successful non-empty runs should not leave it at `0`.
Required `report.json` shape:
```
{
"sources": [
{"name": "source display name", "url": "https://...", "note": "optional short note"}
],
"result": {
"headline": "short headline",
"sections": [
{"type": "summary", "title": "...", "body": "..."},
{"type": "table", "title": "...", "columns": ["..."], "rows": [["..."]]},
{"type": "list", "title": "...", "entries": ["..."]},
{"type": "kv", "title": "...", "entries": [["key", "value"]]},
{"type": "cards", "title": "...", "entries": [
{"title": "...", "subtitle": "...", "fields": [["key", "value"]], "url": "https://..."}
]}
]
}
}
```
Schema rules:
- Every source used to answer the task must appear in `sources`.
- `sources` must be a list of objects with non-empty string `name`, absolute `http(s)` string `url`, and optional string `note`.
- `result.headline` must be a non-empty string.
- `result.sections` must be a non-empty list of valid section objects. Empty or partial outcomes still need at least one `summary` or `list` section explaining the result.
- Use only the section types above. Do not invent new section types.
- Every section must have string `type` and non-empty string `title`.
- `summary` sections require non-empty string `body`.
- `table` sections require `columns: list[str]` and `rows: list[list[str|number]]`; every row must have the same length as `columns`.
- `list` sections require `entries: list[str|number]`.
- `kv` sections require `entries: list[[str, str|number]]`.
- `cards` sections require `entries: list[object]`; every card requires non-empty string `title`, optional string `subtitle`, optional `fields: list[[str, str|number]]`, and optional absolute `http(s)` string `url`.
- All scalar display values in sections must be strings or numbers that serialize cleanly to JSON. Normalize booleans, nulls, dates, complex objects, and arrays into strings before writing display fields. Do not emit comments, trailing commas, NaN, Infinity, Python tuples, or non-JSON values.
- All source URLs and item URLs must be original links discovered during the clean run: visited, clicked, or extracted from the live source. Do not fabricate URLs, use screenshots as URLs, or use search-result/snippet URLs unless that page itself is the source.
- For every result item with its own page, include the URL in `cards.entries[].url` or in a dedicated `URL` table column.
- Render important final data in structured sections, not only in prose.
- Empty or partial results must be visible in `report.json` with a summary/list section explaining what was checked and what failed.
## Renderer Requirement
The render step is separate from JSON generation. `final_script.py` must generate only the Task Showcase dataset, not a custom HTML app. Before declaring done, verify that the existing repo Task Showcase renderer can consume the generated `task.json` and `report.json`. The renderer may live outside the installed `webwright` package data, so locate the existing repo file rather than assuming it is importable as package data. If Flask or the renderer file is unavailable, do not install or generate it; validate the JSON files and report the exact renderer command or missing renderer path instead of claiming a completed render smoke test.
The runtime gate checks `self_reflect_result.json`, so do not run the final self_reflection pass until after Task Showcase JSON validation and the renderer smoke test have passed (or Flask unavailability has been handled exactly as described). If you change `final_script.py` or regenerated showcase JSON after self_reflection, rerun the clean final script in a new `final_runs/run_<id+1>/` folder and rerun self_reflection against that same run.
## Completion Gate
Set `"done": true` ONLY if ALL of the following are true:
1. `plan.md` exists and every critical point is enumerated as a checklist item,
with the intended Task Showcase report sections documented.
2. `self_reflect_config.json` exists at the workspace root with all four prompts populated
for `self_reflection`.
3. `final_script.py` was executed successfully from scratch from `{{ workspace_dir }}`
for a new `final_runs/run_<id>/` artifact folder, producing `final_script_log.txt`,
all critical-point screenshots, and the Task Showcase JSON files.
4. `task_showcase/tasks/<short_id>/task.json` exists and validates.
5. `task_showcase/tasks/<short_id>/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_<id>/self_reflect_result.json`
was executed after JSON/render validation, against that same run, exited 0, and wrote
`final_runs/run_<id>/self_reflect_result.json` with `"predicted_label": 1`.
8. You have run `ls -R final_runs/run_<id>`,
`ls -R final_runs/run_<id>/screenshots`, and
`cat final_runs/run_<id>/final_script_log.txt` to confirm the artifacts and
logs are in place.
9. The final response names the generated JSON paths and the render command.
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, if the
script failed to run, if the checklist in `plan.md` is incomplete, if the JSON
files are missing or malformed, if they were written outside `{{ workspace_dir }}`,
or if the task page does not render when Flask is available. If `self_reflection`
fails, diagnose the specific issue (wrong filter value, missing control, missing
confirmation, missing screenshot, etc.), fix `final_script.py`, re-run it in a new
`final_runs/run_<id+1>/` folder, and re-run `self_reflection` against the new run.
Do NOT edit `self_reflect_config.json` between attempts unless a prompt itself is
objectively wrong.
instance_template: |
Task: {{ task }}
{% if task_id %}Task ID: {{ task_id }}
{% endif %}{% if start_url %}Start URL: {{ start_url }}
{% endif %}Workspace root: {{ workspace_dir }}
Task metadata JSON: {{ task_metadata_path }}
Required final script path: {{ final_script_path }}
Generated showcase tasks dir: {{ workspace_dir }}/task_showcase/tasks
<instructions>
# Task Instructions
You're solving a user-specified web task through a stateless local terminal + workspace harness and converting the result into Task Showcase JSON for the generic renderer under `assets/task_showcase`.
<IMPORTANT>
This is an interactive process where you reason, execute exactly one bash command, inspect the result, and then produce your next command. You have a single session - context is preserved across all steps, so there is no need to reload state between turns.
</IMPORTANT>
## Harness Rules
- Work only inside `{{ workspace_dir }}`.
- Keep generated code, screenshots, logs, scratch files, notes, and showcase JSON **only** in `{{ workspace_dir }}`.
- The required final artifact is `{{ final_script_path }}`.
- Create `final_runs/run_<id>/` folders for every clean execution of the final script. Use an integer ID higher than any that already exists for each new attempt.
- Keep the canonical final script at `{{ final_script_path }}`. For each clean run, copy that exact script into `final_runs/run_<id>/final_script.py` and store that run's `final_script_log.txt` and final verification screenshots inside the same run folder.
- Do not create an empty higher-numbered `final_runs/run_<id>/` folder. The latest numbered run must be the run that contains screenshots, logs, showcase JSON output, and the matching `self_reflect_result.json`.
- The required rendered-data artifacts are:
- `{{ workspace_dir }}/task_showcase/tasks/<short_id>/task.json`
- `{{ workspace_dir }}/task_showcase/tasks/<short_id>/report.json`
- Use `{{ task_id }}` as the preferred `<short_id>` when it is present and already URL-safe; otherwise derive a lowercase slug from the task title.
- The browser mode is `{{ browser_mode }}`. Match generated scripts to that mode (Browserbase cloud session vs. local Playwright launch).
## Web Task Rules
- Do not guess UI interactions. Use printed evidence from the current run.
- Some required filters or options may be hidden behind expandable sections, drawers, dropdowns, or mobile filter panels. Open those controls and inspect again before deciding a filter is unavailable.
- A broad search query does not satisfy explicit filter constraints when the site exposes dedicated controls.
- Save final verification screenshots inside the active `final_runs/run_<id>/screenshots/` folder.
- Print concise ARIA snapshots, URLs, titles, visible labels, extracted records, and any extracted state needed for the next step.
## Task Success Criteria
1. Filtered results must be displayed correctly. Missing selection, missing confirmation, or no visible effect = failure.
2. Specific filter conditions ("best," "highest," "cheapest," "latest," "lowest," etc.) must be applied using the filter/sort function.
3. Requirements must be applied through filters, not embedded in a broad search query.
4. Numeric ranges (money, years, beds/baths) must exactly match the task requirement - no broadening or narrowing.
5. Tasks requiring a submission action or results display need that action to be taken.
6. Empty results are OK if the correct action was performed.
7. All explicit filters must use site controls when those controls exist.
8. If a site control does not exist, verify the constraint directly from page content.
9. The final answer data must be represented in `report.json` structured sections, not only in `final_response`.
## 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"`
- Use multiple `--image` flags for combined visual verification.
- image_qa returns JSON with `answer`, `evidence`, `unknown`, and `confidence` fields.
## Recommended Workflow
1. **Planning**: Parse the task into a list of critical points - every explicit constraint, filter, sort, selection, datum, or source requirement that must be satisfied. Write them to `plan.md` as a checklist and add the intended Task Showcase report sections:
```
# Critical Points
- [ ] CP1: <description of constraint/filter/action>
- [ ] CP2: <description of constraint/filter/action>
...
# Report Sections
- <section type>: <title and purpose>
- ...
```
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.
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.
4. **Final script**: Write `final_script.py` at `{{ final_script_path }}`, then execute it from `{{ workspace_dir }}` for a new `final_runs/run_<id>/` artifact folder. The script must copy itself into that run folder, produce screenshots and action logs as described in **Final Script Instrumentation**, collect or refresh the task data, write the two JSON files under `{{ workspace_dir }}/task_showcase/tasks/<short_id>/`, and print the generated paths. The script should create parent directories, overwrite stale JSON for the same short_id, and fail loudly on malformed data.
5. **Validate JSON**: Confirm the clean run wrote:
- `{{ workspace_dir }}/task_showcase/tasks/<short_id>/task.json`
- `{{ workspace_dir }}/task_showcase/tasks/<short_id>/report.json`
Then validate both JSON files with Python:
- `task.json.short_id` exactly matches the `<short_id>` directory name.
- `task.json` has all required metadata keys, with non-empty strings except integer `num_steps`.
- `task.json.website` is an absolute `http(s)` URL.
- `report.json` has `sources`, non-empty `result.headline`, and non-empty `result.sections`.
- `sources` is a list of `{name, url, note?}` objects with absolute `http(s)` URLs.
- Every section type is one of `summary`, `table`, `list`, `kv`, `cards`.
- Every section satisfies the per-type contract from the system prompt, including table row widths and kv/card field pairs.
- Every source and item URL that appears in the result is an original discovered absolute `http(s)` link.
6. **Render smoke test**:
- Locate the existing repo renderer `assets/task_showcase/app.py`. Prefer this resolver:
```
python - <<'PY'
from pathlib import Path
import webwright
starts = [Path.cwd().resolve(), Path(webwright.__file__).resolve()]
candidates = []
for start in starts:
candidates.extend(parent / "assets" / "task_showcase" / "app.py" for parent in [start, *start.parents])
for candidate in candidates:
if candidate.exists():
print(candidate)
break
else:
raise SystemExit("assets/task_showcase/app.py not found from cwd or webwright package path")
PY
```
- Start `python <app.py> --tasks-dir "{{ workspace_dir }}/task_showcase/tasks" --host 127.0.0.1 --port <free_port>`.
- Fetch `/` and `/task/<short_id>` and confirm both return HTTP 200 and the task headline appears in the task page HTML.
- Stop the server before declaring done.
- 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_<id+1>/` 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_<id>/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_<id>/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_<id+1>/` 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.
## Final Script Instrumentation
`final_script.py` must:
- be stored at `{{ final_script_path }}` and copied as `final_runs/run_<id>/final_script.py` for the clean run
- collect or refresh the task data from the relevant source(s)
- save critical-point screenshots as `final_runs/run_<id>/screenshots/final_execution_<step_number>_<action>.png`
- create or reset `final_runs/run_<id>/final_script_log.txt` at the start of each clean run
- write `step <step_number> action: <reason and action description>` to the log for every constraint-relevant interaction
- write `{{ workspace_dir }}/task_showcase/tasks/<short_id>/task.json`
- write `{{ workspace_dir }}/task_showcase/tasks/<short_id>/report.json`
- optionally copy `final_script_log.txt`, `steps.jsonl`, and screenshots into `{{ workspace_dir }}/task_showcase/tasks/<short_id>/` if you want the renderer to display run traces; these copies are not a substitute for the required `final_runs/run_<id>/` artifacts
- print the generated JSON paths
- include enough logging or stdout details to diagnose source access, empty results, and render validation failures
- each screenshot should correspond to a critical point from `plan.md` so that `self_reflection` can verify it
- keep all generated files inside `{{ workspace_dir }}`
This instrumentation is mandatory because both `self_reflection`, the external judge, and the Task Showcase renderer evaluate these artifacts.
## Completion Gate
Set `"done": true` ONLY if ALL of the following are true:
1. `plan.md` exists with all critical points identified and the intended report sections documented.
2. `self_reflect_config.json` exists with all four prompts populated for `self_reflection`.
3. `{{ final_script_path }}` was run from scratch from `{{ workspace_dir }}` for a new `final_runs/run_<id>/` folder, producing the run-local script copy, `final_script_log.txt`, all critical-point screenshots, and the Task Showcase JSON files.
4. `task_showcase/tasks/<short_id>/task.json` exists and validates.
5. `task_showcase/tasks/<short_id>/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_<id>/self_reflect_result.json` was executed after JSON/render validation against that same run, exited 0, and wrote `final_runs/run_<id>/self_reflect_result.json` with `"predicted_label": 1`.
8. `ls -R final_runs/run_<id>`, `ls -R final_runs/run_<id>/screenshots`, and `cat final_runs/run_<id>/final_script_log.txt` confirm the expected artifacts.
9. `final_response` names the generated JSON paths and the render command.
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, if `self_reflection` has not been run against the latest `final_runs/run_<id>/`, if the JSON files are missing or malformed, if they were written outside `{{ workspace_dir }}`, or if the task page does not render when Flask is available.
</instructions>
+24
View File
@@ -0,0 +1,24 @@
from __future__ import annotations
import copy
import importlib
from webwright import Environment
_ENVIRONMENT_MAPPING = {
"local_browser": "webwright.environments.local_browser.LocalBrowserEnvironment",
"local_workspace": "webwright.environments.local_workspace.LocalWorkspaceEnvironment",
}
def get_environment_class(spec: str) -> type[Environment]:
full_path = _ENVIRONMENT_MAPPING.get(spec, spec)
module_name, class_name = full_path.rsplit(".", 1)
module = importlib.import_module(module_name)
return getattr(module, class_name)
def get_environment(config: dict, *, default_type: str = "local_workspace") -> Environment:
copied = copy.deepcopy(config)
environment_class = copied.pop("environment_class", default_type)
return get_environment_class(environment_class)(**copied)
+567
View File
@@ -0,0 +1,567 @@
from __future__ import annotations
import asyncio
import io
import json
import os
import shutil
import subprocess
import sys
import textwrap
import time
import traceback
from contextlib import redirect_stderr, redirect_stdout
from pathlib import Path
from typing import Any
from urllib.parse import quote, urlparse
from urllib.request import ProxyHandler, Request, build_opener
from pydantic import BaseModel, Field, field_validator
_BROWSER_MODES = {"local_cdp", "local_launch", "local_persistent"}
_DEFAULT_LOCAL_CDP_URL = "http://127.0.0.1:9222"
_DEFAULT_LOCAL_CDP_USER_DATA_DIR = Path("~/.cache/webwright/edge-profile")
_CHROMIUM_EXECUTABLE_CANDIDATES = (
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
"~/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"~/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"google-chrome",
"google-chrome-stable",
"chromium-browser",
"chromium",
"chrome",
)
_LOCAL_CDP_OPENER = build_opener(ProxyHandler({}))
def _urlopen_local_cdp(url_or_request: str | Request, *, timeout: float):
return _LOCAL_CDP_OPENER.open(url_or_request, timeout=timeout)
def _local_cdp_origin(cdp_url: str) -> str:
parsed = urlparse(cdp_url or _DEFAULT_LOCAL_CDP_URL)
scheme = parsed.scheme or "http"
netloc = parsed.netloc or parsed.path
if not netloc:
netloc = urlparse(_DEFAULT_LOCAL_CDP_URL).netloc
return f"{scheme}://{netloc}"
def _local_cdp_port(cdp_url: str) -> int:
parsed = urlparse(cdp_url or _DEFAULT_LOCAL_CDP_URL)
if parsed.port is not None:
return parsed.port
return 443 if parsed.scheme == "https" else 80
def _is_local_cdp_available(cdp_url: str, *, timeout_seconds: float = 0.5) -> bool:
try:
with _urlopen_local_cdp(
f"{_local_cdp_origin(cdp_url).rstrip('/')}/json/version",
timeout=timeout_seconds,
) as response:
return 200 <= response.status < 300
except Exception:
return False
def _local_cdp_json_url(cdp_url: str, path: str) -> str:
return f"{_local_cdp_origin(cdp_url).rstrip('/')}{path}"
def _local_cdp_page_targets(cdp_url: str, *, timeout_seconds: float = 0.5) -> list[dict[str, Any]]:
try:
with _urlopen_local_cdp(
_local_cdp_json_url(cdp_url, "/json/list"),
timeout=timeout_seconds,
) as response:
if not 200 <= response.status < 300:
return []
payload = json.loads(response.read().decode("utf-8"))
except Exception:
return []
if not isinstance(payload, list):
return []
return [
target
for target in payload
if isinstance(target, dict) and target.get("type") == "page"
]
def _ensure_local_cdp_page_target(cdp_url: str, *, timeout_seconds: float = 1.0) -> None:
if _local_cdp_page_targets(cdp_url, timeout_seconds=timeout_seconds):
return
target_url = f"{_local_cdp_json_url(cdp_url, '/json/new')}?{quote('about:blank', safe='')}"
request = Request(target_url, method="PUT")
with _urlopen_local_cdp(request, timeout=timeout_seconds) as response:
if not 200 <= response.status < 300:
raise RuntimeError(f"Could not create a local CDP page target: {cdp_url}")
def _resolve_local_cdp_url(configured_url: str, *, explicit: bool) -> str:
if explicit and configured_url:
return configured_url
return (
os.environ.get("LOCAL_BROWSER_CDP_URL")
or os.environ.get("BROWSER_CDP_URL")
or configured_url
or _DEFAULT_LOCAL_CDP_URL
)
def _resolve_user_data_dir(configured_dir: Path, *, explicit: bool) -> Path:
if explicit:
return configured_dir
env_dir = os.environ.get("LOCAL_BROWSER_USER_DATA_DIR") or os.environ.get("BROWSER_USER_DATA_DIR")
return Path(env_dir).expanduser() if env_dir else _DEFAULT_LOCAL_CDP_USER_DATA_DIR.expanduser()
def _find_chromium_executable(explicit_path: str = "") -> str:
candidates = []
if explicit_path:
candidates.append(explicit_path)
candidates.extend(
value
for value in (os.environ.get("LOCAL_BROWSER_EXECUTABLE"), os.environ.get("BROWSER_EXECUTABLE"))
if value
)
candidates.extend(_CHROMIUM_EXECUTABLE_CANDIDATES)
for candidate in candidates:
expanded = os.path.expanduser(candidate)
if Path(expanded).exists():
return expanded
resolved = shutil.which(expanded)
if resolved:
return resolved
raise FileNotFoundError(
"Could not find Chrome/Chromium. Set local_cdp_executable or LOCAL_BROWSER_EXECUTABLE."
)
def _macos_open_app_name(executable: str) -> str:
if sys.platform != "darwin":
return ""
if "/Microsoft Edge.app/" in executable:
return "Microsoft Edge"
if "/Google Chrome.app/" in executable:
return "Google Chrome"
return ""
class LocalBrowserEnvironmentConfig(BaseModel):
start_url: str | None = None
browser_mode: str = "local_launch"
headless: bool = False
devtools: bool = False
keep_open_on_exit: bool = False
prompt_before_close: bool = False
slow_mo_ms: int = 50
browser_width: int = 1280
browser_height: int = 1440
browser_timeout_ms: int = 10000
browser_navigation_timeout_ms: int = 30000
step_execution_timeout_ms: int = 20000
observation_timeout_ms: int = 5000
output_dir: Path = Path("outputs/default")
user_data_dir: Path = _DEFAULT_LOCAL_CDP_USER_DATA_DIR
launch_args: list[str] = Field(default_factory=list)
local_cdp_url: str = _DEFAULT_LOCAL_CDP_URL
local_cdp_new_page: bool = True
local_cdp_close_page_on_exit: bool = False
local_cdp_auto_start: bool = True
local_cdp_executable: str = ""
local_cdp_startup_timeout_seconds: float = 10
local_cdp_close_started_browser_on_exit: bool = False
@field_validator("browser_mode")
@classmethod
def validate_browser_mode(cls, value: str) -> str:
normalized = value.strip().lower().replace("-", "_")
if normalized not in _BROWSER_MODES:
raise ValueError(
f"browser_mode must be one of: {', '.join(sorted(_BROWSER_MODES))}"
)
return normalized
class LocalBrowserEnvironment:
"""Live local Playwright browser environment.
The environment owns the browser/page and executes each model action as an async
Python snippet with ``page``, ``context``, ``browser``, ``playwright``, and
``task`` already available.
"""
def __init__(self, *, config_class: type = LocalBrowserEnvironmentConfig, **kwargs):
self.config = config_class(**kwargs)
fields_set = getattr(self.config, "model_fields_set", None)
if fields_set is None:
fields_set = getattr(self.config, "__fields_set__", set())
self._config_fields_set = set(fields_set)
self.config.local_cdp_url = _resolve_local_cdp_url(
self.config.local_cdp_url,
explicit="local_cdp_url" in self._config_fields_set,
)
self.config.output_dir = self.config.output_dir.expanduser()
self.config.user_data_dir = _resolve_user_data_dir(
self.config.user_data_dir,
explicit="user_data_dir" in self._config_fields_set,
)
self._playwright = None
self._browser = None
self._context = None
self._page = None
self._local_cdp_page = None
self._loop: asyncio.AbstractEventLoop | None = None
self._local_cdp_process: subprocess.Popen | None = None
self._connected_over_cdp = False
self._step_index = 0
self._prepared_task: dict[str, Any] = {}
self._console_history: list[str] = []
self._step_console: list[str] = []
self._step_python_code = ""
self._step_python_output = ""
def _screenshots_dir(self) -> Path:
return self.config.output_dir / "screenshots"
def _steps_dir(self) -> Path:
return self.config.output_dir / "steps"
def prepare(self, **kwargs) -> None:
self._prepared_task = dict(kwargs)
self._step_index = 0
self._console_history = []
self._step_console = []
start_url = kwargs.get("start_url") or self.config.start_url
if start_url:
self.config.start_url = str(start_url)
self.config.output_dir.mkdir(parents=True, exist_ok=True)
self._steps_dir().mkdir(parents=True, exist_ok=True)
self._screenshots_dir().mkdir(parents=True, exist_ok=True)
(self.config.output_dir / "task.json").write_text(
json.dumps(kwargs, indent=2),
encoding="utf-8",
)
self._run(self._prepare_async())
def _ensure_loop(self) -> asyncio.AbstractEventLoop:
if self._loop is None or self._loop.is_closed():
self._loop = asyncio.new_event_loop()
return self._loop
def _run(self, coro):
loop = self._ensure_loop()
return loop.run_until_complete(coro)
def _ensure_local_cdp_browser(self) -> None:
if _is_local_cdp_available(self.config.local_cdp_url):
return
if not self.config.local_cdp_auto_start:
raise RuntimeError(
"Local CDP endpoint is not reachable. Start Chrome/Chromium with "
f"remote debugging enabled for {self.config.local_cdp_url}."
)
self.config.user_data_dir.mkdir(parents=True, exist_ok=True)
browser_args = list(self.config.launch_args)
executable = _find_chromium_executable(self.config.local_cdp_executable)
browser_flags = [
"--remote-debugging-address=127.0.0.1",
f"--remote-debugging-port={_local_cdp_port(self.config.local_cdp_url)}",
f"--user-data-dir={self.config.user_data_dir}",
"--no-first-run",
"--no-default-browser-check",
*browser_args,
]
app_name = _macos_open_app_name(executable)
launched_with_open = bool(app_name)
command = (
["open", "-na", app_name, "--args", *browser_flags]
if launched_with_open
else [executable, *browser_flags]
)
self._local_cdp_process = subprocess.Popen(
command,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
)
deadline = time.monotonic() + self.config.local_cdp_startup_timeout_seconds
while time.monotonic() < deadline:
if not launched_with_open and self._local_cdp_process.poll() is not None:
raise RuntimeError(
f"Chrome/Chromium exited before CDP became available: {self.config.local_cdp_url}"
)
if _is_local_cdp_available(self.config.local_cdp_url):
return
time.sleep(0.2)
self._local_cdp_process.terminate()
self._local_cdp_process = None
raise TimeoutError(f"Timed out waiting for local CDP endpoint: {self.config.local_cdp_url}")
async def _prepare_async(self) -> None:
from playwright.async_api import async_playwright
if self._page is not None and self._context is not None:
return
self._playwright = await async_playwright().start()
chromium = self._playwright.chromium
launch_args = list(self.config.launch_args)
if self.config.devtools:
launch_args.append("--auto-open-devtools-for-tabs")
launch_kwargs = {
"headless": self.config.headless,
"slow_mo": self.config.slow_mo_ms,
"args": launch_args,
}
if self.config.browser_mode == "local_cdp":
self._ensure_local_cdp_browser()
_ensure_local_cdp_page_target(self.config.local_cdp_url)
self._browser = await chromium.connect_over_cdp(self.config.local_cdp_url)
self._connected_over_cdp = True
self._context = (
self._browser.contexts[0]
if self._browser.contexts
else await self._browser.new_context(no_viewport=True)
)
if self.config.local_cdp_new_page or not self._context.pages:
self._page = await self._context.new_page()
self._local_cdp_page = self._page
else:
self._page = self._context.pages[0]
elif self.config.browser_mode == "local_persistent":
self.config.user_data_dir.mkdir(parents=True, exist_ok=True)
self._context = await chromium.launch_persistent_context(
user_data_dir=str(self.config.user_data_dir),
viewport={
"width": self.config.browser_width,
"height": self.config.browser_height,
},
**launch_kwargs,
)
self._browser = self._context.browser
self._page = self._context.pages[0] if self._context.pages else await self._context.new_page()
else:
self._browser = await chromium.launch(**launch_kwargs)
self._context = await self._browser.new_context(
viewport={
"width": self.config.browser_width,
"height": self.config.browser_height,
}
)
self._page = await self._context.new_page()
self._context.set_default_timeout(self.config.browser_timeout_ms)
self._context.set_default_navigation_timeout(self.config.browser_navigation_timeout_ms)
self._attach_page_listeners(self._page)
if self.config.start_url:
await self._page.goto(self.config.start_url, wait_until="domcontentloaded")
def _attach_page_listeners(self, page: Any) -> None:
page.on("console", self._on_console_message)
page.on("pageerror", self._on_page_error)
def _on_console_message(self, message: Any) -> None:
text = getattr(message, "text", "")
if callable(text):
text = text()
line = str(text)
self._console_history.append(line)
self._step_console.append(line)
def _on_page_error(self, error: Any) -> None:
line = f"Page error: {error}"
self._console_history.append(line)
self._step_console.append(line)
def execute(self, action: dict[str, Any], cwd: str = "") -> dict[str, Any]:
del cwd
return self._run(self._execute_async(action))
async def _execute_async(self, action: dict[str, Any]) -> dict[str, Any]:
self._step_index += 1
self._step_console = []
self._step_python_output = ""
self._step_python_code = str(action.get("python_code", "") or "")
self._persist_step_code(self._step_python_code)
success = True
exception_text = ""
try:
if self._step_python_code.strip():
await asyncio.wait_for(
self._run_python_code(self._step_python_code),
timeout=self.config.step_execution_timeout_ms / 1000,
)
await self._wait_for_observation_ready()
except Exception:
success = False
exception_text = traceback.format_exc()
observation = await self._capture_observation(
success=success,
exception_text=exception_text,
)
return {
"output": self._step_python_output,
"returncode": 0 if success else 1,
"exception_info": exception_text,
"observation": observation,
}
def _persist_step_code(self, python_code: str) -> None:
step_path = self._steps_dir() / f"step_{self._step_index:04d}.py"
step_path.write_text(python_code, encoding="utf-8")
script_path = self.config.output_dir / "script.py"
with script_path.open("a", encoding="utf-8") as handle:
handle.write(f"\n\n# Step {self._step_index}\n")
handle.write(python_code)
handle.write("\n")
async def _run_python_code(self, python_code: str) -> None:
if self._page is None or self._context is None or self._playwright is None:
raise RuntimeError("Browser environment was not prepared.")
buffer = io.StringIO()
globals_dict: dict[str, Any] = {"asyncio": asyncio}
locals_dict: dict[str, Any] = {}
wrapped = "async def __agent_step__(page, context, browser, playwright, task):\n"
wrapped += textwrap.indent(python_code, " ")
with redirect_stdout(buffer), redirect_stderr(buffer):
exec(wrapped, globals_dict, locals_dict)
await locals_dict["__agent_step__"](
self._page,
self._context,
self._browser,
self._playwright,
self._prepared_task,
)
self._step_python_output = buffer.getvalue()
async def _wait_for_observation_ready(self) -> None:
if self._page is None:
return
try:
await self._page.wait_for_load_state(
"domcontentloaded",
timeout=self.config.observation_timeout_ms,
)
except Exception:
pass
async def _capture_observation(self, *, success: bool, exception_text: str) -> dict[str, Any]:
page = self._page
url = ""
title = ""
aria_snapshot = ""
screenshot_path: Path | None = None
if page is not None:
try:
url = page.url
except Exception:
url = self.config.start_url or ""
try:
title = await page.title()
except Exception:
title = ""
try:
aria_snapshot = await page.locator("body").aria_snapshot(
timeout=self.config.observation_timeout_ms,
)
except Exception:
aria_snapshot = ""
try:
screenshot_path = self._screenshots_dir() / f"step_{self._step_index:04d}.png"
await page.screenshot(path=str(screenshot_path), full_page=False)
except Exception:
screenshot_path = None
return {
"success": success,
"exception": exception_text,
"url": url or self.config.start_url or "",
"title": title,
"screenshot_path": str(screenshot_path) if screenshot_path is not None else "",
"aria_snapshot": aria_snapshot,
"python_code": self._step_python_code,
"python_output": self._step_python_output,
"console_output": "\n".join(self._step_console[-20:]),
"recent_console": "\n".join(self._console_history[-50:]),
}
def get_template_vars(self, **kwargs) -> dict[str, Any]:
return {
"start_url": self.config.start_url or "",
"output_dir": str(self.config.output_dir.resolve()),
"browser_mode": self.config.browser_mode,
"user_data_dir": str(self.config.user_data_dir),
**kwargs,
}
def serialize(self) -> dict[str, Any]:
return {
"environment": {
"config": self.config.model_dump(mode="json"),
"environment_type": f"{self.__class__.__module__}.{self.__class__.__name__}",
}
}
def close(self) -> None:
if self.config.prompt_before_close:
input("Press Enter to close the browser...")
if self.config.keep_open_on_exit:
return
try:
self._run(self._close_async())
finally:
if self._loop is not None and not self._loop.is_closed():
self._loop.close()
self._loop = None
async def _close_async(self) -> None:
context = self._context
browser = self._browser
page = self._local_cdp_page
playwright = self._playwright
connected_over_cdp = self._connected_over_cdp
local_cdp_process = self._local_cdp_process
self._page = None
self._local_cdp_page = None
self._context = None
self._browser = None
self._playwright = None
self._connected_over_cdp = False
self._local_cdp_process = None
try:
if connected_over_cdp:
if page is not None and self.config.local_cdp_close_page_on_exit:
try:
await page.close()
except Exception:
pass
if (
local_cdp_process is not None
and self.config.local_cdp_close_started_browser_on_exit
):
local_cdp_process.terminate()
elif context is not None:
await context.close()
elif browser is not None:
await browser.close()
finally:
if playwright is not None:
await playwright.stop()
@@ -0,0 +1,296 @@
from __future__ import annotations
import json
import os
import re
import subprocess
from pathlib import Path
from typing import Any
from pydantic import BaseModel, Field
_EXPORT_RE = re.compile(r"^export\s+([A-Za-z_][A-Za-z0-9_]*)=(.*)$")
_IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".webp"}
class LocalWorkspaceEnvironmentConfig(BaseModel):
"""Shell-based workspace environment.
The agent drives a real browser through bash commands it generates inside this
workspace. Two browser modes are exposed to those generated scripts via
environment variables:
* ``browser_mode = "browserbase"`` (default): the agent's scripts should
create a Browserbase cloud session. ``BROWSERBASE_API_KEY`` and
``BROWSERBASE_PROJECT_ID`` are forwarded if present.
* ``browser_mode = "local"``: the agent's scripts should launch a local
Playwright browser (``playwright.chromium.launch(...)``).
The selected mode is forwarded to the subprocess via ``BROWSER_MODE`` so the
generated scripts can branch on it.
"""
start_url: str | None = None
output_dir: Path = Path("outputs/sandbox/default")
command_timeout_seconds: int = 180
shell: str = "/bin/bash"
env: dict[str, str] = Field(default_factory=dict)
credentials_file: Path | None = None
browser_mode: str = "browserbase" # "browserbase" or "local"
task_metadata_filename: str = "task.json"
final_script_name: str = "final_script.py"
output_truncation_chars: int = 12000
final_script_preview_chars: int = 4000
recent_files_limit: int = 40
class LocalWorkspaceEnvironment:
def __init__(self, *, config_class: type = LocalWorkspaceEnvironmentConfig, **kwargs):
self.config = config_class(**kwargs)
self.config.output_dir = self.config.output_dir.expanduser()
self._step_index = 0
self._prepared_task: dict[str, Any] = {}
self._credential_env = self._load_credential_env(self.config.credentials_file)
def _load_credential_env(self, path: Path | None) -> dict[str, str]:
if path is None:
return {}
resolved = Path(path).expanduser()
if not resolved.exists():
return {}
parsed: dict[str, str] = {}
for raw_line in resolved.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line or line.startswith("#"):
continue
match = _EXPORT_RE.match(line)
if match is None:
continue
key, raw_value = match.groups()
value = raw_value.strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
value = value[1:-1]
parsed[key] = value
return parsed
def _workspace_dir(self) -> Path:
return self.config.output_dir.resolve()
def _resolve_cwd(self, cwd: str = "") -> Path:
workspace_dir = self._workspace_dir()
if not cwd:
return workspace_dir
candidate = Path(cwd)
if not candidate.is_absolute():
candidate = workspace_dir / candidate
resolved = candidate.resolve()
try:
resolved.relative_to(workspace_dir)
except ValueError as exc:
raise ValueError(f"Command cwd must stay inside workspace: {resolved}") from exc
return resolved
def _task_metadata_path(self) -> Path:
return self._workspace_dir() / self.config.task_metadata_filename
def _final_script_path(self) -> Path:
return self._workspace_dir() / self.config.final_script_name
def _steps_dir(self) -> Path:
return self._workspace_dir() / "steps"
def _logs_dir(self) -> Path:
return self._workspace_dir() / "logs"
def _screenshots_dir(self) -> Path:
return self._workspace_dir() / "screenshots"
def _history_path(self) -> Path:
return self._workspace_dir() / "command_history.sh"
def _truncate(self, text: str, limit: int) -> str:
if len(text) <= limit:
return text
omitted = len(text) - limit
return f"{text[:limit]}\n\n... [{omitted} characters omitted]"
def _recent_workspace_files(self) -> list[str]:
workspace_dir = self._workspace_dir()
files: list[Path] = [path for path in workspace_dir.rglob("*") if path.is_file()]
files.sort(key=lambda path: path.stat().st_mtime, reverse=True)
recent = files[: self.config.recent_files_limit]
return [str(path.relative_to(workspace_dir)) for path in recent]
def _recent_screenshots(self) -> list[Path]:
screenshots_dir = self._screenshots_dir()
if not screenshots_dir.exists():
return []
files = [path for path in screenshots_dir.rglob("*") if path.is_file() and path.suffix.lower() in _IMAGE_SUFFIXES]
files.sort(key=lambda path: path.stat().st_mtime, reverse=True)
return files
def _persist_step_command(self, command: str) -> Path:
self._steps_dir().mkdir(parents=True, exist_ok=True)
step_path = self._steps_dir() / f"step_{self._step_index:04d}.sh"
step_path.write_text(command.rstrip() + "\n", encoding="utf-8")
history_path = self._history_path()
with history_path.open("a", encoding="utf-8") as handle:
handle.write(f"# Step {self._step_index}\n")
handle.write(command.rstrip())
handle.write("\n\n")
return step_path
def _write_step_log(self, output: str) -> Path | None:
if not output:
return None
self._logs_dir().mkdir(parents=True, exist_ok=True)
log_path = self._logs_dir() / f"step_{self._step_index:04d}.log"
log_path.write_text(output, encoding="utf-8")
return log_path
def prepare(self, **kwargs) -> None:
self._prepared_task = dict(kwargs)
self._step_index = 0
start_url = kwargs.get("start_url") or self.config.start_url
if start_url:
self.config.start_url = str(start_url)
workspace_dir = self._workspace_dir()
workspace_dir.mkdir(parents=True, exist_ok=True)
self._steps_dir().mkdir(parents=True, exist_ok=True)
self._logs_dir().mkdir(parents=True, exist_ok=True)
self._screenshots_dir().mkdir(parents=True, exist_ok=True)
(workspace_dir / ".tmp").mkdir(parents=True, exist_ok=True)
self._task_metadata_path().write_text(json.dumps(kwargs, indent=2), encoding="utf-8")
def _browser_env(self) -> dict[str, str]:
"""Forward Browserbase / browser-mode hints to the subprocess."""
env: dict[str, str] = {"BROWSER_MODE": str(self.config.browser_mode or "browserbase")}
for var in ("BROWSERBASE_API_KEY", "BROWSERBASE_PROJECT_ID"):
value = self._credential_env.get(var) or os.environ.get(var)
if value:
env[var] = value
return env
def execute(self, action: dict[str, Any], cwd: str = "") -> dict[str, Any]:
self._step_index += 1
command = str(
action.get("command") or action.get("bash_command") or action.get("python_code") or ""
).strip()
self._persist_step_command(command)
resolved_cwd = self._resolve_cwd(cwd)
command_env = os.environ | self._credential_env | self._browser_env() | self.config.env | {
"WORKSPACE_DIR": str(self._workspace_dir()),
"OM2W_TASK_JSON": str(self._task_metadata_path()),
"FINAL_SCRIPT_PATH": str(self._final_script_path()),
"TMPDIR": str(self._workspace_dir() / ".tmp"),
}
try:
result = subprocess.run(
command,
shell=True,
executable=self.config.shell,
text=True,
cwd=resolved_cwd,
env=command_env,
timeout=self.config.command_timeout_seconds,
encoding="utf-8",
errors="replace",
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
)
output = result.stdout
returncode = result.returncode
exception_info = ""
except Exception as exc:
raw_output = getattr(exc, "output", None)
output = raw_output.decode("utf-8", errors="replace") if isinstance(raw_output, bytes) else (raw_output or "")
returncode = -1
exception_info = f"An error occurred while executing the command: {exc}"
log_path = self._write_step_log(output)
observation = self._capture_observation(
command=command,
cwd=resolved_cwd,
output=output,
returncode=returncode,
exception_info=exception_info,
log_path=log_path,
)
return {
"output": output,
"returncode": returncode,
"exception_info": exception_info,
"observation": observation,
}
def _capture_observation(
self,
*,
command: str,
cwd: Path,
output: str,
returncode: int,
exception_info: str,
log_path: Path | None,
) -> dict[str, Any]:
final_script_path = self._final_script_path()
recent_screenshot_paths = self._recent_screenshots()
latest_screenshot = recent_screenshot_paths[0] if recent_screenshot_paths else None
final_script_preview = ""
if final_script_path.exists():
final_script_preview = self._truncate(
final_script_path.read_text(encoding="utf-8", errors="replace"),
self.config.final_script_preview_chars,
)
workspace_dir = self._workspace_dir()
recent_screenshots = [str(path.relative_to(workspace_dir)) for path in recent_screenshot_paths[:10]]
return {
"success": returncode == 0 and not exception_info,
"exception": exception_info,
"command": command,
"returncode": returncode,
"workspace_dir": str(workspace_dir),
"cwd": str(cwd),
"url": self.config.start_url or "",
"title": "",
"aria_snapshot": "",
"console_output": "",
"recent_console": "",
"command_output": self._truncate(output, self.config.output_truncation_chars),
"log_path": str(log_path) if log_path is not None else "",
"task_metadata_path": str(self._task_metadata_path()),
"final_script_path": str(final_script_path) if final_script_path.exists() else "",
"final_script_exists": final_script_path.exists(),
"final_script_preview": final_script_preview,
"screenshot_path": str(latest_screenshot) if latest_screenshot is not None else "",
"recent_screenshots": recent_screenshots,
"workspace_files": self._recent_workspace_files(),
}
def get_template_vars(self, **kwargs) -> dict[str, Any]:
return {
"start_url": self.config.start_url or "",
"output_dir": str(self._workspace_dir()),
"workspace_dir": str(self._workspace_dir()),
"task_metadata_path": str(self._task_metadata_path()),
"final_script_path": str(self._final_script_path()),
"browser_mode": self.config.browser_mode,
**kwargs,
}
def serialize(self) -> dict:
return {
"environment": {
"config": self.config.model_dump(mode="json"),
"environment_type": f"{self.__class__.__module__}.{self.__class__.__name__}",
"workspace_dir": str(self._workspace_dir()),
}
}
def close(self) -> None:
return None
+19
View File
@@ -0,0 +1,19 @@
from __future__ import annotations
class InterruptAgentFlow(Exception):
def __init__(self, *messages: dict):
super().__init__()
self.messages = list(messages)
class LimitsExceeded(InterruptAgentFlow):
pass
class Submitted(InterruptAgentFlow):
pass
class FormatError(InterruptAgentFlow):
pass
+25
View File
@@ -0,0 +1,25 @@
from __future__ import annotations
import copy
import importlib
from webwright import Model
_MODEL_MAPPING = {
"openai": "webwright.models.openai_model.OpenAIModel",
"anthropic": "webwright.models.anthropic_model.AnthropicModel",
"openrouter": "webwright.models.openrouter_model.OpenRouterModel",
}
def get_model_class(spec: str) -> type[Model]:
full_path = _MODEL_MAPPING.get(spec, spec)
module_name, class_name = full_path.rsplit(".", 1)
module = importlib.import_module(module_name)
return getattr(module, class_name)
def get_model(config: dict, *, default_type: str = "openai") -> Model:
copied = copy.deepcopy(config)
model_class = copied.pop("model_class", default_type)
return get_model_class(model_class)(**copied)
+192
View File
@@ -0,0 +1,192 @@
"""Anthropic (Claude) Messages API model backend."""
from __future__ import annotations
import asyncio
import random
from typing import Any
from webwright.models.base import (
BaseModel,
BaseModelConfig,
OptStr,
_safe_int,
)
# Anthropic-specific retry limits (independent of OpenAI defaults). The Claude
# Opus org-level ITPM cap can saturate for minutes at high concurrency, so we
# allow many more retries and longer backoffs.
MAX_RATE_LIMIT_RETRIES = 50
MAX_TRANSIENT_GATEWAY_RETRIES = 20
RATE_LIMIT_BACKOFF_MIN_SECONDS = 30.0
RATE_LIMIT_BACKOFF_MAX_SECONDS = 60.0
TRANSIENT_BACKOFF_BASE_SECONDS = 1.5
TRANSIENT_BACKOFF_CAP_SECONDS = 60.0
def _retry_after_seconds(exc: BaseException) -> float | None:
response = getattr(exc, "response", None)
if response is None:
return None
header = response.headers.get("retry-after") if getattr(response, "headers", None) else None
if not header:
return None
try:
return max(0.0, float(header))
except (TypeError, ValueError):
return None
def _image_source_from_url(image_url: str) -> dict[str, Any]:
if image_url.startswith("data:"):
header, _, encoded = image_url.partition(",")
media_type = header.split(";")[0].removeprefix("data:") or "image/png"
return {"type": "base64", "media_type": media_type, "data": encoded}
return {"type": "url", "url": image_url}
def _serialize_anthropic_content_part(part: dict[str, Any]) -> dict[str, Any]:
if part.get("type") == "input_image":
return {"type": "image", "source": _image_source_from_url(part.get("image_url", ""))}
return {"type": "text", "text": part.get("text", "")}
def _serialize_anthropic_messages(
messages: list[dict[str, Any]],
) -> tuple[str | None, list[dict[str, Any]]]:
system_chunks: list[str] = []
serialized: list[dict[str, Any]] = []
for message in messages:
role = message["role"]
if role == "exit":
continue
content = message.get("content", "")
if role == "system":
if isinstance(content, str):
if content:
system_chunks.append(content)
else:
for part in content:
if isinstance(part, dict) and part.get("type") != "input_image":
text = part.get("text", "")
if text:
system_chunks.append(text)
continue
if isinstance(content, str):
serialized.append({"role": role, "content": content})
continue
parts = [_serialize_anthropic_content_part(p) for p in content if isinstance(p, dict)]
if parts and all(p.get("type") == "text" for p in parts):
serialized.append({"role": role, "content": "\n".join(p["text"] for p in parts)})
else:
serialized.append({"role": role, "content": parts})
system_prompt = "\n\n".join(system_chunks) if system_chunks else None
return system_prompt, serialized
def _extract_anthropic_text(payload: dict[str, Any]) -> str:
texts: list[str] = []
for block in payload.get("content") or []:
if isinstance(block, dict) and block.get("type") == "text":
text = block.get("text", "")
if text:
texts.append(text)
return "\n".join(texts)
def _usage_from_anthropic_payload(payload: dict[str, Any]) -> dict[str, int]:
usage = payload.get("usage") or {}
input_tokens = _safe_int(usage.get("input_tokens"))
output_tokens = _safe_int(usage.get("output_tokens"))
cached_input_tokens = _safe_int(usage.get("cache_read_input_tokens"))
return {
"input_tokens": input_tokens,
"output_tokens": output_tokens,
"total_tokens": input_tokens + output_tokens,
"cached_input_tokens": cached_input_tokens,
"reasoning_output_tokens": 0,
}
def _metrics_input_from_anthropic(
system_prompt: str | None, anthropic_messages: list[dict[str, Any]]
) -> list[dict[str, Any]]:
items: list[dict[str, Any]] = []
if system_prompt:
items.append({"content": [{"type": "input_text", "text": system_prompt}]})
for msg in anthropic_messages:
content = msg.get("content", "")
if isinstance(content, str):
items.append({"content": [{"type": "input_text", "text": content}]})
continue
parts: list[dict[str, Any]] = []
for part in content:
if not isinstance(part, dict):
continue
if part.get("type") == "text":
parts.append({"type": "input_text", "text": part.get("text", "")})
elif part.get("type") == "image":
parts.append({"type": "input_image"})
items.append({"content": parts})
return items
class AnthropicModelConfig(BaseModelConfig):
model_name: OptStr = "claude-opus-4-7"
anthropic_api_key: OptStr = ""
anthropic_endpoint: OptStr = "https://api.anthropic.com/v1/messages"
anthropic_version: OptStr = "2023-06-01"
max_output_tokens: int = 8000
class AnthropicModel(BaseModel):
_API_KEY_FIELD = "anthropic_api_key"
_ENV_VAR = "ANTHROPIC_API_KEY"
_LOG_SOURCE = "anthropic"
_MAX_RATE_LIMIT_RETRIES = MAX_RATE_LIMIT_RETRIES
_MAX_TRANSIENT_RETRIES = MAX_TRANSIENT_GATEWAY_RETRIES
_DEFAULT_CONFIG_CLASS = AnthropicModelConfig
def _request_headers(self) -> dict[str, str]:
return {
"Content-Type": "application/json",
"x-api-key": self.config.anthropic_api_key,
"anthropic-version": self.config.anthropic_version,
}
def _post_url(self) -> str:
return self.config.anthropic_endpoint
async def _rate_limit_backoff(self, attempt: int, exc: BaseException) -> None:
delay = random.uniform(RATE_LIMIT_BACKOFF_MIN_SECONDS, RATE_LIMIT_BACKOFF_MAX_SECONDS)
retry_after = _retry_after_seconds(exc)
if retry_after is not None and retry_after > delay:
delay = min(retry_after, RATE_LIMIT_BACKOFF_MAX_SECONDS * 2)
await asyncio.sleep(delay)
async def _transient_backoff(self, attempt: int, exc: BaseException) -> None:
await asyncio.sleep(
min(TRANSIENT_BACKOFF_BASE_SECONDS * (2 ** attempt), TRANSIENT_BACKOFF_CAP_SECONDS)
)
def _build_payload(self, messages: list[dict[str, Any]]) -> dict[str, Any]:
system_prompt, anth_messages = _serialize_anthropic_messages(messages)
payload: dict[str, Any] = {
"model": self.config.model_name,
"messages": anth_messages,
"max_tokens": self.config.max_output_tokens,
}
if system_prompt:
payload["system"] = system_prompt
return payload
def _request_metrics_input(self, payload: dict[str, Any]) -> list[dict[str, Any]]:
return _metrics_input_from_anthropic(payload.get("system"), payload.get("messages") or [])
def _extract_text(self, payload: dict[str, Any]) -> str:
return _extract_anthropic_text(payload)
def _usage_metrics_from_payload(self, payload: dict[str, Any]) -> dict[str, int]:
return _usage_from_anthropic_payload(payload)
+587
View File
@@ -0,0 +1,587 @@
from __future__ import annotations
import asyncio
import base64
import json
import mimetypes
import os
import subprocess
from pathlib import Path
from typing import Annotated, Any
import httpx
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
def _none_to_str(value: Any) -> str:
return "" if value is None else str(value)
# String field that coerces None -> "" and any value -> str via pydantic.
OptStr = Annotated[str, BeforeValidator(_none_to_str)]
MAX_JSON_PARSE_RETRIES = 3
DEFAULT_OBSERVATION_TEMPLATE = """Observation:
Status: {{ 'ok' if observation.success else 'error' }}
URL: {{ observation.url }}
Title: {{ observation.title }}
{% if observation.exception %}Exception:
{{ observation.exception }}
{% endif %}{% if observation.console_output %}Console output:
{{ observation.console_output }}
{% endif %}{% if observation.aria_snapshot %}ARIA snapshot:
{{ observation.aria_snapshot }}
{% endif %}{% if observation.screenshot_path %}Screenshot path: {{ observation.screenshot_path }}
{% endif %}"""
DEFAULT_FORMAT_ERROR_TEMPLATE = """Format error:
{{ error }}
Please respond with strict JSON using exactly these fields:
- thought: short reasoning about the next step
- bash_command: exactly one shell command for local-workspace tasks
- python_code: exactly one async Python browser step for local-browser tasks
- done: boolean indicating whether the task is complete
- final_response: final natural-language answer when done, otherwise empty
"""
ACTION_FIELDS = {"bash_command", "python_code"}
def _is_rate_limit_error(exc: BaseException | None) -> bool:
current: BaseException | None = exc
while current is not None:
status_code = getattr(current, "status_code", None)
if status_code == 429:
return True
response = getattr(current, "response", None)
if getattr(response, "status_code", None) == 429:
return True
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
return False
def _is_transient_http_error(exc: BaseException | None) -> bool:
"""True for retryable transient HTTP failures (timeouts, 5xx, conn resets, ...).
Applies to any HTTP backend, not just gateway/proxy setups.
"""
current: BaseException | None = exc
while current is not None:
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}:
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}:
return True
text = str(current).lower()
if any(
needle in text
for needle in (
"bad gateway",
"gateway timeout",
"server disconnected",
"temporary failure",
"temporarily unavailable",
"connection reset",
"connection aborted",
"timed out",
)
):
return True
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]:
try:
parsed = json.loads(raw)
except json.JSONDecodeError as exc:
raise ValueError(f"Unable to parse JSON output: {exc}") from exc
if not isinstance(parsed, dict):
raise ValueError("Model output was JSON but not a JSON object.")
# Strict-schema responses cannot have done=true with a non-empty action;
# tolerate it from non-strict callers by demoting `done`.
action_text = str(parsed.get(action_field, "") or "").strip()
if action_text and bool(parsed.get("done", False)):
parsed = dict(parsed)
parsed["done"] = False
return parsed
def _validate_bash_command(command: str) -> None:
result = subprocess.run(
["/bin/bash", "-n"],
input=command,
text=True,
capture_output=True,
encoding="utf-8",
errors="replace",
check=False,
)
if result.returncode == 0:
return
error = (result.stderr or result.stdout or "bash syntax check failed").strip()
raise ValueError(f"Invalid bash_command syntax: {error}")
def text_part(text: str) -> dict[str, Any]:
return {"type": "input_text", "text": text}
def image_part_from_path(path: Path) -> dict[str, Any]:
mime_type, _ = mimetypes.guess_type(str(path))
encoded = base64.b64encode(path.read_bytes()).decode("ascii")
return {
"type": "input_image",
"image_url": f"data:{mime_type or 'image/png'};base64,{encoded}",
"detail": "high",
}
def _safe_int(value: Any) -> int:
try:
return int(value)
except (TypeError, ValueError):
return 0
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
text_chars = 0
for item in serialized_input:
for content in item.get("content") or []:
if not isinstance(content, dict):
continue
part_type = content.get("type")
if part_type in {"input_text", "output_text"}:
text_part_count += 1
text_chars += len(str(content.get("text", "") or ""))
elif part_type == "input_image":
image_part_count += 1
serialized_chars = len(json.dumps(serialized_input, ensure_ascii=False))
return {
"message_count": message_count,
"text_part_count": text_part_count,
"image_part_count": image_part_count,
"text_chars": text_chars,
"serialized_chars": serialized_chars,
}
_REQUEST_METRIC_KEYS = (
"message_count",
"text_part_count",
"image_part_count",
"text_chars",
"serialized_chars",
)
_USAGE_METRIC_KEYS = (
"input_tokens",
"output_tokens",
"total_tokens",
"cached_input_tokens",
"reasoning_output_tokens",
)
class BaseModelConfig(PydanticBaseModel):
"""Fields common to every model backend (OpenAI, Anthropic, ...)."""
model_name: OptStr = ""
max_output_tokens: int = 4000
request_timeout_seconds: int = 120
error_log_path: Path | None = None
observation_template: OptStr = DEFAULT_OBSERVATION_TEMPLATE
format_error_template: OptStr = DEFAULT_FORMAT_ERROR_TEMPLATE
attach_observation_screenshot: bool = True
action_field: str = "bash_command"
@field_validator("action_field")
@classmethod
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))}")
return normalized
class BaseModel:
"""Provider-agnostic model backend.
Subclasses must override:
- class constants ``_API_KEY_FIELD``, ``_ENV_VAR``, ``_LOG_SOURCE``,
``_DEFAULT_CONFIG_CLASS`` (and optionally ``_MAX_RATE_LIMIT_RETRIES``,
``_MAX_TRANSIENT_RETRIES``)
- ``_request_headers``, ``_post_url``
- ``_build_payload``, ``_request_metrics_input``
- ``_extract_text``, ``_usage_metrics_from_payload``
Optionally override ``_rate_limit_backoff`` / ``_transient_backoff`` for
custom retry timing.
"""
_API_KEY_FIELD: str = ""
_ENV_VAR: str = ""
_LOG_SOURCE: str = ""
_MAX_RATE_LIMIT_RETRIES: int = 5
_MAX_TRANSIENT_RETRIES: int = 5
_DEFAULT_CONFIG_CLASS: type = BaseModelConfig
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_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_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, ""))
if not getattr(self.config, self._API_KEY_FIELD, ""):
raise RuntimeError(f"Missing {self._ENV_VAR}.")
# ---- subclass extension points ------------------------------------------------
def _request_headers(self) -> dict[str, str]:
raise NotImplementedError
def _post_url(self) -> str:
raise NotImplementedError
def _build_payload(self, messages: list[dict[str, Any]]) -> dict[str, Any]:
raise NotImplementedError
def _build_text_payload(self, messages: list[dict[str, Any]]) -> dict[str, Any]:
return self._build_payload(messages)
def _request_metrics_input(self, payload: dict[str, Any]) -> list[dict[str, Any]]:
raise NotImplementedError
def _extract_text(self, payload: dict[str, Any]) -> str:
raise NotImplementedError
def _usage_metrics_from_payload(self, payload: dict[str, Any]) -> dict[str, int]:
raise NotImplementedError
async def _rate_limit_backoff(self, attempt: int, exc: BaseException) -> None:
await asyncio.sleep(min(5 * (attempt + 1), 30))
async def _transient_backoff(self, attempt: int, exc: BaseException) -> None:
await asyncio.sleep(min(2 * (attempt + 1), 10))
# ---- shared infrastructure ----------------------------------------------------
def get_template_vars(self, **kwargs) -> dict[str, Any]:
vars: dict[str, Any] = {
"action_field": self.config.action_field,
"model_name": self.config.model_name,
}
for k, v in self._last_request_metrics.items():
vars[f"last_request_{k}"] = v
for k, v in self._last_usage_metrics.items():
vars[f"last_request_{k}"] = v
for k, v in self._cumulative_request_metrics.items():
vars[f"cumulative_request_{k}"] = v
for k, v in self._cumulative_usage_metrics.items():
vars[f"cumulative_{k}"] = v
vars.update(kwargs)
return vars
def _response_schema(self) -> dict[str, Any]:
action_field = self.config.action_field
return {
"type": "object",
"additionalProperties": False,
"properties": {
"thought": {"type": "string"},
action_field: {"type": "string"},
"done": {"type": "boolean"},
"final_response": {"type": "string"},
},
"required": ["thought", action_field, "done", "final_response"],
}
def _usage_snapshot(self) -> dict[str, dict[str, int]]:
return {
"last_request": {
"message_count": self._last_request_metrics["message_count"],
"text_part_count": self._last_request_metrics["text_part_count"],
"image_part_count": self._last_request_metrics["image_part_count"],
"input_tokens": self._last_usage_metrics["input_tokens"],
"cached_input_tokens": self._last_usage_metrics["cached_input_tokens"],
},
"last_response": dict(self._last_usage_metrics),
"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"],
"input_tokens": self._cumulative_usage_metrics["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:
response = getattr(error, "response", None)
response_text = ""
if response is not None:
try:
response_text = str(getattr(response, "text", "") or "")
except Exception:
response_text = ""
if len(response_text) > 4000:
response_text = response_text[:4000]
append_runtime_log(
self.config.error_log_path,
source=self._LOG_SOURCE,
event=event,
model_name=self.config.model_name,
endpoint=self._post_url(),
attempt=attempt,
error_type=type(error).__name__,
error=str(error),
status_code=getattr(response, "status_code", None),
response_text=response_text,
)
def _raw_response_log_path(self) -> Path | None:
if self.config.error_log_path is None:
return None
return self.config.error_log_path.parent / "raw_responses.jsonl"
def format_message(self, **kwargs) -> dict[str, Any]:
role = kwargs["role"]
content = kwargs.get("content", "")
extra = kwargs.get("extra", {})
return {"role": role, "content": content, "extra": extra}
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]]:
observation_messages: list[dict[str, Any]] = []
for output in outputs:
observation = output.get("observation", {})
content = Template(self.config.observation_template, undefined=StrictUndefined).render(
output=output,
observation=observation,
**(template_vars or {}),
)
parts: list[dict[str, Any]] = [text_part(content)]
screenshot_path = observation.get("screenshot_path")
if self.config.attach_observation_screenshot and screenshot_path:
parts.append(image_part_from_path(Path(screenshot_path)))
observation_messages.append(
self.format_message(role="user", content=parts, extra={"observation": observation})
)
return observation_messages
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(
error=error,
model_response=raw_text,
**self.get_template_vars(),
),
extra={
"interrupt_type": "FormatError",
"model_response": raw_text,
},
)
)
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(
error=error,
model_response=raw_text,
**self.get_template_vars(),
),
extra={
"interrupt_type": "FormatErrorRetry",
"model_response": raw_text,
},
)
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):
try:
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()
except Exception as exc:
if _is_rate_limit_error(exc):
self._log_gateway_error(
event="rate_limit_error", attempt=attempt + 1, error=exc
)
if attempt >= self._MAX_RATE_LIMIT_RETRIES:
raise
await self._rate_limit_backoff(attempt, exc)
continue
if _is_transient_http_error(exc):
self._log_gateway_error(
event="transient_http_error", attempt=attempt + 1, error=exc
)
if attempt >= self._MAX_TRANSIENT_RETRIES:
raise
await self._transient_backoff(attempt, exc)
continue
self._log_gateway_error(
event="fatal_gateway_error", attempt=attempt + 1, error=exc
)
raise
raise RuntimeError("Exceeded retry budget without exception or success.")
async def _query_async(self, messages: list[dict[str, Any]]) -> dict[str, Any]:
last_error: ValueError | None = None
raw_text = ""
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))
self._last_request_metrics = dict(request_metrics)
for key, value in request_metrics.items():
self._cumulative_request_metrics[key] += value
response_payload = await self._post_with_retries(payload)
usage_metrics = self._usage_metrics_from_payload(response_payload)
self._last_usage_metrics = dict(usage_metrics)
for key, value in usage_metrics.items():
self._cumulative_usage_metrics[key] += value
raw_text = self._extract_text(response_payload)
append_runtime_log(
self._raw_response_log_path(),
source="model",
event="raw_text",
attempt=attempt_index + 1,
raw_text=raw_text,
)
try:
parsed = parse_json_output(raw_text, action_field=self.config.action_field)
break
except ValueError as exc:
last_error = exc
if attempt_index < MAX_JSON_PARSE_RETRIES:
request_messages.append(
self._format_repair_message(raw_text=raw_text, error=str(exc))
)
else:
raise self._format_error(
raw_text=raw_text,
error=str(last_error or ValueError("Unable to parse model output.")),
)
actions: list[dict[str, Any]] = []
action_field = self.config.action_field
action_text = str(parsed.get(action_field, "") or "").strip()
if action_text:
action = {action_field: action_text, "command": action_text}
if action_field == "bash_command":
action["bash_command"] = action_text
try:
_validate_bash_command(action_text)
except ValueError as exc:
raise self._format_error(raw_text=raw_text, error=str(exc))
else:
action["python_code"] = action_text
actions.append(action)
return self.format_message(
role="assistant",
content=parsed.get("thought", ""),
extra={
"actions": actions,
"done": bool(parsed.get("done", False)),
"final_response": parsed.get("final_response", ""),
"raw_response": parsed,
"usage": self._usage_snapshot(),
},
)
async def _complete_text_async(
self,
messages: list[dict[str, Any]],
*,
max_output_tokens: int | None = None,
) -> str:
original_max_output_tokens = self.config.max_output_tokens
if max_output_tokens is not None:
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))
self._last_request_metrics = dict(request_metrics)
for key, value in request_metrics.items():
self._cumulative_request_metrics[key] += value
response_payload = await self._post_with_retries(payload)
usage_metrics = self._usage_metrics_from_payload(response_payload)
self._last_usage_metrics = dict(usage_metrics)
for key, value in usage_metrics.items():
self._cumulative_usage_metrics[key] += value
raw_text = self._extract_text(response_payload)
append_runtime_log(
self._raw_response_log_path(),
source="model",
event="raw_text",
raw_text=raw_text,
)
return raw_text
finally:
self.config.max_output_tokens = original_max_output_tokens
def __call__(
self,
messages: list[dict[str, Any]],
**kwargs: Any,
) -> str:
return run_async(self._complete_text_async(messages, **kwargs))
def query(self, messages: list[dict[str, Any]], **kwargs) -> dict[str, Any]:
return run_async(self._query_async(messages))
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] = "<redacted>"
return {
"model": {
"config": config_dump,
"usage": {
**self._usage_snapshot(),
},
"model_type": f"{self.__class__.__module__}.{self.__class__.__name__}",
}
}
+157
View File
@@ -0,0 +1,157 @@
"""OpenAI Responses API model backend."""
from __future__ import annotations
from pathlib import Path
from typing import Any
from webwright.models.base import (
BaseModel,
BaseModelConfig,
OptStr,
_safe_int,
image_part_from_path,
text_part,
)
__all__ = [
"OpenAIModel",
"OpenAIModelConfig",
"_extract_response_text",
"text_part",
]
def _serialize_response_content_part(part: dict[str, Any], *, role: str) -> dict[str, Any]:
if part.get("type") == "input_image":
return {
"type": "input_image",
"image_url": part.get("image_url", ""),
"detail": part.get("detail", "high"),
}
text = part.get("text", "")
if role == "assistant":
return {"type": "output_text", "text": text}
return {"type": "input_text", "text": text}
def _serialize_response_input(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
serialized: list[dict[str, Any]] = []
for message in messages:
role = message["role"]
if role == "exit":
continue
content = message.get("content", "")
if isinstance(content, str):
serialized_content = [text_part(content)]
else:
serialized_content = [part for part in content if isinstance(part, dict)]
# The Responses API accepts "developer" for system-style instructions.
mapped_role = "developer" if role == "system" else role
serialized.append(
{
"type": "message",
"role": mapped_role,
"content": [
_serialize_response_content_part(part, role=mapped_role)
for part in serialized_content
],
}
)
return serialized
def _extract_response_text(payload: dict[str, Any]) -> str:
output_text = payload.get("output_text")
if isinstance(output_text, str) and output_text:
return output_text
texts: list[str] = []
for item in payload.get("output") or []:
if not isinstance(item, dict):
continue
if item.get("type") != "message":
continue
for content in item.get("content") or []:
if not isinstance(content, dict):
continue
if isinstance(content.get("text"), str):
texts.append(content["text"])
elif isinstance(content.get("output_text"), str):
texts.append(content["output_text"])
return "\n".join(texts)
def _usage_metrics_from_response_payload(payload: dict[str, Any]) -> dict[str, int]:
usage = payload.get("usage")
if not isinstance(usage, dict):
usage = {}
input_details = usage.get("input_tokens_details")
if not isinstance(input_details, dict):
input_details = {}
output_details = usage.get("output_tokens_details")
if not isinstance(output_details, dict):
output_details = {}
return {
"input_tokens": _safe_int(usage.get("input_tokens")),
"output_tokens": _safe_int(usage.get("output_tokens")),
"total_tokens": _safe_int(usage.get("total_tokens")),
"cached_input_tokens": _safe_int(input_details.get("cached_tokens")),
"reasoning_output_tokens": _safe_int(output_details.get("reasoning_tokens")),
}
class OpenAIModelConfig(BaseModelConfig):
model_name: OptStr = "gpt-4o"
openai_api_key: OptStr = ""
openai_endpoint: OptStr = "https://api.openai.com/v1/responses"
class OpenAIModel(BaseModel):
_API_KEY_FIELD = "openai_api_key"
_ENV_VAR = "OPENAI_API_KEY"
_LOG_SOURCE = "openai"
_MAX_RATE_LIMIT_RETRIES = 5
_MAX_TRANSIENT_RETRIES = 5
_DEFAULT_CONFIG_CLASS = OpenAIModelConfig
def _request_headers(self) -> dict[str, str]:
return {
"Content-Type": "application/json",
"Authorization": f"Bearer {self.config.openai_api_key}",
}
def _post_url(self) -> str:
return self.config.openai_endpoint
def _build_payload(self, messages: list[dict[str, Any]]) -> dict[str, Any]:
return {
"model": self.config.model_name,
"input": _serialize_response_input(messages),
"max_output_tokens": self.config.max_output_tokens,
"text": {
"format": {
"type": "json_schema",
"name": "playwright_step",
"schema": self._response_schema(),
"strict": True,
}
},
}
def _build_text_payload(self, messages: list[dict[str, Any]]) -> dict[str, Any]:
return {
"model": self.config.model_name,
"input": _serialize_response_input(messages),
"max_output_tokens": self.config.max_output_tokens,
}
def _request_metrics_input(self, payload: dict[str, Any]) -> list[dict[str, Any]]:
return payload.get("input") or []
def _extract_text(self, payload: dict[str, Any]) -> str:
return _extract_response_text(payload)
def _usage_metrics_from_payload(self, payload: dict[str, Any]) -> dict[str, int]:
return _usage_metrics_from_response_payload(payload)
+206
View File
@@ -0,0 +1,206 @@
"""OpenRouter chat completions model backend."""
from __future__ import annotations
from typing import Any
from urllib.parse import urlparse
from webwright.models.base import (
BaseModel,
BaseModelConfig,
OptStr,
_safe_int,
)
__all__ = [
"OpenRouterModel",
"OpenRouterModelConfig",
]
def _serialize_chat_content_part(part: dict[str, Any]) -> dict[str, Any] | None:
part_type = part.get("type")
if part_type in {"input_text", "output_text"}:
return {"type": "text", "text": str(part.get("text", "") or "")}
if part_type == "input_image":
return {
"type": "image_url",
"image_url": {
"url": str(part.get("image_url", "") or ""),
"detail": str(part.get("detail", "high") or "high"),
},
}
return None
def _serialize_chat_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
serialized: list[dict[str, Any]] = []
for message in messages:
role = message["role"]
if role == "exit":
continue
mapped_role = "system" if role == "system" else ("assistant" if role == "assistant" else "user")
content = message.get("content", "")
if isinstance(content, str):
serialized.append({"role": mapped_role, "content": content})
continue
parts = [
serialized_part
for part in content
if isinstance(part, dict)
for serialized_part in [_serialize_chat_content_part(part)]
if serialized_part is not None
]
if mapped_role == "assistant" or all(part.get("type") == "text" for part in parts):
serialized.append(
{
"role": mapped_role,
"content": "\n".join(str(part.get("text", "") or "") for part in parts),
}
)
else:
serialized.append({"role": mapped_role, "content": parts})
return serialized
def _metrics_input_from_chat_messages(chat_messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
metrics_input: list[dict[str, Any]] = []
for message in chat_messages:
content = message.get("content", "")
if isinstance(content, str):
metrics_input.append({"content": [{"type": "input_text", "text": content}]})
continue
parts: list[dict[str, Any]] = []
for part in content:
if not isinstance(part, dict):
continue
if part.get("type") == "text":
parts.append({"type": "input_text", "text": str(part.get("text", "") or "")})
elif part.get("type") == "image_url":
parts.append({"type": "input_image"})
metrics_input.append({"content": parts})
return metrics_input
def _extract_chat_completions_text(payload: dict[str, Any]) -> str:
choices = payload.get("choices")
if not isinstance(choices, list) or not choices:
return ""
first_choice = choices[0]
if not isinstance(first_choice, dict):
return ""
message = first_choice.get("message", {})
if not isinstance(message, dict):
return ""
content = message.get("content", "")
if isinstance(content, str):
return content
if isinstance(content, list):
return "\n".join(
str(part.get("text", "") or "")
for part in content
if isinstance(part, dict) and part.get("type") == "text"
)
return ""
def _usage_metrics_from_chat_completions(payload: dict[str, Any]) -> dict[str, int]:
usage = payload.get("usage")
if not isinstance(usage, dict):
usage = {}
return {
"input_tokens": _safe_int(usage.get("prompt_tokens")),
"output_tokens": _safe_int(usage.get("completion_tokens")),
"total_tokens": _safe_int(usage.get("total_tokens")),
"cached_input_tokens": 0,
"reasoning_output_tokens": 0,
}
def _endpoint_host(endpoint: str) -> str:
return (urlparse(endpoint).hostname or "").lower()
def _is_openai_endpoint(endpoint: str) -> bool:
return _endpoint_host(endpoint) == "api.openai.com"
def _is_openrouter_endpoint(endpoint: str) -> bool:
return _endpoint_host(endpoint).endswith("openrouter.ai")
class OpenRouterModelConfig(BaseModelConfig):
model_name: OptStr = "openai/gpt-5.4"
openrouter_api_key: OptStr = ""
openrouter_endpoint: OptStr = "https://openrouter.ai/api/v1/chat/completions"
http_referer: OptStr = ""
app_title: OptStr = ""
provider_require_parameters: bool = True
class OpenRouterModel(BaseModel):
_API_KEY_FIELD = "openrouter_api_key"
_ENV_VAR = "OPENROUTER_API_KEY"
_LOG_SOURCE = "openrouter"
_MAX_RATE_LIMIT_RETRIES = 5
_MAX_TRANSIENT_RETRIES = 5
_DEFAULT_CONFIG_CLASS = OpenRouterModelConfig
def _request_headers(self) -> dict[str, str]:
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {self.config.openrouter_api_key}",
}
if _is_openrouter_endpoint(self.config.openrouter_endpoint) and self.config.http_referer:
headers["HTTP-Referer"] = self.config.http_referer
if _is_openrouter_endpoint(self.config.openrouter_endpoint) and self.config.app_title:
headers["X-Title"] = self.config.app_title
return headers
def _post_url(self) -> str:
return self.config.openrouter_endpoint
def _build_payload(self, messages: list[dict[str, Any]]) -> dict[str, Any]:
payload: dict[str, Any] = {
"model": self.config.model_name,
"messages": _serialize_chat_messages(messages),
"stream": False,
"response_format": {
"type": "json_schema",
"json_schema": {
"name": "playwright_step",
"strict": True,
"schema": self._response_schema(),
},
},
}
if _is_openai_endpoint(self.config.openrouter_endpoint) and self.config.model_name.startswith("gpt-5"):
payload["max_completion_tokens"] = self.config.max_output_tokens
else:
payload["max_tokens"] = self.config.max_output_tokens
if self.config.provider_require_parameters and _is_openrouter_endpoint(self.config.openrouter_endpoint):
payload["provider"] = {"require_parameters": True}
return payload
def _build_text_payload(self, messages: list[dict[str, Any]]) -> dict[str, Any]:
payload: dict[str, Any] = {
"model": self.config.model_name,
"messages": _serialize_chat_messages(messages),
"stream": False,
}
if _is_openai_endpoint(self.config.openrouter_endpoint) and self.config.model_name.startswith("gpt-5"):
payload["max_completion_tokens"] = self.config.max_output_tokens
else:
payload["max_tokens"] = self.config.max_output_tokens
if self.config.provider_require_parameters and _is_openrouter_endpoint(self.config.openrouter_endpoint):
payload["provider"] = {"require_parameters": True}
return payload
def _request_metrics_input(self, payload: dict[str, Any]) -> list[dict[str, Any]]:
return _metrics_input_from_chat_messages(payload.get("messages") or [])
def _extract_text(self, payload: dict[str, Any]) -> str:
return _extract_chat_completions_text(payload)
def _usage_metrics_from_payload(self, payload: dict[str, Any]) -> dict[str, int]:
return _usage_metrics_from_chat_completions(payload)
View File
+175
View File
@@ -0,0 +1,175 @@
from __future__ import annotations
from datetime import datetime
from pathlib import Path
from typing import Any
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
DEFAULT_CONFIGS = ["base.yaml", "model_openai.yaml"]
app = typer.Typer(no_args_is_help=True)
console = Console(highlight=False)
def _timestamped_output_dir(base_dir: str | Path | None, task_id: str | None) -> Path:
base = Path(base_dir or "outputs").expanduser()
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
suffix = task_id or "adhoc"
return base / f"{suffix}_{stamp}"
def run_one(
*,
task: str | None = None,
task_id: str | None = None,
start_url: str | None = None,
config_spec: list[str] | None = None,
output_dir: Path | None = None,
resolved_output_dir: Path | None = None,
debug: bool = False,
snapshot_config: bool = True,
) -> Any:
config_spec = config_spec or DEFAULT_CONFIGS
configs = [get_config_from_spec(spec) for spec in config_spec]
config = recursive_merge(*configs)
run_config = config.get("run", {})
resolved_task_id = task_id or run_config.get("task_id")
resolved_task = task or run_config.get("task")
resolved_start_url = start_url or run_config.get("start_url")
if not resolved_task:
raise ValueError("A task is required. Use --task.")
resolved_output_dir = resolved_output_dir or _timestamped_output_dir(
output_dir or config.get("environment", {}).get("output_dir") or "outputs",
resolved_task_id,
)
if snapshot_config:
snapshot_config_specs(config_spec, resolved_output_dir, merged_config=config)
config = recursive_merge(
config,
{
"run": {
"task": resolved_task,
"task_id": resolved_task_id or UNSET,
"start_url": resolved_start_url or UNSET,
},
"environment": {
"output_dir": str(resolved_output_dir),
"start_url": resolved_start_url or UNSET,
"headless": False if debug else UNSET,
"devtools": True if debug else UNSET,
"keep_open_on_exit": True if debug else UNSET,
"prompt_before_close": True if debug else UNSET,
"slow_mo_ms": 250 if debug else UNSET,
},
"model": {
"error_log_path": str(resolved_output_dir / "runtime_errors.jsonl"),
},
"agent": {
"output_path": str(resolved_output_dir / "trajectory.json"),
},
},
)
model = get_model(config.get("model", {}))
env = get_environment(config.get("environment", {}))
agent = get_agent(model, env, config.get("agent", {}), default_type="default")
console.print(f"Running task in [bold green]{resolved_output_dir}[/bold green]")
run_exception: Exception | None = None
close_exception: Exception | None = None
result: dict[str, Any] = {}
try:
env.prepare(
task=resolved_task,
task_id=resolved_task_id,
start_url=resolved_start_url,
)
result = agent.run(
resolved_task,
task_id=resolved_task_id or "",
start_url=resolved_start_url or "",
)
except Exception as exc:
run_exception = exc
if getattr(agent, "messages", None):
result = dict(agent.messages[-1].get("extra", {}))
result.setdefault("exit_status", type(exc).__name__)
result.setdefault("submission", "")
result.setdefault("final_response", "")
result["run_exception"] = str(exc)
finally:
try:
env.close()
except Exception as exc:
close_exception = exc
result.setdefault("exit_status", type(exc).__name__)
result.setdefault("submission", "")
result.setdefault("final_response", "")
result.setdefault("run_exception", str(exc))
result["close_exception"] = str(exc)
if run_exception is None:
run_exception = exc
result["_output_dir"] = str(resolved_output_dir)
if close_exception is not None:
result["_close_exception"] = str(close_exception)
console.print(
result.get("final_response") or result.get("submission") or "Task finished."
)
if run_exception is not None:
raise run_exception
return result
@app.command()
def main(
task: str = typer.Option(
..., "-t", "--task", help="Natural language task description."
),
task_id: str | None = typer.Option(
None, "--task-id", help="Optional identifier used in the output directory name."
),
start_url: str | None = typer.Option(
None, "--start-url", help="Optional starting URL for the task."
),
config_spec: list[str] = typer.Option(DEFAULT_CONFIGS, "-c", "--config"),
output_dir: Path | None = typer.Option(None, "-o", "--output-dir"),
debug: bool = typer.Option(
False,
"--debug",
help="Launch headed local Playwright with devtools and keep it open for inspection.",
),
) -> Any:
return run_one(
task=task,
task_id=task_id,
start_url=start_url,
config_spec=config_spec,
output_dir=output_dir,
debug=debug,
)
@app.command()
def doctor():
"""
Validate local Webwright setup.
"""
run_doctor()
if __name__ == "__main__":
app()
+147
View File
@@ -0,0 +1,147 @@
from __future__ import annotations
import os
import subprocess
import sys
from importlib.util import find_spec
from pathlib import Path
from rich.console import Console
from rich.table import Table
console = Console()
def check_python():
version = sys.version_info
if version >= (3, 10):
return True, f"Python {version.major}.{version.minor}"
return False, ("Python 3.10+ required\nFix: install Python 3.10 or newer")
def check_playwright():
if find_spec("playwright") is not None:
return True, "playwright installed"
return False, ("playwright not installed\nFix: pip install playwright")
def check_chromium():
try:
result = subprocess.run(
["playwright", "install", "--dry-run"],
capture_output=True,
text=True,
)
if result.returncode == 0:
return True, "chromium available"
return False, ("chromium missing\nFix: playwright install chromium")
except Exception as e:
return False, str(e)
def check_screenshot():
try:
from playwright.sync_api import sync_playwright
screenshot_path = Path("doctor_test.png")
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()
page.set_content("<h1>Webwright Doctor</h1>")
page.screenshot(path=str(screenshot_path))
browser.close()
if screenshot_path.exists():
screenshot_path.unlink(missing_ok=True)
return True, "screenshot capture working"
return False, "screenshot file was not created"
except Exception:
return False, (
"unable to launch Chromium for screenshot validation\n"
"Fix: playwright install"
)
def check_openai_key():
if os.getenv("OPENAI_API_KEY"):
return True, "OPENAI_API_KEY found"
return False, (
"OPENAI_API_KEY missing\nFix: set the OPENAI_API_KEY environment variable"
)
def check_plugin_manifests():
claude = Path(".claude-plugin/plugin.json")
codex = Path(".codex-plugin/plugin.json")
missing = []
if not claude.exists():
missing.append("Claude")
if not codex.exists():
missing.append("Codex")
if not missing:
return True, "plugin manifests found"
return False, (
f"missing plugin manifests: {', '.join(missing)}\n"
"Fix: configure Claude/Codex plugins"
)
CHECKS = [
("Python", check_python),
("Playwright", check_playwright),
("Chromium", check_chromium),
("Screenshot", check_screenshot),
("OpenAI Key", check_openai_key),
("Plugins", check_plugin_manifests),
]
def run_doctor():
table = Table(title="Webwright Doctor")
table.add_column("Check")
table.add_column("Status")
table.add_column("Details")
passed = 0
for name, fn in CHECKS:
ok, message = fn()
status = "PASS" if ok else "FAIL"
table.add_row(
name,
status,
message,
)
if ok:
passed += 1
console.print(table)
console.print(f"\n{passed}/{len(CHECKS)} checks passed")
if __name__ == "__main__":
run_doctor()
+6
View File
@@ -0,0 +1,6 @@
python -m 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
View File
+77
View File
@@ -0,0 +1,77 @@
"""Resolve the model client used by inner tools (image_qa, self_reflection).
The CLI snapshots the fully merged run config to
``<workspace_dir>/config_snapshot/merged_config.yaml``; the tools read that file
(or an explicit ``--model-config`` override) and instantiate the same model the
agent uses.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
import yaml
from webwright.models import get_model
DEFAULT_MERGED_CONFIG_RELPATH = Path("config_snapshot") / "merged_config.yaml"
def _load_structured_config(path: Path) -> dict[str, Any]:
text = path.read_text(encoding="utf-8")
if path.suffix.lower() in {".yaml", ".yml"}:
loaded = yaml.safe_load(text)
else:
loaded = json.loads(text)
if not isinstance(loaded, dict):
raise ValueError(f"Model config must be an object: {path}")
return loaded
def _extract_model_block(config: dict[str, Any]) -> dict[str, Any]:
model_block = config.get("model")
if not isinstance(model_block, dict):
raise ValueError(
"Model config is missing a top-level `model:` block; "
"stack a model_*.yaml (e.g. model_claude.yaml) or pass --model-config <path>."
)
return model_block
def resolve_model_config_path(model_config_arg: str, *, workspace_dir: str) -> Path:
"""Return the path to a config containing a top-level ``model:`` block.
Resolution order:
1. ``model_config_arg`` (absolute or relative to ``workspace_dir``).
2. ``<workspace_dir>/config_snapshot/merged_config.yaml`` (written by the CLI).
"""
candidates: list[Path] = []
if model_config_arg:
configured = Path(model_config_arg)
candidates.append(configured)
if workspace_dir and not configured.is_absolute():
candidates.append(Path(workspace_dir) / configured)
if workspace_dir:
candidates.append(Path(workspace_dir) / DEFAULT_MERGED_CONFIG_RELPATH)
for candidate in candidates:
if candidate.exists():
return candidate.resolve()
raise FileNotFoundError(
"No tool model config found. Pass --model-config <path> or run via the agent so "
f"<workspace-dir>/{DEFAULT_MERGED_CONFIG_RELPATH} is available."
)
def load_tool_model(
*,
model_config_arg: str,
workspace_dir: str,
timeout_seconds: int,
) -> Any:
config_path = resolve_model_config_path(model_config_arg, workspace_dir=workspace_dir)
config = _load_structured_config(config_path)
model_block = dict(_extract_model_block(config))
model_block["request_timeout_seconds"] = timeout_seconds
return get_model(model_block)
+141
View File
@@ -0,0 +1,141 @@
from __future__ import annotations
import argparse
import base64
import json
import mimetypes
from pathlib import Path
from typing import Any
from webwright.models.base import text_part
from webwright.tools._model_config import load_tool_model
def _build_prompt(question: str) -> str:
return (
"Answer the user's question using only visible evidence from the provided image or images. "
"If the answer is not visible, say so instead of guessing. Return only a JSON object with "
"string `answer`, string array `evidence`, boolean `unknown`, and number `confidence`.\n\n"
f"Question: {question.strip()}"
)
def _high_detail_image_part_from_path(image_path: Path) -> dict[str, Any]:
mime_type, _ = mimetypes.guess_type(str(image_path))
encoded = base64.b64encode(image_path.read_bytes()).decode("ascii")
return {
"type": "input_image",
"image_url": f"data:{mime_type or 'image/png'};base64,{encoded}",
"detail": "high",
}
def _resolve_image_path(image_path: str, workspace_dir: str = "") -> Path:
path = Path(image_path)
if not path.is_absolute():
base_dir = Path(workspace_dir) if workspace_dir else Path.cwd()
path = base_dir / path
path = path.resolve()
if not path.exists():
raise FileNotFoundError(f"Image path does not exist: {path}")
return path
def _normalize_image_paths(
*,
image_path: Path | None = None,
image_paths: list[Path] | tuple[Path, ...] | None = None,
) -> list[Path]:
normalized = list(image_paths or [])
if image_path is not None:
normalized.insert(0, image_path)
if not normalized:
raise ValueError("At least one image path is required.")
return normalized
def _parse_json_response(raw_text: str) -> dict[str, Any]:
try:
parsed = json.loads(raw_text)
except json.JSONDecodeError:
start = raw_text.find("{")
end = raw_text.rfind("}")
if start == -1 or end == -1 or end <= start:
raise
parsed = json.loads(raw_text[start : end + 1])
if not isinstance(parsed, dict):
raise ValueError("image_qa model response must be a JSON object.")
return parsed
def run_image_qa(
*,
image_path: Path | None = None,
image_paths: list[Path] | tuple[Path, ...] | None = None,
question: str,
model_client: Any,
) -> dict[str, Any]:
resolved_image_paths = _normalize_image_paths(image_path=image_path, image_paths=image_paths)
raw_text = model_client(
[
{
"role": "user",
"content": [text_part(_build_prompt(question))]
+ [_high_detail_image_part_from_path(path) for path in resolved_image_paths],
}
],
max_output_tokens=32000,
).strip()
parsed = _parse_json_response(raw_text)
result = {
"image_paths": [str(path) for path in resolved_image_paths],
"question": question,
**parsed,
}
if len(resolved_image_paths) == 1:
result["image_path"] = str(resolved_image_paths[0])
return result
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Ask a visual question about a local image and print JSON.")
parser.add_argument(
"--image",
required=True,
action="append",
help="Path to an image file. Repeat --image to include multiple images.",
)
parser.add_argument("--question", required=True, help="Question to answer from the image.")
parser.add_argument("--workspace-dir", default="", help="Optional base directory for relative image paths.")
parser.add_argument(
"--model-config",
default="",
help=(
"Path to a JSON/YAML config containing a top-level `model:` block. "
"If omitted, reads <workspace-dir>/config_snapshot/merged_config.yaml."
),
)
parser.add_argument("--timeout-seconds", type=int, default=60, help="HTTP request timeout.")
return parser
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
image_paths = [_resolve_image_path(image_path, workspace_dir=args.workspace_dir) for image_path in args.image]
model_client = load_tool_model(
model_config_arg=args.model_config,
workspace_dir=args.workspace_dir,
timeout_seconds=args.timeout_seconds,
)
result = run_image_qa(
image_paths=image_paths,
question=args.question,
model_client=model_client,
)
print(json.dumps(result, ensure_ascii=True, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,314 @@
"""CLI for managing a long-lived, reusable local Chromium browser session.
Local-browser counterpart to ``browserbase_session.py``. Launches a
detached headless Chromium subprocess via the Playwright-bundled binary
with ``--remote-debugging-port=0`` and a per-session ``--user-data-dir``,
parses the printed ``DevTools listening on ws://...`` URL, and persists
``{id, pid, connectUrl, userDataDir}`` to a JSON file on disk so any
later Python/bash step can attach via
``playwright.chromium.connect_over_cdp(connectUrl)`` and end with
``await browser.disconnect()`` (NEVER ``browser.close()``) to keep the
browser alive across steps.
Subcommands:
* ``create`` -> spawn detached Chromium, write JSON, print id.
* ``info`` -> print whether the saved PID is still alive plus
the persisted JSON.
* ``release`` -> SIGTERM (then SIGKILL) the PID, optionally remove
the user-data-dir and the JSON file.
Usage:
python -m webwright.tools.persistent_local_browser create --workspace-dir <ws> --out .lb_session.json
python -m webwright.tools.persistent_local_browser info --workspace-dir <ws> --session-file .lb_session.json
python -m webwright.tools.persistent_local_browser release --workspace-dir <ws> --session-file .lb_session.json --delete-file
"""
from __future__ import annotations
import argparse
import json
import os
import re
import shutil
import signal
import subprocess
import sys
import time
import uuid
from pathlib import Path
DEFAULT_SESSION_FILE = ".lb_session.json"
DEFAULT_USER_DATA_SUBDIR = ".lb_user_data"
_DEVTOOLS_RE = re.compile(r"DevTools listening on (ws://\S+)")
def _resolve_path(path_str: str, workspace_dir: str = "") -> Path:
path = Path(path_str)
if not path.is_absolute():
base = Path(workspace_dir) if workspace_dir else Path.cwd()
path = base / path
return path
def _chromium_executable() -> str:
"""Locate the Playwright-bundled Chromium executable."""
try:
from playwright.sync_api import sync_playwright
except ImportError as exc: # pragma: no cover - import guard
raise SystemExit(f"error: playwright is not installed: {exc}")
with sync_playwright() as p:
path = p.chromium.executable_path
if not path or not Path(path).exists():
raise SystemExit(
"error: Playwright chromium binary not found. Run `playwright install chromium`."
)
return path
def _pid_alive(pid: int) -> bool:
if pid <= 0:
return False
try:
os.kill(pid, 0)
except ProcessLookupError:
return False
except PermissionError:
return True
return True
def _wait_for_devtools_url(proc: subprocess.Popen, timeout: float) -> str:
"""Read Chromium's stderr until the DevTools ws:// URL appears."""
assert proc.stderr is not None
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if proc.poll() is not None:
tail = ""
try:
tail = proc.stderr.read() or ""
except Exception: # noqa: BLE001
pass
raise SystemExit(
f"error: chromium exited early (code={proc.returncode}); stderr tail:\n{tail}"
)
line = proc.stderr.readline()
if not line:
time.sleep(0.05)
continue
match = _DEVTOOLS_RE.search(line)
if match:
return match.group(1).strip()
raise SystemExit(
f"error: timed out after {timeout:.1f}s waiting for 'DevTools listening on ws://...' line"
)
def _cmd_create(args: argparse.Namespace) -> int:
workspace_dir = args.workspace_dir or str(Path.cwd())
out_path = _resolve_path(args.out, workspace_dir)
user_data_dir = _resolve_path(
args.user_data_dir or DEFAULT_USER_DATA_SUBDIR, workspace_dir
)
out_path.parent.mkdir(parents=True, exist_ok=True)
user_data_dir.mkdir(parents=True, exist_ok=True)
chromium = _chromium_executable()
chromium_args = [
chromium,
"--remote-debugging-port=0",
f"--user-data-dir={user_data_dir}",
"--no-first-run",
"--no-default-browser-check",
"--disable-features=TranslateUI,MediaRouter",
f"--window-size={args.window_width},{args.window_height}",
]
if args.headless:
chromium_args.append("--headless=new")
if args.no_sandbox:
chromium_args.append("--no-sandbox")
chromium_args.extend(args.chromium_arg or [])
# Detach into its own process group so it survives the parent shell exit.
popen_kwargs: dict = {
"stdout": subprocess.DEVNULL,
"stderr": subprocess.PIPE,
"stdin": subprocess.DEVNULL,
"text": True,
"bufsize": 1,
"close_fds": True,
}
if os.name == "posix":
popen_kwargs["start_new_session"] = True
proc = subprocess.Popen(chromium_args, **popen_kwargs) # noqa: S603
try:
connect_url = _wait_for_devtools_url(proc, args.startup_timeout)
except SystemExit:
try:
proc.terminate()
except Exception: # noqa: BLE001
pass
raise
session = {
"id": uuid.uuid4().hex,
"pid": proc.pid,
"connectUrl": connect_url,
"userDataDir": str(user_data_dir),
"executablePath": chromium,
"headless": bool(args.headless),
"createdAt": int(time.time()),
}
out_path.write_text(json.dumps(session, indent=2) + "\n", encoding="utf-8")
print(f"LB_SESSION_ID={session['id']}")
print(f"LB_SESSION_PID={session['pid']}")
print(f"LB_SESSION_FILE={out_path}")
print(f"LB_CONNECT_URL={connect_url}")
return 0
def _cmd_info(args: argparse.Namespace) -> int:
session_path = _resolve_path(args.session_file, args.workspace_dir)
if not session_path.exists():
print(f"LB_INFO_MISSING file={session_path}")
return 1
session = json.loads(session_path.read_text(encoding="utf-8"))
session["alive"] = _pid_alive(int(session.get("pid", 0)))
print(json.dumps(session, indent=2))
return 0
def _terminate_pid(pid: int, kill_timeout: float) -> str:
if pid <= 0 or not _pid_alive(pid):
return "not_running"
try:
os.kill(pid, signal.SIGTERM)
except ProcessLookupError:
return "already_gone"
deadline = time.monotonic() + kill_timeout
while time.monotonic() < deadline:
if not _pid_alive(pid):
return "terminated"
time.sleep(0.1)
try:
os.kill(pid, signal.SIGKILL)
except ProcessLookupError:
return "already_gone"
time.sleep(0.2)
return "killed" if not _pid_alive(pid) else "still_alive"
def _cmd_release(args: argparse.Namespace) -> int:
session_path = _resolve_path(args.session_file, args.workspace_dir)
if not session_path.exists():
print(f"LB_RELEASE_SKIPPED missing={session_path}")
return 0
session = json.loads(session_path.read_text(encoding="utf-8"))
pid = int(session.get("pid", 0))
status = _terminate_pid(pid, args.kill_timeout)
print(f"LB_RELEASE_REQUESTED pid={pid} status={status}")
if args.delete_user_data:
udd = session.get("userDataDir", "")
if udd and Path(udd).exists():
try:
shutil.rmtree(udd)
print(f"LB_USER_DATA_DELETED {udd}")
except OSError as exc:
print(f"LB_USER_DATA_DELETE_FAILED {udd} {exc}")
if args.delete_file:
try:
session_path.unlink()
print(f"LB_SESSION_FILE_DELETED {session_path}")
except OSError as exc:
print(f"LB_SESSION_FILE_DELETE_FAILED {session_path} {exc}")
return 0
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="python -m webwright.tools.persistent_local_browser",
description="Manage a keep-alive local Chromium session shared across bash steps.",
)
parser.add_argument(
"--workspace-dir",
default="",
help="Resolve --out / --session-file / --user-data-dir relative to this directory.",
)
sub = parser.add_subparsers(dest="command", required=True)
create = sub.add_parser("create", help="Launch a detached Chromium and persist its connectUrl.")
create.add_argument("--out", default=DEFAULT_SESSION_FILE, help="Where to write the session JSON.")
create.add_argument(
"--user-data-dir",
default="",
help=f"Per-session Chromium user-data-dir (default: <workspace>/{DEFAULT_USER_DATA_SUBDIR}).",
)
create.add_argument(
"--headless",
action=argparse.BooleanOptionalAction,
default=True,
help="Launch Chromium headless (default: True).",
)
create.add_argument(
"--no-sandbox",
action=argparse.BooleanOptionalAction,
default=True,
help="Pass --no-sandbox (often required in containers/CI).",
)
create.add_argument("--window-width", type=int, default=1280)
create.add_argument("--window-height", type=int, default=1800)
create.add_argument(
"--startup-timeout",
type=float,
default=30.0,
help="Seconds to wait for the DevTools URL to appear on stderr.",
)
create.add_argument(
"--chromium-arg",
action="append",
default=[],
help="Extra Chromium command-line argument; repeat for multiple.",
)
create.set_defaults(func=_cmd_create)
info = sub.add_parser("info", help="Print the persisted session JSON and liveness.")
info.add_argument("--session-file", default=DEFAULT_SESSION_FILE)
info.set_defaults(func=_cmd_info)
release = sub.add_parser("release", help="Terminate the persisted session.")
release.add_argument("--session-file", default=DEFAULT_SESSION_FILE)
release.add_argument(
"--delete-file",
action=argparse.BooleanOptionalAction,
default=True,
help="Also delete the session JSON file after release.",
)
release.add_argument(
"--delete-user-data",
action=argparse.BooleanOptionalAction,
default=True,
help="Also remove the per-session Chromium user-data-dir.",
)
release.add_argument(
"--kill-timeout",
type=float,
default=10.0,
help="Seconds to wait for SIGTERM before sending SIGKILL.",
)
release.set_defaults(func=_cmd_release)
return parser
def main(argv: list[str] | None = None) -> int:
args = _build_parser().parse_args(argv)
return args.func(args)
if __name__ == "__main__":
sys.exit(main())
+611
View File
@@ -0,0 +1,611 @@
"""Self-reflection two-stage screenshot judge CLI.
Previously named ``two_stage_judge``; renamed to ``self_reflection``.
Stage 1: for each screenshot, send a (system, user + image) pair to the
configured model and parse a 1-5 ``Score`` with a short ``Reasoning``.
Stage 2: drop every per-image ``Reasoning`` into the caller-provided final
user prompt template (via ``{image_reasonings}``), attach EVERY screenshot,
and make one final call that must end with ``Status: success`` or
``Status: failure``.
The CLI reads all of its config from a single JSON file so the agent can
prepare it in one turn and invoke the tool in the next.
Usage::
python -m webwright.tools.self_reflection --config self_reflect_config.json
JSON schema (paths relative to ``--workspace-dir`` or the CWD)::
{
"images": ["final_runs/run_001/screenshots/final_execution_1.png", ...],
"image_judge_system_prompt": "...",
"image_judge_user_prompt": "...", // sent verbatim with each image
"final_verdict_system_prompt": "...",
"final_verdict_user_prompt": "...{action_history_log}...{image_reasonings}..."
}
Any of the four prompt fields may instead be supplied via
``<field>_file`` variants pointing to a text file on disk (recommended when
prompts contain many literal braces or newlines).
The output JSON written to ``--output`` (or stdout) contains the per-image
records, the image path list, the final response, and
``predicted_label`` (``1`` for success, ``0`` for failure, ``null`` if the
``Status:`` line could not be parsed). Exit code: 0 if PASS, 1 otherwise.
"""
from __future__ import annotations
import argparse
import asyncio
import base64
import json
import mimetypes
import re
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from webwright.models.base import text_part
from webwright.tools._model_config import load_tool_model
DEFAULT_IMAGE_PARSE_MAX_RETRIES = 3
_PROMPT_FIELDS = (
("image_judge_system_prompt", True),
("image_judge_user_prompt", True),
("final_verdict_system_prompt", True),
("final_verdict_user_prompt", True),
)
_IMAGE_SUFFIXES = frozenset({".png", ".jpg", ".jpeg", ".webp"})
# ---------------------------------------------------------------------------
# Image helpers
# ---------------------------------------------------------------------------
def _resolve_image_path(image_path: str, workspace_dir: str = "") -> Path:
path = Path(image_path)
if not path.is_absolute():
base_dir = Path(workspace_dir) if workspace_dir else Path.cwd()
path = base_dir / path
path = path.resolve()
if not path.exists():
raise FileNotFoundError(f"Image path does not exist: {path}")
return path
def _final_execution_sort_key(name: str) -> tuple[int, str]:
match = re.match(r"final_execution_(\d+)_", name)
if match:
return (int(match.group(1)), name)
nums = re.findall(r"\d+", name)
return (int(nums[0]) if nums else 0, name)
def _run_id_sort_key(name: str) -> tuple[int, str]:
match = re.search(r"run_(\d+)", name)
if match:
return (int(match.group(1)), name)
return (0, name)
def _sorted_image_paths(image_dir: Path) -> list[Path]:
if not image_dir.is_dir():
return []
return sorted(
[path for path in image_dir.iterdir() if path.is_file() and path.suffix.lower() in _IMAGE_SUFFIXES],
key=lambda path: _final_execution_sort_key(path.name),
)
def _discover_latest_run_screenshots(
final_runs_dir: Path,
) -> tuple[Path | None, list[Path]]:
"""Find the highest-numbered ``final_runs/run_<id>/screenshots`` dir and its images.
Returns ``(run_dir_or_None, sorted_image_paths)``. Empty list if no images found.
"""
if not final_runs_dir.exists() or not final_runs_dir.is_dir():
return None, []
candidates = sorted(
(d for d in final_runs_dir.iterdir() if d.is_dir() and re.fullmatch(r"run_\d+", d.name)),
key=lambda p: _run_id_sort_key(p.name),
)
# Walk from highest-numbered run downward and pick the first one with any screenshots.
for run_dir in reversed(candidates):
screenshots_dir = run_dir / "screenshots"
images = _sorted_image_paths(screenshots_dir)
if images:
return run_dir, images
return None, []
def _infer_run_dir_from_images(images: list[Path]) -> Path | None:
run_dirs = {
path.parent.parent.resolve()
for path in images
if path.parent.name == "screenshots"
}
if len(run_dirs) == 1:
return next(iter(run_dirs))
return None
def _resolve_artifact_dir(
*,
images: list[Path],
discovered_run_dir: Path | None,
output_path: str,
workspace_dir: str,
) -> Path | None:
candidates: list[Path] = []
inferred_run_dir = _infer_run_dir_from_images(images)
if inferred_run_dir is not None:
candidates.append(inferred_run_dir)
if discovered_run_dir is not None:
candidates.append(discovered_run_dir.resolve())
if output_path:
candidates.append(Path(output_path).resolve().parent)
base_dir = Path(workspace_dir).resolve() if workspace_dir else Path.cwd().resolve()
candidates.append(base_dir)
seen: set[Path] = set()
ordered_candidates: list[Path] = []
for candidate in candidates:
if candidate in seen:
continue
seen.add(candidate)
ordered_candidates.append(candidate)
for candidate in ordered_candidates:
if (candidate / "final_script_log.txt").is_file():
return candidate
return ordered_candidates[0] if ordered_candidates else None
def _load_action_history_log(artifact_dir: Path | None) -> str:
if artifact_dir is None:
return ""
log_path = artifact_dir / "final_script_log.txt"
if not log_path.is_file():
return ""
return log_path.read_text(encoding="utf-8").rstrip()
def _render_final_verdict_user_prompt(
template: str,
*,
image_reasonings: str,
action_history_log: str,
) -> str:
rendered = template
if "{image_reasonings}" in template or "{action_history_log}" in template:
try:
rendered = template.format(
image_reasonings=image_reasonings,
action_history_log=action_history_log,
)
except KeyError as exc:
raise ValueError(
"Unknown placeholder in final_verdict_user_prompt: "
f"{exc.args[0]!r}. Supported placeholders are "
"{image_reasonings} and {action_history_log}; double any literal "
"braces as {{ and }}."
) from exc
return rendered
def _high_detail_image_part_from_path(image_path: Path) -> dict[str, Any]:
mime_type, _ = mimetypes.guess_type(str(image_path))
encoded = base64.b64encode(image_path.read_bytes()).decode("ascii")
return {
"type": "input_image",
"image_url": f"data:{mime_type or 'image/png'};base64,{encoded}",
"detail": "high",
}
# ---------------------------------------------------------------------------
# Model call: plain message list -> text
# ---------------------------------------------------------------------------
def _call_model(
*,
model_client: Any,
system_prompt: str,
user_content: list[dict[str, Any]],
max_new_tokens: int,
) -> str:
return model_client(
[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_content},
],
max_output_tokens=max_new_tokens,
).strip()
def _model_endpoint(model_client: Any) -> str:
config = getattr(model_client, "config", None)
for key in ("openai_endpoint", "anthropic_endpoint", "openrouter_endpoint"):
value = getattr(config, key, "")
if value:
return str(value)
return ""
# ---------------------------------------------------------------------------
# Parsing helpers (ported from webjudge_online_mind2web_sandbox.py)
# ---------------------------------------------------------------------------
def _parse_image_judge_response(response: str) -> tuple[str, int]:
score_match = re.search(r"(?is)\bscore\b[^1-5]*([1-5])\b", response)
reasoning_match = re.search(
r"(?is)(?:\*\*?\s*reasoning\s*\*\*?|reasoning)\s*[:\-]\s*"
r"(.*?)(?=\n\s*(?:\d+\.\s*)?(?:\*\*?\s*score\s*\*\*?|score)\s*[:\-]|\Z)",
response,
)
if score_match and reasoning_match:
reasoning = re.sub(r"\s+", " ", reasoning_match.group(1)).strip()
return reasoning, int(score_match.group(1))
try:
payload = json.loads(response)
except Exception:
payload = None
if isinstance(payload, dict):
score = payload.get("Score", payload.get("score"))
reasoning = payload.get("Reasoning", payload.get("reasoning"))
if (
isinstance(score, int)
and 1 <= score <= 5
and isinstance(reasoning, str)
and reasoning.strip()
):
return re.sub(r"\s+", " ", reasoning).strip(), score
raise ValueError("Could not parse image judge response")
def _parse_final_verdict(response: str) -> int | None:
matches = list(re.finditer(r"(?i)status:\s*", response))
if not matches:
return None
tail = response[matches[-1].end():].strip()
m = re.match(r"""^[\'\"“”‘’\s]*(success|failure)\b""", tail, re.IGNORECASE)
if not m:
return None
return 1 if m.group(1).lower() == "success" else 0
# ---------------------------------------------------------------------------
# Per-image scoring
# ---------------------------------------------------------------------------
async def _judge_one_image(
*,
image_path: Path,
image_judge_system_prompt: str,
image_judge_user_prompt: str,
model_client: Any,
max_new_tokens: int,
max_parse_retries: int,
) -> dict[str, Any]:
user_content = [
text_part(image_judge_user_prompt),
_high_detail_image_part_from_path(image_path),
]
last_response = ""
last_error: BaseException | None = None
for attempt in range(1, max_parse_retries + 1):
last_response = await asyncio.to_thread(
_call_model,
model_client=model_client,
system_prompt=image_judge_system_prompt,
user_content=user_content,
max_new_tokens=max_new_tokens,
)
try:
reasoning, score = _parse_image_judge_response(last_response)
return {
"image_path": str(image_path),
"Response": last_response,
"Score": score,
"Reasoning": reasoning,
"Attempts": attempt,
"ParseFailed": False,
}
except Exception as exc: # noqa: BLE001
last_error = exc
print(
f"[self_reflection] parse attempt {attempt}/{max_parse_retries} failed for "
f"{image_path}: {exc}",
file=sys.stderr,
)
return {
"image_path": str(image_path),
"Response": last_response,
"Score": 0,
"Reasoning": "",
"Attempts": max_parse_retries,
"ParseFailed": True,
"ParseError": str(last_error) if last_error is not None else "unknown",
}
# ---------------------------------------------------------------------------
# Orchestrator
# ---------------------------------------------------------------------------
@dataclass
class SelfReflectionResult:
image_records: list[dict[str, Any]]
image_paths: list[str]
final_user_text: str
final_system_msg: str
final_response: str
predicted_label: int | None # 1 success, 0 failure, None unparsed
model: str = ""
endpoint: str = ""
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,
"final_system_msg": self.final_system_msg,
"image_paths": self.image_paths,
"image_records": self.image_records,
}
async def run_self_reflection_async(
*,
images: list[Path],
image_judge_system_prompt: str,
image_judge_user_prompt: str,
final_verdict_system_prompt: str,
final_verdict_user_prompt: str,
action_history_log: str,
max_image_parse_retries: int,
final_max_new_tokens: int,
image_max_new_tokens: int,
model_client: Any,
) -> SelfReflectionResult:
model_name = str(getattr(model_client.config, "model_name", ""))
endpoint = _model_endpoint(model_client)
if images:
per_image = await asyncio.gather(
*(
_judge_one_image(
image_path=path,
image_judge_system_prompt=image_judge_system_prompt,
image_judge_user_prompt=image_judge_user_prompt,
model_client=model_client,
max_new_tokens=image_max_new_tokens,
max_parse_retries=max_image_parse_retries,
)
for path in images
)
)
else:
per_image = []
image_paths = [record["image_path"] for record in per_image]
reasonings = [record["Reasoning"] or "" for record in per_image]
reasonings_block = "\n".join(
f"{i + 1}. {text}" for i, text in enumerate(reasonings)
)
final_user_text = _render_final_verdict_user_prompt(
final_verdict_user_prompt,
image_reasonings=reasonings_block,
action_history_log=action_history_log,
)
user_content: list[dict[str, Any]] = [text_part(final_user_text)]
for path_str in image_paths:
user_content.append(_high_detail_image_part_from_path(Path(path_str)))
final_response = await asyncio.to_thread(
_call_model,
model_client=model_client,
system_prompt=final_verdict_system_prompt,
user_content=user_content,
max_new_tokens=final_max_new_tokens,
)
predicted_label = _parse_final_verdict(final_response)
return SelfReflectionResult(
image_records=list(per_image),
image_paths=image_paths,
final_user_text=final_user_text,
final_system_msg=final_verdict_system_prompt,
final_response=final_response,
predicted_label=predicted_label,
model=model_name,
endpoint=endpoint,
)
def run_self_reflection(**kwargs: Any) -> SelfReflectionResult:
return asyncio.run(run_self_reflection_async(**kwargs))
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def _resolve_prompt(cfg: dict[str, Any], key: str, *, required: bool) -> str | None:
inline = cfg.get(key)
file_key = f"{key}_file"
file_path = cfg.get(file_key)
if inline is not None and file_path is not None:
raise ValueError(f"Provide only one of {key!r} or {file_key!r}, not both.")
if file_path is not None:
return Path(file_path).read_text(encoding="utf-8")
if inline is not None:
return inline
if required:
raise ValueError(f"Missing required prompt: {key} (or {file_key}).")
return None
def _load_config(config_arg: str) -> dict[str, Any]:
if config_arg == "-":
return json.loads(sys.stdin.read())
return json.loads(Path(config_arg).read_text(encoding="utf-8"))
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description=(
"Two-stage screenshot judge. Reads a JSON config describing images and "
"prompts, calls the configured model, and prints a "
"JSON result with per-image records and the final verdict."
)
)
parser.add_argument("--config", required=True, help="Path to JSON config, or '-' for stdin.")
parser.add_argument("--workspace-dir", default="", help="Base directory for relative image paths.")
parser.add_argument("--output", default="", help="Write JSON result to this path instead of stdout.")
parser.add_argument(
"--auto-latest-run",
default="final_runs",
help=(
"When the config has no 'images' list, auto-discover screenshots from the "
"highest-numbered `<workspace-dir>/<this-value>/run_<id>/screenshots` folder. "
"Default: 'final_runs'. Pass '' (empty string) to disable auto-discovery."
),
)
parser.add_argument("--max-image-parse-retries", type=int, default=DEFAULT_IMAGE_PARSE_MAX_RETRIES)
parser.add_argument("--image-max-new-tokens", type=int, default=1024)
parser.add_argument("--final-max-new-tokens", type=int, default=8192)
parser.add_argument(
"--model-config",
default="",
help=(
"Path to a JSON/YAML config containing a top-level `model:` block. "
"If omitted, reads <workspace-dir>/config_snapshot/merged_config.yaml."
),
)
parser.add_argument("--timeout-seconds", type=int, default=120)
return parser
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
base_dir = Path(args.workspace_dir).resolve() if args.workspace_dir else Path.cwd().resolve()
cfg = _load_config(args.config)
prompts = {
key: _resolve_prompt(cfg, key, required=required)
for key, required in _PROMPT_FIELDS
}
images_config = cfg.get("images") or cfg.get("images_path") or []
resolved_images = [
_resolve_image_path(p, workspace_dir=args.workspace_dir) for p in images_config
]
discovered_run_dir = _infer_run_dir_from_images(resolved_images)
# If config did not provide images, fall back to the latest run's screenshots.
if not resolved_images:
discovered: list[Path] = []
discovered_source = ""
if args.auto_latest_run:
auto_root = Path(args.auto_latest_run)
if not auto_root.is_absolute():
auto_root = base_dir / auto_root
auto_root = auto_root.resolve()
discovered_run_dir, discovered = _discover_latest_run_screenshots(auto_root)
if discovered_run_dir is not None:
discovered_source = str(discovered_run_dir / "screenshots")
if discovered:
resolved_images = discovered
print(
f"[self_reflection] auto-discovered {len(resolved_images)} screenshots from {discovered_source}",
file=sys.stderr,
)
artifact_dir = _resolve_artifact_dir(
images=resolved_images,
discovered_run_dir=discovered_run_dir,
output_path=args.output,
workspace_dir=args.workspace_dir,
)
action_history_log = _load_action_history_log(artifact_dir)
if not resolved_images:
print(
"[self_reflection] warning: no images provided; final stage will run without screenshot attachments.",
file=sys.stderr,
)
if not action_history_log:
print(
"[self_reflection] warning: no final_script_log.txt found; final prompt will omit action history content.",
file=sys.stderr,
)
model_client = load_tool_model(
model_config_arg=args.model_config,
workspace_dir=args.workspace_dir,
timeout_seconds=args.timeout_seconds,
)
result = run_self_reflection(
images=resolved_images,
image_judge_system_prompt=prompts["image_judge_system_prompt"],
image_judge_user_prompt=prompts["image_judge_user_prompt"],
final_verdict_system_prompt=prompts["final_verdict_system_prompt"],
final_verdict_user_prompt=prompts["final_verdict_user_prompt"],
action_history_log=action_history_log,
max_image_parse_retries=args.max_image_parse_retries,
final_max_new_tokens=args.final_max_new_tokens,
image_max_new_tokens=args.image_max_new_tokens,
model_client=model_client,
)
payload = result.to_dict()
serialized = json.dumps(payload, indent=2, ensure_ascii=False)
if args.output:
Path(args.output).write_text(serialized, encoding="utf-8")
print(f"Wrote result to {args.output}", file=sys.stderr)
else:
sys.stdout.write(serialized)
sys.stdout.write("\n")
label = result.predicted_label
if label == 1:
print("JUDGE VERDICT: PASS", file=sys.stderr)
return 0
if label == 0:
print("JUDGE VERDICT: FAIL", file=sys.stderr)
return 1
print("JUDGE VERDICT: UNPARSED (treating as FAIL)", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())
View File
+21
View File
@@ -0,0 +1,21 @@
from __future__ import annotations
from datetime import datetime, timezone
import json
from pathlib import Path
from typing import Any
def append_runtime_log(path: Path | None, *, source: str, event: str, **data: Any) -> None:
if path is None:
return
path.parent.mkdir(parents=True, exist_ok=True)
payload = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"source": source,
"event": event,
**data,
}
with path.open("a", encoding="utf-8") as handle:
handle.write(json.dumps(payload, ensure_ascii=True))
handle.write("\n")
+14
View File
@@ -0,0 +1,14 @@
from __future__ import annotations
import asyncio
from typing import TypeVar
T = TypeVar("T")
def run_async(coro) -> T:
try:
asyncio.get_running_loop()
except RuntimeError:
return asyncio.run(coro)
raise RuntimeError("Webwright does not support running inside an active event loop.")
+22
View File
@@ -0,0 +1,22 @@
from __future__ import annotations
from typing import Any
UNSET = object()
def recursive_merge(*dictionaries: dict | None) -> dict[str, Any]:
result: dict[str, Any] = {}
for dictionary in dictionaries:
if dictionary is None:
continue
for key, value in dictionary.items():
if value is UNSET:
continue
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
result[key] = recursive_merge(result[key], value)
elif isinstance(value, dict):
result[key] = recursive_merge(value)
else:
result[key] = value
return result