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

This commit is contained in:
2026-08-20 13:12:50 +00:00
commit b119135836
10275 changed files with 3284984 additions and 0 deletions
+111
View File
@@ -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
+273
View File
@@ -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;
File diff suppressed because one or more lines are too long
@@ -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"
}
]
Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

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."
}