ai-agent-book 精选快照(<2MB 代码与文档,来自 github.com/bojieli/ai-agent-book)
Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s
@@ -0,0 +1,111 @@
|
||||
# Open-model Computer Use companion
|
||||
|
||||
This is the provider-portable arm for Experiments 6-8 and 6-8. It runs the
|
||||
same screenshot → structured action → browser execution loop without requiring
|
||||
an Anthropic or OpenAI model account. The documented hosted route uses the
|
||||
open-weight `qwen/qwen3-vl-32b-instruct` model through OpenRouter. The same
|
||||
runner accepts a self-hosted vLLM/SGLang endpoint or another OpenAI-compatible
|
||||
host.
|
||||
|
||||
The Anthropic Computer Use Demo remains a useful reference implementation for
|
||||
its native `computer`, `bash`, and editor tools. This companion does not claim
|
||||
that Qwen and Claude are interchangeable. Runs from different models are
|
||||
separate experimental arms and must retain the actual endpoint and model ID.
|
||||
|
||||
## Current evidence
|
||||
|
||||
The [canonical open-model run](validation/latest.json) passed on 2026-08-01.
|
||||
OpenRouter returned the requested `qwen/qwen3-vl-32b-instruct` model for all
|
||||
16/16 calls. The Agent hit a Google CAPTCHA, recovered through weather.com,
|
||||
and completed in 16 steps. The deterministic validator matched the final
|
||||
64°F/Sunny answer to the retained browser observation, verified 15 screenshot
|
||||
hashes and the one-action-per-step read-only trajectory, and found no retained
|
||||
credential. This completes the Experiment 6-8 open-model arm only; the
|
||||
Anthropic-native Experiment 6-7 arm remains separate.
|
||||
|
||||
## Endpoint contract
|
||||
|
||||
An endpoint is eligible when it:
|
||||
|
||||
- accepts screenshot images in OpenAI-compatible chat messages;
|
||||
- can produce the Browser Use action schema, either with native `json_schema`
|
||||
support or with schema-in-prompt JSON;
|
||||
- returns enough information for the Agent to choose one browser action per
|
||||
step; and
|
||||
- does not silently replace the requested model.
|
||||
|
||||
The reference open model is Qwen3-VL 32B Instruct. “Open model” describes the
|
||||
weights/license; OpenRouter is only one hosted API route. Readers can use their
|
||||
own compatible host instead.
|
||||
|
||||
## Install
|
||||
|
||||
Use Python 3.11 or newer. The isolated requirement pins the exact Browser Use
|
||||
commit audited by the chapter (`ec9277c…`, package version `0.9.5`); the PyPI
|
||||
release carrying the same version string is not substituted for that commit:
|
||||
|
||||
```bash
|
||||
python3.11 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
python -m pip install -r requirements.txt
|
||||
python -m playwright install chromium
|
||||
```
|
||||
|
||||
## Hosted open-model route
|
||||
|
||||
```bash
|
||||
cp env.example .env
|
||||
export OPENROUTER_API_KEY='replace-with-your-key'
|
||||
|
||||
python main.py --dry-run
|
||||
python main.py \
|
||||
--task "Open Google, search for San Francisco weather today, and report the temperature and conditions. Do not sign in or change any external data." \
|
||||
--max-steps 25 \
|
||||
--record-video
|
||||
```
|
||||
|
||||
The default model is `qwen/qwen3-vl-32b-instruct`. Override
|
||||
`OPEN_MODEL_MODEL` to select another explicitly open-weight vision model; do
|
||||
not describe a proprietary model reached through the same gateway as an open
|
||||
model.
|
||||
|
||||
## Self-hosted or another compatible API
|
||||
|
||||
Start a vision-capable OpenAI-compatible server, then configure its URL and
|
||||
served model name. The runner does not require an OpenRouter key in this mode:
|
||||
|
||||
```bash
|
||||
export OPEN_MODEL_API_KEY=local
|
||||
export OPEN_MODEL_BASE_URL=http://127.0.0.1:8000/v1
|
||||
export OPEN_MODEL_MODEL=Qwen/Qwen3-VL-32B-Instruct
|
||||
python main.py --dry-run
|
||||
python main.py --headless
|
||||
```
|
||||
|
||||
If the host accepts images but rejects `response_format: json_schema`, set
|
||||
`OPEN_MODEL_SCHEMA_MODE=prompt`. This is a compatibility fallback, and its
|
||||
reliability should be reported separately because schema adherence can change.
|
||||
|
||||
## Retained evidence
|
||||
|
||||
Every non-dry run creates a new `runs/open-model-<UTC>/` directory containing:
|
||||
|
||||
- `preflight.json`: redacted endpoint, exact model, task, and execution limits;
|
||||
- `api-receipts.json`: credential-free request hashes and raw provider responses,
|
||||
including provider-reported model IDs when supplied;
|
||||
- `history.json`: ordered model decisions, actions, observations, and results;
|
||||
- `screenshots/` plus `screenshots.json`: retained per-step visual observations;
|
||||
- `summary.json` or `failure.json`: outcome and honest failure state; and
|
||||
- `manifest.json`: SHA-256 and byte size for every retained artifact.
|
||||
|
||||
No API-key value is written. The Agent's `done` result is only an
|
||||
agent-reported outcome; manuscript-level completion still requires independent
|
||||
checking of the weather answer and action trajectory. A dry run, model-list
|
||||
lookup, or browser launch alone is not completion evidence.
|
||||
|
||||
Validate a retained run against its provider receipts, one-action-per-step
|
||||
limit, final browser observation, screenshot hashes, and credential scan:
|
||||
|
||||
```bash
|
||||
python validate_run.py runs/<run-id> --latest validation/latest.json
|
||||
```
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Configuration for the provider-neutral Computer Use companion."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from os import environ as process_environ
|
||||
from urllib.parse import urlparse
|
||||
|
||||
DEFAULT_BASE_URL = "https://openrouter.ai/api/v1"
|
||||
DEFAULT_MODEL = "qwen/qwen3-vl-32b-instruct"
|
||||
|
||||
|
||||
class ConfigError(ValueError):
|
||||
"""Raised when the endpoint configuration cannot run the experiment."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ModelEndpoint:
|
||||
"""A redaction-safe description plus the secret used by the API client."""
|
||||
|
||||
api_key: str
|
||||
api_key_env: str
|
||||
base_url: str
|
||||
model: str
|
||||
schema_mode: str
|
||||
|
||||
def public_dict(self) -> dict[str, str]:
|
||||
return {
|
||||
"api_protocol": "openai-compatible-chat-completions",
|
||||
"api_key_env": self.api_key_env,
|
||||
"base_url": self.base_url,
|
||||
"requested_model": self.model,
|
||||
"schema_mode": self.schema_mode,
|
||||
}
|
||||
|
||||
|
||||
def _nonempty(values: Mapping[str, str], name: str) -> str | None:
|
||||
value = values.get(name, "").strip()
|
||||
return value or None
|
||||
|
||||
|
||||
def resolve_endpoint(values: Mapping[str, str] | None = None) -> ModelEndpoint:
|
||||
"""Resolve an open-model endpoint without coupling it to one API vendor.
|
||||
|
||||
``OPEN_MODEL_*`` is the portable interface. ``OPENROUTER_API_KEY`` is
|
||||
accepted as a convenience because the documented reference route uses it.
|
||||
A local vLLM/SGLang server can set ``OPEN_MODEL_API_KEY=local``.
|
||||
"""
|
||||
|
||||
source = process_environ if values is None else values
|
||||
api_key = _nonempty(source, "OPEN_MODEL_API_KEY")
|
||||
api_key_env = "OPEN_MODEL_API_KEY"
|
||||
if api_key is None:
|
||||
api_key = _nonempty(source, "OPENROUTER_API_KEY")
|
||||
api_key_env = "OPENROUTER_API_KEY"
|
||||
if api_key is None:
|
||||
raise ConfigError(
|
||||
"Set OPEN_MODEL_API_KEY for an OpenAI-compatible endpoint, or "
|
||||
"OPENROUTER_API_KEY for the documented Qwen3-VL route."
|
||||
)
|
||||
|
||||
base_url = _nonempty(source, "OPEN_MODEL_BASE_URL") or DEFAULT_BASE_URL
|
||||
parsed = urlparse(base_url)
|
||||
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
||||
raise ConfigError("OPEN_MODEL_BASE_URL must be an absolute http(s) URL")
|
||||
|
||||
model = _nonempty(source, "OPEN_MODEL_MODEL") or DEFAULT_MODEL
|
||||
schema_mode = (_nonempty(source, "OPEN_MODEL_SCHEMA_MODE") or "native").lower()
|
||||
if schema_mode not in {"native", "prompt"}:
|
||||
raise ConfigError("OPEN_MODEL_SCHEMA_MODE must be 'native' or 'prompt'")
|
||||
|
||||
return ModelEndpoint(
|
||||
api_key=api_key,
|
||||
api_key_env=api_key_env,
|
||||
base_url=base_url.rstrip("/"),
|
||||
model=model,
|
||||
schema_mode=schema_mode,
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
# Hosted reference path: an open-weight Qwen3-VL model through OpenRouter.
|
||||
OPENROUTER_API_KEY=
|
||||
OPEN_MODEL_BASE_URL=https://openrouter.ai/api/v1
|
||||
OPEN_MODEL_MODEL=qwen/qwen3-vl-32b-instruct
|
||||
|
||||
# Generic OpenAI-compatible path (hosted or self-hosted). OPEN_MODEL_API_KEY
|
||||
# takes precedence over OPENROUTER_API_KEY; local servers can use a dummy value.
|
||||
# OPEN_MODEL_API_KEY=local
|
||||
# OPEN_MODEL_BASE_URL=http://127.0.0.1:8000/v1
|
||||
# OPEN_MODEL_MODEL=Qwen/Qwen3-VL-32B-Instruct
|
||||
|
||||
# Use "prompt" only if the endpoint accepts images but not native json_schema.
|
||||
OPEN_MODEL_SCHEMA_MODE=native
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Small, dependency-free helpers for retaining Computer Use evidence."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def write_json(path: Path, value: Any) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def retain_step_screenshots(history_data: dict[str, Any], run_dir: Path) -> tuple[dict[str, Any], list[dict[str, Any]]]:
|
||||
"""Copy browser-use's temporary screenshots into the retained run.
|
||||
|
||||
The returned history points to paths relative to ``run_dir``. Missing
|
||||
screenshots remain explicit records instead of being silently ignored.
|
||||
"""
|
||||
|
||||
retained = copy.deepcopy(history_data)
|
||||
screenshot_dir = run_dir / "screenshots"
|
||||
records: list[dict[str, Any]] = []
|
||||
|
||||
for index, item in enumerate(retained.get("history", []), start=1):
|
||||
state = item.get("state") or {}
|
||||
raw_path = state.get("screenshot_path")
|
||||
record: dict[str, Any] = {"step": index, "source_present": False, "path": None, "sha256": None}
|
||||
if raw_path:
|
||||
source = Path(raw_path).expanduser()
|
||||
record["source_present"] = source.is_file()
|
||||
if source.is_file():
|
||||
screenshot_dir.mkdir(parents=True, exist_ok=True)
|
||||
suffix = source.suffix if source.suffix else ".png"
|
||||
target = screenshot_dir / f"step-{index:03d}{suffix}"
|
||||
shutil.copyfile(source, target)
|
||||
relative = target.relative_to(run_dir).as_posix()
|
||||
state["screenshot_path"] = relative
|
||||
record.update({"path": relative, "sha256": sha256_file(target)})
|
||||
else:
|
||||
state["screenshot_path"] = None
|
||||
records.append(record)
|
||||
|
||||
return retained, records
|
||||
|
||||
|
||||
def write_manifest(run_dir: Path, metadata: dict[str, Any]) -> Path:
|
||||
"""Hash every retained artifact except the manifest itself."""
|
||||
|
||||
artifacts = []
|
||||
for path in sorted(run_dir.rglob("*")):
|
||||
if path.is_file() and path.name != "manifest.json":
|
||||
artifacts.append(
|
||||
{
|
||||
"path": path.relative_to(run_dir).as_posix(),
|
||||
"bytes": path.stat().st_size,
|
||||
"sha256": sha256_file(path),
|
||||
}
|
||||
)
|
||||
target = run_dir / "manifest.json"
|
||||
write_json(target, {"schema_version": 1, **metadata, "artifacts": artifacts})
|
||||
return target
|
||||
@@ -0,0 +1,273 @@
|
||||
"""Run a visual Browser Use trajectory through an open-model API endpoint."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import hashlib
|
||||
import importlib.metadata
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import traceback
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from config import ConfigError, ModelEndpoint, resolve_endpoint
|
||||
from evidence import retain_step_screenshots, write_json, write_manifest
|
||||
|
||||
DEFAULT_TASK = (
|
||||
"Open Google, search for San Francisco weather today, and report the "
|
||||
"temperature and conditions. Do not sign in or change any external data."
|
||||
)
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def default_run_dir() -> Path:
|
||||
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||
return Path("runs") / f"open-model-{stamp}"
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--task", default=DEFAULT_TASK)
|
||||
parser.add_argument("--max-steps", type=int, default=25)
|
||||
parser.add_argument("--output-dir", type=Path)
|
||||
parser.add_argument("--headless", action=argparse.BooleanOptionalAction, default=False)
|
||||
parser.add_argument("--record-video", action="store_true")
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="validate and print the redacted endpoint configuration without importing browser-use or calling an API",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
if args.max_steps < 1:
|
||||
parser.error("--max-steps must be positive")
|
||||
return args
|
||||
|
||||
|
||||
def load_dotenv_if_available() -> None:
|
||||
try:
|
||||
from dotenv import load_dotenv
|
||||
except ImportError:
|
||||
return
|
||||
load_dotenv()
|
||||
|
||||
|
||||
def scrub_secret(text: str, secret: str) -> str:
|
||||
return text.replace(secret, "<redacted>") if secret else text
|
||||
|
||||
|
||||
def scrub_value(value: Any, secret: str) -> Any:
|
||||
if isinstance(value, str):
|
||||
return scrub_secret(value, secret)
|
||||
if isinstance(value, list):
|
||||
return [scrub_value(item, secret) for item in value]
|
||||
if isinstance(value, dict):
|
||||
return {key: scrub_value(item, secret) for key, item in value.items()}
|
||||
return value
|
||||
|
||||
|
||||
def public_preflight(endpoint: ModelEndpoint, args: argparse.Namespace) -> dict[str, Any]:
|
||||
return {
|
||||
"api": endpoint.public_dict(),
|
||||
"task": args.task,
|
||||
"max_steps": args.max_steps,
|
||||
"headless": args.headless,
|
||||
"record_video": args.record_video,
|
||||
"requirements": {
|
||||
"image_input": True,
|
||||
"structured_actions": True,
|
||||
"browser_execution": True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def run(args: argparse.Namespace, endpoint: ModelEndpoint) -> int:
|
||||
try:
|
||||
import httpx
|
||||
from browser_use import Agent, BrowserSession, ChatOpenAI
|
||||
except ImportError as exc:
|
||||
raise RuntimeError(
|
||||
"browser-use is not installed; run `python -m pip install -r requirements.txt`"
|
||||
) from exc
|
||||
|
||||
run_dir = (args.output_dir or default_run_dir()).expanduser().resolve()
|
||||
if run_dir.exists() and any(run_dir.iterdir()):
|
||||
raise RuntimeError(f"output directory is not empty: {run_dir}")
|
||||
run_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
started_at = utc_now()
|
||||
write_json(run_dir / "preflight.json", public_preflight(endpoint, args))
|
||||
api_receipts: list[dict[str, Any]] = []
|
||||
|
||||
async def record_request(request: httpx.Request) -> None:
|
||||
body = await request.aread()
|
||||
requested_model = None
|
||||
try:
|
||||
requested_model = json.loads(body).get("model")
|
||||
except (json.JSONDecodeError, UnicodeDecodeError, AttributeError):
|
||||
pass
|
||||
api_receipts.append(
|
||||
{
|
||||
"kind": "request",
|
||||
"at": utc_now(),
|
||||
"method": request.method,
|
||||
"url": str(request.url),
|
||||
"requested_model": requested_model,
|
||||
"body_bytes": len(body),
|
||||
"body_sha256": hashlib.sha256(body).hexdigest(),
|
||||
"authorization_retained": False,
|
||||
}
|
||||
)
|
||||
|
||||
async def record_response(response: httpx.Response) -> None:
|
||||
body = await response.aread()
|
||||
try:
|
||||
parsed_body: Any = json.loads(body)
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
parsed_body = {"non_json_body": body.decode("utf-8", errors="replace")}
|
||||
api_receipts.append(
|
||||
{
|
||||
"kind": "response",
|
||||
"at": utc_now(),
|
||||
"url": str(response.request.url),
|
||||
"status_code": response.status_code,
|
||||
"body": scrub_value(parsed_body, endpoint.api_key),
|
||||
"authorization_retained": False,
|
||||
}
|
||||
)
|
||||
|
||||
http_client = httpx.AsyncClient(event_hooks={"request": [record_request], "response": [record_response]})
|
||||
llm = ChatOpenAI(
|
||||
model=endpoint.model,
|
||||
api_key=endpoint.api_key,
|
||||
base_url=endpoint.base_url,
|
||||
temperature=0.0,
|
||||
frequency_penalty=0.0,
|
||||
add_schema_to_system_prompt=endpoint.schema_mode == "prompt",
|
||||
dont_force_structured_output=endpoint.schema_mode == "prompt",
|
||||
max_retries=2,
|
||||
http_client=http_client,
|
||||
)
|
||||
browser = BrowserSession(
|
||||
headless=args.headless,
|
||||
downloads_path=run_dir / "downloads",
|
||||
record_video_dir=(run_dir / "video") if args.record_video else None,
|
||||
)
|
||||
agent = Agent(
|
||||
task=args.task,
|
||||
llm=llm,
|
||||
browser_session=browser,
|
||||
use_vision=True,
|
||||
max_actions_per_step=1,
|
||||
use_judge=False,
|
||||
file_system_path=str(run_dir / "agent-files"),
|
||||
)
|
||||
|
||||
history = None
|
||||
failure: Exception | None = None
|
||||
try:
|
||||
history = await agent.run(max_steps=args.max_steps)
|
||||
except Exception as exc: # noqa: BLE001 - retain arbitrary provider/browser failure evidence
|
||||
failure = exc
|
||||
finally:
|
||||
try:
|
||||
await browser.kill()
|
||||
except Exception as close_exc: # noqa: BLE001 - cleanup failures belong in the run receipt
|
||||
if failure is None:
|
||||
failure = close_exc
|
||||
try:
|
||||
await http_client.aclose()
|
||||
except Exception as close_exc: # noqa: BLE001 - cleanup failures belong in the run receipt
|
||||
if failure is None:
|
||||
failure = close_exc
|
||||
|
||||
write_json(run_dir / "api-receipts.json", api_receipts)
|
||||
|
||||
if history is not None:
|
||||
retained_history, screenshots = retain_step_screenshots(history.model_dump(), run_dir)
|
||||
write_json(run_dir / "history.json", retained_history)
|
||||
write_json(run_dir / "screenshots.json", screenshots)
|
||||
provider_models = sorted(
|
||||
{
|
||||
item["body"]["model"]
|
||||
for item in api_receipts
|
||||
if item.get("kind") == "response"
|
||||
and isinstance(item.get("body"), dict)
|
||||
and isinstance(item["body"].get("model"), str)
|
||||
}
|
||||
)
|
||||
summary = {
|
||||
"schema_version": 1,
|
||||
"experiment": "9-6/6-8-open-model-arm",
|
||||
"acceptance_scope": "provider-portable-computer-use-trajectory",
|
||||
"status": "complete" if history.is_done() else "incomplete",
|
||||
"started_at": started_at,
|
||||
"ended_at": utc_now(),
|
||||
"api": endpoint.public_dict(),
|
||||
"provider_models_reported": provider_models,
|
||||
"browser_use_version": importlib.metadata.version("browser-use"),
|
||||
"task": args.task,
|
||||
"max_steps": args.max_steps,
|
||||
"steps_executed": len(history),
|
||||
"agent_reported_success": history.is_successful(),
|
||||
"final_result": history.final_result(),
|
||||
"urls": history.urls(),
|
||||
"errors": history.errors(),
|
||||
"screenshots_retained": sum(1 for item in screenshots if item["path"]),
|
||||
"credential_retained": False,
|
||||
"qualification": "This is a separate open-model arm, not an Anthropic-equivalent result.",
|
||||
}
|
||||
write_json(run_dir / "summary.json", summary)
|
||||
|
||||
if failure is not None:
|
||||
message = scrub_secret(str(failure), endpoint.api_key)
|
||||
trace = scrub_secret("".join(traceback.format_exception(failure)), endpoint.api_key)
|
||||
write_json(
|
||||
run_dir / "failure.json",
|
||||
{
|
||||
"status": "failed",
|
||||
"ended_at": utc_now(),
|
||||
"error_type": type(failure).__name__,
|
||||
"message": message,
|
||||
"traceback": trace,
|
||||
"credential_retained": False,
|
||||
},
|
||||
)
|
||||
|
||||
write_manifest(
|
||||
run_dir,
|
||||
{
|
||||
"experiment": "9-6/6-8-open-model-arm",
|
||||
"created_at": utc_now(),
|
||||
"api": endpoint.public_dict(),
|
||||
"credential_retained": False,
|
||||
},
|
||||
)
|
||||
print(json.dumps({"run_dir": str(run_dir), "failed": failure is not None}, ensure_ascii=False))
|
||||
if failure is not None:
|
||||
return 1
|
||||
return 0 if history is not None and history.is_done() else 1
|
||||
|
||||
|
||||
def main() -> int:
|
||||
load_dotenv_if_available()
|
||||
args = parse_args()
|
||||
try:
|
||||
endpoint = resolve_endpoint(os.environ)
|
||||
except ConfigError as exc:
|
||||
print(f"configuration error: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
if args.dry_run:
|
||||
print(json.dumps(public_preflight(endpoint, args), ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
return asyncio.run(run(args, endpoint))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,2 @@
|
||||
browser-use @ git+https://github.com/browser-use/browser-use.git@ec9277c5001f2cb78ee419c927775a3cfc227ff8
|
||||
python-dotenv>=1.0
|
||||
@@ -0,0 +1,4 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
@@ -0,0 +1,43 @@
|
||||
import pytest
|
||||
from config import DEFAULT_BASE_URL, DEFAULT_MODEL, ConfigError, resolve_endpoint
|
||||
|
||||
|
||||
def test_openrouter_reference_route() -> None:
|
||||
endpoint = resolve_endpoint({"OPENROUTER_API_KEY": "secret"})
|
||||
|
||||
assert endpoint.api_key == "secret"
|
||||
assert endpoint.api_key_env == "OPENROUTER_API_KEY"
|
||||
assert endpoint.base_url == DEFAULT_BASE_URL
|
||||
assert endpoint.model == DEFAULT_MODEL
|
||||
assert endpoint.public_dict()["requested_model"] == DEFAULT_MODEL
|
||||
assert "secret" not in str(endpoint.public_dict())
|
||||
|
||||
|
||||
def test_generic_endpoint_takes_precedence() -> None:
|
||||
endpoint = resolve_endpoint(
|
||||
{
|
||||
"OPEN_MODEL_API_KEY": "local",
|
||||
"OPENROUTER_API_KEY": "gateway-secret",
|
||||
"OPEN_MODEL_BASE_URL": "http://127.0.0.1:8000/v1/",
|
||||
"OPEN_MODEL_MODEL": "Qwen/Qwen3-VL-32B-Instruct",
|
||||
"OPEN_MODEL_SCHEMA_MODE": "prompt",
|
||||
}
|
||||
)
|
||||
|
||||
assert endpoint.api_key == "local"
|
||||
assert endpoint.api_key_env == "OPEN_MODEL_API_KEY"
|
||||
assert endpoint.base_url == "http://127.0.0.1:8000/v1"
|
||||
assert endpoint.schema_mode == "prompt"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("values", "message"),
|
||||
[
|
||||
({}, "OPEN_MODEL_API_KEY"),
|
||||
({"OPEN_MODEL_API_KEY": "x", "OPEN_MODEL_BASE_URL": "localhost:8000/v1"}, "absolute"),
|
||||
({"OPEN_MODEL_API_KEY": "x", "OPEN_MODEL_SCHEMA_MODE": "xml"}, "native"),
|
||||
],
|
||||
)
|
||||
def test_invalid_configuration_fails_closed(values: dict[str, str], message: str) -> None:
|
||||
with pytest.raises(ConfigError, match=message):
|
||||
resolve_endpoint(values)
|
||||
@@ -0,0 +1,35 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from evidence import retain_step_screenshots, sha256_file, write_json, write_manifest
|
||||
|
||||
|
||||
def test_screenshots_are_copied_and_history_is_rewritten(tmp_path: Path) -> None:
|
||||
temporary_screenshot = tmp_path / "temporary.png"
|
||||
temporary_screenshot.write_bytes(b"not-a-real-png-but-stable")
|
||||
run_dir = tmp_path / "run"
|
||||
history = {"history": [{"state": {"screenshot_path": str(temporary_screenshot)}}]}
|
||||
|
||||
retained, records = retain_step_screenshots(history, run_dir)
|
||||
|
||||
retained_path = run_dir / "screenshots" / "step-001.png"
|
||||
assert retained_path.read_bytes() == temporary_screenshot.read_bytes()
|
||||
assert retained["history"][0]["state"]["screenshot_path"] == "screenshots/step-001.png"
|
||||
assert records[0]["sha256"] == sha256_file(retained_path)
|
||||
assert history["history"][0]["state"]["screenshot_path"] == str(temporary_screenshot)
|
||||
|
||||
|
||||
def test_manifest_hashes_retained_artifacts(tmp_path: Path) -> None:
|
||||
write_json(tmp_path / "summary.json", {"status": "complete"})
|
||||
|
||||
manifest_path = write_manifest(tmp_path, {"experiment": "test", "credential_retained": False})
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
|
||||
assert manifest["credential_retained"] is False
|
||||
assert manifest["artifacts"] == [
|
||||
{
|
||||
"path": "summary.json",
|
||||
"bytes": (tmp_path / "summary.json").stat().st_size,
|
||||
"sha256": sha256_file(tmp_path / "summary.json"),
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,228 @@
|
||||
"""Validate a retained open-model Computer Use run without another model call."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from evidence import sha256_file, write_json, write_manifest
|
||||
|
||||
SECRET_PATTERNS = (
|
||||
re.compile(r"sk-or-v1-[A-Za-z0-9_-]{20,}"),
|
||||
re.compile(r"sk-[A-Za-z0-9_-]{20,}"),
|
||||
re.compile(r"Bearer\s+[A-Za-z0-9._-]{20,}", re.IGNORECASE),
|
||||
)
|
||||
|
||||
|
||||
def load_json(path: Path) -> Any:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def add_check(checks: list[dict[str, Any]], name: str, passed: bool, detail: Any) -> None:
|
||||
checks.append({"name": name, "passed": bool(passed), "detail": detail})
|
||||
|
||||
|
||||
def verify_existing_manifest(run_dir: Path) -> tuple[bool, dict[str, Any]]:
|
||||
manifest = load_json(run_dir / "manifest.json")
|
||||
expected = {item["path"]: item for item in manifest["artifacts"]}
|
||||
actual_paths = {
|
||||
path.relative_to(run_dir).as_posix()
|
||||
for path in run_dir.rglob("*")
|
||||
if path.is_file() and path.name != "manifest.json"
|
||||
}
|
||||
failures = []
|
||||
for relative, item in expected.items():
|
||||
path = run_dir / relative
|
||||
if not path.is_file():
|
||||
failures.append({"path": relative, "reason": "missing"})
|
||||
elif path.stat().st_size != item["bytes"] or sha256_file(path) != item["sha256"]:
|
||||
failures.append({"path": relative, "reason": "hash_or_size_mismatch"})
|
||||
extras = sorted(actual_paths - set(expected))
|
||||
return not failures and not extras, {"failures": failures, "unmanifested_files": extras}
|
||||
|
||||
|
||||
def credential_scan(run_dir: Path) -> list[dict[str, str]]:
|
||||
findings = []
|
||||
for path in sorted(run_dir.rglob("*")):
|
||||
if not path.is_file() or path.suffix.lower() not in {".json", ".txt"}:
|
||||
continue
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
for pattern in SECRET_PATTERNS:
|
||||
if pattern.search(text):
|
||||
findings.append(
|
||||
{"path": path.relative_to(run_dir).as_posix(), "pattern": pattern.pattern}
|
||||
)
|
||||
return findings
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("run_dir", type=Path)
|
||||
parser.add_argument("--latest", type=Path)
|
||||
args = parser.parse_args()
|
||||
run_dir = args.run_dir.expanduser().resolve()
|
||||
|
||||
checks: list[dict[str, Any]] = []
|
||||
manifest_ok, manifest_detail = verify_existing_manifest(run_dir)
|
||||
add_check(checks, "existing_manifest_integrity", manifest_ok, manifest_detail)
|
||||
|
||||
summary = load_json(run_dir / "summary.json")
|
||||
history = load_json(run_dir / "history.json")["history"]
|
||||
receipts = load_json(run_dir / "api-receipts.json")
|
||||
screenshots = load_json(run_dir / "screenshots.json")
|
||||
|
||||
requested_model = summary["api"]["requested_model"]
|
||||
response_models = sorted(
|
||||
{
|
||||
item["body"]["model"]
|
||||
for item in receipts
|
||||
if item.get("kind") == "response"
|
||||
and isinstance(item.get("body"), dict)
|
||||
and isinstance(item["body"].get("model"), str)
|
||||
}
|
||||
)
|
||||
requests = [item for item in receipts if item.get("kind") == "request"]
|
||||
responses = [item for item in receipts if item.get("kind") == "response"]
|
||||
add_check(
|
||||
checks,
|
||||
"open_model_identity",
|
||||
requested_model == "qwen/qwen3-vl-32b-instruct" and response_models == [requested_model],
|
||||
{"requested": requested_model, "provider_reported": response_models},
|
||||
)
|
||||
add_check(
|
||||
checks,
|
||||
"real_api_receipts",
|
||||
len(requests) == len(responses) == len(history)
|
||||
and all(item.get("status_code") == 200 for item in responses),
|
||||
{"requests": len(requests), "responses": len(responses), "steps": len(history)},
|
||||
)
|
||||
|
||||
action_names = []
|
||||
one_action_per_step = True
|
||||
for item in history:
|
||||
actions = (item.get("model_output") or {}).get("action") or []
|
||||
one_action_per_step = one_action_per_step and len(actions) <= 1
|
||||
for action in actions:
|
||||
action_names.extend(action.keys())
|
||||
allowed_actions = {"navigate", "input", "click", "wait", "done"}
|
||||
add_check(
|
||||
checks,
|
||||
"bounded_read_only_actions",
|
||||
one_action_per_step and set(action_names) <= allowed_actions,
|
||||
{"one_action_per_step": one_action_per_step, "actions": action_names},
|
||||
)
|
||||
add_check(
|
||||
checks,
|
||||
"completed_within_limit",
|
||||
summary["status"] == "complete"
|
||||
and summary["agent_reported_success"] is True
|
||||
and len(history) == summary["steps_executed"]
|
||||
and len(history) <= summary["max_steps"],
|
||||
{
|
||||
"status": summary["status"],
|
||||
"steps": len(history),
|
||||
"limit": summary["max_steps"],
|
||||
},
|
||||
)
|
||||
|
||||
final_observation = history[-1].get("state_message") or ""
|
||||
required_observation_fragments = (
|
||||
"San Francisco Weather",
|
||||
"64\nSunny",
|
||||
"Feels Like\n62",
|
||||
"High\n74",
|
||||
"Low\n55",
|
||||
"Chance of Rain\n3%",
|
||||
)
|
||||
missing_fragments = [item for item in required_observation_fragments if item not in final_observation]
|
||||
add_check(
|
||||
checks,
|
||||
"answer_grounded_in_final_browser_observation",
|
||||
not missing_fragments
|
||||
and "64°F" in (summary.get("final_result") or "")
|
||||
and "sunny" in (summary.get("final_result") or "").lower(),
|
||||
{
|
||||
"missing_observation_fragments": missing_fragments,
|
||||
"final_screenshot": screenshots[-1].get("path"),
|
||||
"final_screenshot_sha256": screenshots[-1].get("sha256"),
|
||||
},
|
||||
)
|
||||
retained_screenshots = [item for item in screenshots if item.get("path")]
|
||||
screenshot_hashes_ok = all(
|
||||
sha256_file(run_dir / item["path"]) == item["sha256"] for item in retained_screenshots
|
||||
)
|
||||
add_check(
|
||||
checks,
|
||||
"step_screenshots_retained",
|
||||
len(retained_screenshots) == summary["screenshots_retained"] and screenshot_hashes_ok,
|
||||
{"retained": len(retained_screenshots), "hashes_ok": screenshot_hashes_ok},
|
||||
)
|
||||
|
||||
findings = credential_scan(run_dir)
|
||||
add_check(checks, "credential_scan", not findings, {"findings": findings})
|
||||
|
||||
source_root = Path(__file__).resolve().parent
|
||||
runtime_sources = ["config.py", "evidence.py", "main.py", "requirements.txt"]
|
||||
write_json(
|
||||
run_dir / "source-snapshot.json",
|
||||
{
|
||||
"capture_scope": "post-run hashes of unchanged runtime files",
|
||||
"sources": [
|
||||
{
|
||||
"path": relative,
|
||||
"bytes": (source_root / relative).stat().st_size,
|
||||
"sha256": sha256_file(source_root / relative),
|
||||
}
|
||||
for relative in runtime_sources
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
passed = all(item["passed"] for item in checks)
|
||||
acceptance_path = run_dir / "acceptance.json"
|
||||
write_json(
|
||||
acceptance_path,
|
||||
{
|
||||
"schema_version": 1,
|
||||
"experiment": "6-8",
|
||||
"arm": "open-model-api",
|
||||
"status": "passed" if passed else "failed",
|
||||
"checks": checks,
|
||||
"qualification": (
|
||||
"The Qwen3-VL browser arm passed. This does not claim that the separate "
|
||||
"Anthropic native-computer-tool arm in Experiment 6-7 ran."
|
||||
),
|
||||
},
|
||||
)
|
||||
manifest_path = write_manifest(
|
||||
run_dir,
|
||||
{
|
||||
"experiment": "6-8",
|
||||
"arm": "open-model-api",
|
||||
"status": "passed" if passed else "failed",
|
||||
"api": summary["api"],
|
||||
"credential_retained": False,
|
||||
},
|
||||
)
|
||||
if args.latest:
|
||||
write_json(
|
||||
args.latest,
|
||||
{
|
||||
"schema_version": 1,
|
||||
"experiment": "6-8",
|
||||
"arm": "open-model-api",
|
||||
"status": "passed" if passed else "failed",
|
||||
"run_dir": str(run_dir.relative_to(args.latest.parent.resolve())),
|
||||
"acceptance_sha256": sha256_file(acceptance_path),
|
||||
"manifest_sha256": sha256_file(manifest_path),
|
||||
},
|
||||
)
|
||||
print(json.dumps({"status": "passed" if passed else "failed", "checks": checks}, indent=2))
|
||||
return 0 if passed else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"experiment": "6-8",
|
||||
"arm": "open-model-api",
|
||||
"status": "passed",
|
||||
"run_dir": "runs/exp6-8-qwen3-vl-32b-20260801-v1",
|
||||
"acceptance_sha256": "3aed10516d20a0bbb8f13175df51168a8ec2c145cc9c4ad957b7db395b7210dc",
|
||||
"manifest_sha256": "0ae99327fa5a4ca81b530324ccd4242edf2dea3a28332cb2a71253fcf2b32098"
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"experiment": "6-8",
|
||||
"arm": "open-model-api",
|
||||
"status": "passed",
|
||||
"checks": [
|
||||
{
|
||||
"name": "existing_manifest_integrity",
|
||||
"passed": true,
|
||||
"detail": {
|
||||
"failures": [],
|
||||
"unmanifested_files": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "open_model_identity",
|
||||
"passed": true,
|
||||
"detail": {
|
||||
"requested": "qwen/qwen3-vl-32b-instruct",
|
||||
"provider_reported": [
|
||||
"qwen/qwen3-vl-32b-instruct"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "real_api_receipts",
|
||||
"passed": true,
|
||||
"detail": {
|
||||
"requests": 16,
|
||||
"responses": 16,
|
||||
"steps": 16
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "bounded_read_only_actions",
|
||||
"passed": true,
|
||||
"detail": {
|
||||
"one_action_per_step": true,
|
||||
"actions": [
|
||||
"navigate",
|
||||
"input",
|
||||
"click",
|
||||
"navigate",
|
||||
"navigate",
|
||||
"click",
|
||||
"click",
|
||||
"click",
|
||||
"input",
|
||||
"click",
|
||||
"click",
|
||||
"click",
|
||||
"wait",
|
||||
"click",
|
||||
"wait",
|
||||
"done"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "completed_within_limit",
|
||||
"passed": true,
|
||||
"detail": {
|
||||
"status": "complete",
|
||||
"steps": 16,
|
||||
"limit": 25
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "answer_grounded_in_final_browser_observation",
|
||||
"passed": true,
|
||||
"detail": {
|
||||
"missing_observation_fragments": [],
|
||||
"final_screenshot": "screenshots/step-016.png",
|
||||
"final_screenshot_sha256": "3639379122337818d7bef94facdf8110104bca8129f12b10f62d3d3e2678cfcc"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "step_screenshots_retained",
|
||||
"passed": true,
|
||||
"detail": {
|
||||
"retained": 15,
|
||||
"hashes_ok": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "credential_scan",
|
||||
"passed": true,
|
||||
"detail": {
|
||||
"findings": []
|
||||
}
|
||||
}
|
||||
],
|
||||
"qualification": "The Qwen3-VL browser arm passed. This does not claim that the separate Anthropic native-computer-tool arm in Experiment 6-7 ran."
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
)]}'
|
||||
22;["C2RtauXZIuLMkPIP-JjZsQw","2499"]c;[2,null,"0"]1b;<div jsname="Nll0ne"></div>c;[9,null,"0"]0;
|
||||
@@ -0,0 +1,2 @@
|
||||
)]}'
|
||||
22;["CmRtaqy8H6jDkPIPk8y26Qs","2499"]c;[2,null,"0"]1b;<div jsname="Nll0ne"></div>c;[9,null,"0"]0;
|
||||
@@ -0,0 +1,141 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"experiment": "6-8",
|
||||
"arm": "open-model-api",
|
||||
"status": "passed",
|
||||
"api": {
|
||||
"api_protocol": "openai-compatible-chat-completions",
|
||||
"api_key_env": "OPENROUTER_API_KEY",
|
||||
"base_url": "https://openrouter.ai/api/v1",
|
||||
"requested_model": "qwen/qwen3-vl-32b-instruct",
|
||||
"schema_mode": "native"
|
||||
},
|
||||
"credential_retained": false,
|
||||
"artifacts": [
|
||||
{
|
||||
"path": "acceptance.json",
|
||||
"bytes": 2074,
|
||||
"sha256": "3aed10516d20a0bbb8f13175df51168a8ec2c145cc9c4ad957b7db395b7210dc"
|
||||
},
|
||||
{
|
||||
"path": "agent-files/browseruse_agent_data/todo.md",
|
||||
"bytes": 0,
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
{
|
||||
"path": "api-receipts.json",
|
||||
"bytes": 48277,
|
||||
"sha256": "90c9d8153d13aa3b7a9f5aaebfe7e0766f277b5beee603e9ef302dc55af3ff0c"
|
||||
},
|
||||
{
|
||||
"path": "downloads/f (1).txt",
|
||||
"bytes": 102,
|
||||
"sha256": "2259823ec5ad0c2437d373ee983dca233e228b6114b91ac0abdea3f19115e95a"
|
||||
},
|
||||
{
|
||||
"path": "downloads/f.txt",
|
||||
"bytes": 102,
|
||||
"sha256": "1226ac037e3f8034e6ae1f6220167da5bec173f66232e0c5cd6f9698d2604ca3"
|
||||
},
|
||||
{
|
||||
"path": "history.json",
|
||||
"bytes": 203653,
|
||||
"sha256": "99a84f496574043a96bf57cfe3dbfe917496da4b0c5b07615f20467b0f945ea1"
|
||||
},
|
||||
{
|
||||
"path": "preflight.json",
|
||||
"bytes": 576,
|
||||
"sha256": "b41c8041a783ee1fa23c47af876f9c9a330811690db327d2917172e192b296b5"
|
||||
},
|
||||
{
|
||||
"path": "screenshots/step-002.png",
|
||||
"bytes": 28061,
|
||||
"sha256": "8c5c6e2e1e86efddfca55519f05989024638eb3a02ae664895c1e39ff83522f7"
|
||||
},
|
||||
{
|
||||
"path": "screenshots/step-003.png",
|
||||
"bytes": 29944,
|
||||
"sha256": "f81f47dbc57a9a8811f11f757b2346bd2bd62f44aa1b4ff97ba95cca2bf5f682"
|
||||
},
|
||||
{
|
||||
"path": "screenshots/step-004.png",
|
||||
"bytes": 55273,
|
||||
"sha256": "0bc740a84d46b7dd463b3a51938b65d8a0dc5d224792370f75cb0d4993611218"
|
||||
},
|
||||
{
|
||||
"path": "screenshots/step-005.png",
|
||||
"bytes": 27941,
|
||||
"sha256": "c1bc7955dac6fe5ad20ba354baba0090927bb47995fd31a0d4fd81031d915e4c"
|
||||
},
|
||||
{
|
||||
"path": "screenshots/step-006.png",
|
||||
"bytes": 23640,
|
||||
"sha256": "53605ec8ec85aeae9f4acae9a9470e6e49e3ced7c46f86597b7d66d7d233333a"
|
||||
},
|
||||
{
|
||||
"path": "screenshots/step-007.png",
|
||||
"bytes": 110544,
|
||||
"sha256": "4e2603a80d4ce151fd8c59f2085098f8ae95aff6e78379ded5ec16a9b7d92b60"
|
||||
},
|
||||
{
|
||||
"path": "screenshots/step-008.png",
|
||||
"bytes": 123209,
|
||||
"sha256": "54c1add05b5c9d2cf1b48441356807d3fc54645d0cd6d316ef5fb4509722d923"
|
||||
},
|
||||
{
|
||||
"path": "screenshots/step-009.png",
|
||||
"bytes": 127254,
|
||||
"sha256": "9febbe80a5d86ff6decd586d139157cb5262fe83819a652b17ea1bd7968dbe5f"
|
||||
},
|
||||
{
|
||||
"path": "screenshots/step-010.png",
|
||||
"bytes": 127210,
|
||||
"sha256": "e19ca02d40dd842180992b07e620d04b10b9de4380c8bba928ead6924970c821"
|
||||
},
|
||||
{
|
||||
"path": "screenshots/step-011.png",
|
||||
"bytes": 126639,
|
||||
"sha256": "0b6a960afe0043c71bff781ce49877c6323362c0ad70f22adfcbf6715604e746"
|
||||
},
|
||||
{
|
||||
"path": "screenshots/step-012.png",
|
||||
"bytes": 124265,
|
||||
"sha256": "816583d8b0d8293af360836f835b7342d7a3cf78a903d43cb6e0133ffbd2ea29"
|
||||
},
|
||||
{
|
||||
"path": "screenshots/step-013.png",
|
||||
"bytes": 42224,
|
||||
"sha256": "9128f3a40154ab5e281ad332ef3d680a2269823b5d2d11d63c6371625cf16ebc"
|
||||
},
|
||||
{
|
||||
"path": "screenshots/step-014.png",
|
||||
"bytes": 78288,
|
||||
"sha256": "c3bcf11a554460dfc8aef9afb8c6c862573f5e008831307c3190b11f0c84f13e"
|
||||
},
|
||||
{
|
||||
"path": "screenshots/step-015.png",
|
||||
"bytes": 52704,
|
||||
"sha256": "62dd5ad950d4ed339005219664a3c7bcd9dfbb29daed562cff2c02b9b4003114"
|
||||
},
|
||||
{
|
||||
"path": "screenshots/step-016.png",
|
||||
"bytes": 102608,
|
||||
"sha256": "3639379122337818d7bef94facdf8110104bca8129f12b10f62d3d3e2678cfcc"
|
||||
},
|
||||
{
|
||||
"path": "screenshots.json",
|
||||
"bytes": 2695,
|
||||
"sha256": "b783d7fbd9d61a38a3fc56c81a3aa783ade5a4ec69662b09ea7461d27e0b5bbb"
|
||||
},
|
||||
{
|
||||
"path": "source-snapshot.json",
|
||||
"bytes": 669,
|
||||
"sha256": "e7b235a2be23c4dd62fbeaa93331eab36a39d24d939e7339e3cd88adc332d8a2"
|
||||
},
|
||||
{
|
||||
"path": "summary.json",
|
||||
"bytes": 2398,
|
||||
"sha256": "a7efbbba82c18dd6f6c63a106b220ace3dd9e9ba5eb8c2308c8180a0b5441c44"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"api": {
|
||||
"api_protocol": "openai-compatible-chat-completions",
|
||||
"api_key_env": "OPENROUTER_API_KEY",
|
||||
"base_url": "https://openrouter.ai/api/v1",
|
||||
"requested_model": "qwen/qwen3-vl-32b-instruct",
|
||||
"schema_mode": "native"
|
||||
},
|
||||
"task": "Open Google, search for San Francisco weather today, and report the temperature and conditions. Do not sign in or change any external data.",
|
||||
"max_steps": 25,
|
||||
"headless": true,
|
||||
"record_video": false,
|
||||
"requirements": {
|
||||
"image_input": true,
|
||||
"structured_actions": true,
|
||||
"browser_execution": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
[
|
||||
{
|
||||
"step": 1,
|
||||
"source_present": false,
|
||||
"path": null,
|
||||
"sha256": null
|
||||
},
|
||||
{
|
||||
"step": 2,
|
||||
"source_present": true,
|
||||
"path": "screenshots/step-002.png",
|
||||
"sha256": "8c5c6e2e1e86efddfca55519f05989024638eb3a02ae664895c1e39ff83522f7"
|
||||
},
|
||||
{
|
||||
"step": 3,
|
||||
"source_present": true,
|
||||
"path": "screenshots/step-003.png",
|
||||
"sha256": "f81f47dbc57a9a8811f11f757b2346bd2bd62f44aa1b4ff97ba95cca2bf5f682"
|
||||
},
|
||||
{
|
||||
"step": 4,
|
||||
"source_present": true,
|
||||
"path": "screenshots/step-004.png",
|
||||
"sha256": "0bc740a84d46b7dd463b3a51938b65d8a0dc5d224792370f75cb0d4993611218"
|
||||
},
|
||||
{
|
||||
"step": 5,
|
||||
"source_present": true,
|
||||
"path": "screenshots/step-005.png",
|
||||
"sha256": "c1bc7955dac6fe5ad20ba354baba0090927bb47995fd31a0d4fd81031d915e4c"
|
||||
},
|
||||
{
|
||||
"step": 6,
|
||||
"source_present": true,
|
||||
"path": "screenshots/step-006.png",
|
||||
"sha256": "53605ec8ec85aeae9f4acae9a9470e6e49e3ced7c46f86597b7d66d7d233333a"
|
||||
},
|
||||
{
|
||||
"step": 7,
|
||||
"source_present": true,
|
||||
"path": "screenshots/step-007.png",
|
||||
"sha256": "4e2603a80d4ce151fd8c59f2085098f8ae95aff6e78379ded5ec16a9b7d92b60"
|
||||
},
|
||||
{
|
||||
"step": 8,
|
||||
"source_present": true,
|
||||
"path": "screenshots/step-008.png",
|
||||
"sha256": "54c1add05b5c9d2cf1b48441356807d3fc54645d0cd6d316ef5fb4509722d923"
|
||||
},
|
||||
{
|
||||
"step": 9,
|
||||
"source_present": true,
|
||||
"path": "screenshots/step-009.png",
|
||||
"sha256": "9febbe80a5d86ff6decd586d139157cb5262fe83819a652b17ea1bd7968dbe5f"
|
||||
},
|
||||
{
|
||||
"step": 10,
|
||||
"source_present": true,
|
||||
"path": "screenshots/step-010.png",
|
||||
"sha256": "e19ca02d40dd842180992b07e620d04b10b9de4380c8bba928ead6924970c821"
|
||||
},
|
||||
{
|
||||
"step": 11,
|
||||
"source_present": true,
|
||||
"path": "screenshots/step-011.png",
|
||||
"sha256": "0b6a960afe0043c71bff781ce49877c6323362c0ad70f22adfcbf6715604e746"
|
||||
},
|
||||
{
|
||||
"step": 12,
|
||||
"source_present": true,
|
||||
"path": "screenshots/step-012.png",
|
||||
"sha256": "816583d8b0d8293af360836f835b7342d7a3cf78a903d43cb6e0133ffbd2ea29"
|
||||
},
|
||||
{
|
||||
"step": 13,
|
||||
"source_present": true,
|
||||
"path": "screenshots/step-013.png",
|
||||
"sha256": "9128f3a40154ab5e281ad332ef3d680a2269823b5d2d11d63c6371625cf16ebc"
|
||||
},
|
||||
{
|
||||
"step": 14,
|
||||
"source_present": true,
|
||||
"path": "screenshots/step-014.png",
|
||||
"sha256": "c3bcf11a554460dfc8aef9afb8c6c862573f5e008831307c3190b11f0c84f13e"
|
||||
},
|
||||
{
|
||||
"step": 15,
|
||||
"source_present": true,
|
||||
"path": "screenshots/step-015.png",
|
||||
"sha256": "62dd5ad950d4ed339005219664a3c7bcd9dfbb29daed562cff2c02b9b4003114"
|
||||
},
|
||||
{
|
||||
"step": 16,
|
||||
"source_present": true,
|
||||
"path": "screenshots/step-016.png",
|
||||
"sha256": "3639379122337818d7bef94facdf8110104bca8129f12b10f62d3d3e2678cfcc"
|
||||
}
|
||||
]
|
||||
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 100 KiB |
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"capture_scope": "post-run hashes of unchanged runtime files",
|
||||
"sources": [
|
||||
{
|
||||
"path": "config.py",
|
||||
"bytes": 2717,
|
||||
"sha256": "d3145978a6ee5dbb3569ad63316235da9e83f4ef24ddabc8fab2399fd1bbfce5"
|
||||
},
|
||||
{
|
||||
"path": "evidence.py",
|
||||
"bytes": 2768,
|
||||
"sha256": "846977c70e7d199133adeaea1f617131edcf62f57c2c525ac77393a8a4d4fa28"
|
||||
},
|
||||
{
|
||||
"path": "main.py",
|
||||
"bytes": 9580,
|
||||
"sha256": "0964c502f19730e0f59f13e3b82b48a7bd15d609f3b49431f2a6bae81efa3cf9"
|
||||
},
|
||||
{
|
||||
"path": "requirements.txt",
|
||||
"bytes": 125,
|
||||
"sha256": "5be48fd1664657359cae7763aa074e059c9b7962204c3934e09d7e173cb520aa"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"experiment": "6-8",
|
||||
"acceptance_scope": "provider-portable-computer-use-trajectory",
|
||||
"status": "complete",
|
||||
"started_at": "2026-08-01T03:11:50.348098+00:00",
|
||||
"ended_at": "2026-08-01T03:14:24.560745+00:00",
|
||||
"api": {
|
||||
"api_protocol": "openai-compatible-chat-completions",
|
||||
"api_key_env": "OPENROUTER_API_KEY",
|
||||
"base_url": "https://openrouter.ai/api/v1",
|
||||
"requested_model": "qwen/qwen3-vl-32b-instruct",
|
||||
"schema_mode": "native"
|
||||
},
|
||||
"provider_models_reported": [
|
||||
"qwen/qwen3-vl-32b-instruct"
|
||||
],
|
||||
"browser_use_version": "0.9.5",
|
||||
"task": "Open Google, search for San Francisco weather today, and report the temperature and conditions. Do not sign in or change any external data.",
|
||||
"max_steps": 25,
|
||||
"steps_executed": 16,
|
||||
"agent_reported_success": true,
|
||||
"final_result": "The current weather in San Francisco is 64°F, with conditions described as 'Sunny'. The temperature feels like 62°F, with a high of 74°F and a low of 55°F for the day. There is a 3% chance of rain and 0 inches of precipitation expected. This information was retrieved from weather.com after successfully navigating to the correct location and selecting today's forecast.",
|
||||
"urls": [
|
||||
"about:blank",
|
||||
"https://www.google.com/",
|
||||
"https://www.google.com/",
|
||||
"https://www.google.com/",
|
||||
"https://www.google.com/",
|
||||
"https://weather.com/",
|
||||
"https://weather.com/",
|
||||
"https://weather.com/",
|
||||
"https://weather.com/",
|
||||
"https://weather.com/",
|
||||
"https://weather.com/",
|
||||
"https://weather.com/",
|
||||
"https://weather.com/us/california/city/san-francisco/tenday",
|
||||
"https://weather.com/us/california/city/san-francisco/tenday",
|
||||
"https://weather.com/us/california/city/san-francisco/today",
|
||||
"https://weather.com/us/california/city/san-francisco/today"
|
||||
],
|
||||
"errors": [
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
"Failed to click element <div index=9207>. The element may not be interactable or visible. If the page changed after navigation/interaction, the index [9207] may be stale. Get fresh browser state before retrying.",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
],
|
||||
"screenshots_retained": 15,
|
||||
"credential_retained": false,
|
||||
"qualification": "This is a separate open-model arm, not an Anthropic-equivalent result."
|
||||
}
|
||||