Files
liqiang b119135836
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
ai-agent-book 精选快照(<2MB 代码与文档,来自 github.com/bojieli/ai-agent-book)
2026-08-20 13:12:50 +00:00

544 lines
25 KiB
Python

#!/usr/bin/env python3
"""Run Experiment 9-5 with a real model, localhost HTTP, and Chromium."""
from __future__ import annotations
import argparse
import asyncio
from datetime import datetime, timezone
import hashlib
import json
import os
import platform
from pathlib import Path
import re
import shutil
import time
from typing import Any
from urllib.request import Request, urlopen
from openai import OpenAI
from playwright.async_api import async_playwright
from learning_agent import KnowledgeBase
from learning_agent.replay import WorkflowReplayer
from learning_agent.workflow import (
ActionType,
PredicateType,
StatePredicate,
Workflow,
WorkflowStep,
)
from local_mail_sandbox import mail_sandbox
ROOT = Path(__file__).resolve().parent
FIRST = {"recipient": "test@example.com", "subject": "测试邮件", "content": "第一次探索正文"}
TRANSFER = {"recipient": "second@example.org", "subject": "季度报告", "content": "完全不同的回放正文"}
ALLOWED_PLAN = [
("input_text", "#recipient", "recipient"),
("input_text", "#subject", "subject"),
("input_text", "#content", "content"),
("click", "#send", None),
]
def _jsonable(value: Any) -> Any:
if hasattr(value, "model_dump"):
return _jsonable(value.model_dump(mode="json", exclude_none=True))
if isinstance(value, dict):
return {str(key): _jsonable(item) for key, item in value.items()}
if isinstance(value, (list, tuple)):
return [_jsonable(item) for item in value]
return value
def _post(url: str, payload: dict[str, Any]) -> dict[str, Any]:
request = Request(
url,
data=json.dumps(payload).encode("utf-8"),
headers={"content-type": "application/json"},
method="POST",
)
with urlopen(request, timeout=10) as response:
return json.loads(response.read())
def _provider_client(provider: str) -> tuple[OpenAI, dict[str, Any]]:
if provider == "openrouter":
key = os.getenv("OPENROUTER_API_KEY")
if not key:
raise RuntimeError("OPENROUTER_API_KEY is required for --provider openrouter")
endpoint = "https://openrouter.ai/api/v1"
return OpenAI(api_key=key, base_url=endpoint), {
"provider": provider, "endpoint": endpoint + "/chat/completions", "credential_env": "OPENROUTER_API_KEY"
}
if provider == "ark":
key = os.getenv("ARK_API_KEY")
if not key:
raise RuntimeError("ARK_API_KEY is required for --provider ark")
endpoint = "https://ark.cn-beijing.volces.com/api/v3"
return OpenAI(api_key=key, base_url=endpoint), {
"provider": provider, "endpoint": endpoint + "/chat/completions", "credential_env": "ARK_API_KEY"
}
if provider == "moonshot":
key = os.getenv("MOONSHOT_API_KEY")
if not key:
raise RuntimeError("MOONSHOT_API_KEY is required for --provider moonshot")
endpoint = "https://api.moonshot.cn/v1"
return OpenAI(api_key=key, base_url=endpoint), {
"provider": provider, "endpoint": endpoint + "/chat/completions", "credential_env": "MOONSHOT_API_KEY"
}
key = os.getenv("OPENAI_API_KEY")
if not key:
raise RuntimeError("OPENAI_API_KEY is required for --provider openai")
return OpenAI(api_key=key), {
"provider": provider, "endpoint": "https://api.openai.com/v1/chat/completions", "credential_env": "OPENAI_API_KEY"
}
def _extract_json(text: str) -> dict[str, Any]:
cleaned = re.sub(r"^```(?:json)?\s*|\s*```$", "", text.strip(), flags=re.I)
try:
return json.loads(cleaned)
except json.JSONDecodeError:
match = re.search(r"\{.*\}", cleaned, re.S)
if not match:
raise
return json.loads(match.group(0))
def _usage(response_payload: dict[str, Any]) -> dict[str, Any]:
usage = response_payload.get("usage") or {}
cost = usage.get("cost")
return {
"prompt_tokens": int(usage.get("prompt_tokens") or 0),
"completion_tokens": int(usage.get("completion_tokens") or 0),
"total_tokens": int(usage.get("total_tokens") or 0),
"provider_reported_cost_usd": float(cost) if cost is not None else None,
"cost_qualification": (
"provider-native usage.cost" if cost is not None
else "provider did not expose monetary cost; no price was guessed"
),
}
def _sum_usage(receipts: list[dict[str, Any]]) -> dict[str, Any]:
prompt = completion = total = 0
cost = 0.0
cost_count = 0
for receipt in receipts:
usage = receipt["response"].get("usage") or {}
prompt += int(usage.get("prompt_tokens") or 0)
completion += int(usage.get("completion_tokens") or 0)
total += int(usage.get("total_tokens") or 0)
if usage.get("cost") is not None:
cost += float(usage["cost"])
cost_count += 1
return {
"prompt_tokens": prompt,
"completion_tokens": completion,
"total_tokens": total or prompt + completion,
"provider_reported_cost_usd": round(cost, 9) if cost_count else None,
"provider_reported_cost_observations": cost_count,
"cost_qualification": (
"provider-native usage.cost summed across calls"
if cost_count else "provider did not expose monetary cost; no price was guessed"
),
}
async def model_explore(
base_url: str, client: OpenAI, backend: dict[str, Any], model: str,
seed: int, screenshot_path: Path,
):
"""Run a real observe-decide-act loop, one provider call per browser step."""
started = time.perf_counter()
async with async_playwright() as playwright:
browser = await playwright.chromium.launch(headless=True)
browser_version = browser.version
page = await browser.new_page()
await page.goto(base_url + "/app", wait_until="networkidle")
receipts = []
captured = []
for step_index, expected in enumerate(ALLOWED_PLAN):
controls = await page.locator("input, textarea, button").evaluate_all("""els => els.map(e => {
const parts=[]; let n=e;
while(n && n.nodeType===1){ let i=1, s=n.previousElementSibling; while(s){if(s.tagName===n.tagName)i++;s=s.previousElementSibling} parts.unshift(n.tagName.toLowerCase()+'['+i+']');n=n.parentElement; }
return {tag:e.tagName.toLowerCase(),id:e.id,name:e.name,type:e.type||null,
role:e.getAttribute('role'),aria_label:e.getAttribute('aria-label'),
data_testid:e.getAttribute('data-testid'),text:e.innerText||'',
label:e.labels?.[0]?.innerText||'',value:e.value||'',xpath:'/'+parts.join('/')};
})""")
status = {
"url": page.url,
"status_text": await page.locator("#status").inner_text(),
"persisted_count": await page.evaluate("window.__agentState.persistedCount"),
}
prompt = f"""You are the stepwise browser Agent for a local disposable mail sandbox.
Choose exactly the next safe action, not a multi-action plan. Return JSON only as
{{"action":"input_text|click","selector":"#id","value_key":"recipient|subject|content|null"}}.
Goal: send one message using the supplied parameter keys. Fill recipient, subject and content before clicking send.
Task parameters: {json.dumps(FIRST, ensure_ascii=False)}
Prior actions: {json.dumps(captured, ensure_ascii=False)}
Current page state: {json.dumps(status, ensure_ascii=False)}
Current controls: {json.dumps(controls, ensure_ascii=False)}
"""
request_payload = {
"model": model,
"messages": [{"role": "user", "content": prompt}],
"temperature": 0,
"seed": seed + step_index,
"max_tokens": 300,
"response_format": {"type": "json_object"},
}
api_started = time.perf_counter()
response = await asyncio.to_thread(client.chat.completions.create, **request_payload)
raw_response = _jsonable(response)
decision = _extract_json(response.choices[0].message.content or "")
normalized = (decision.get("action"), decision.get("selector"), decision.get("value_key"))
if normalized != expected:
raise RuntimeError(f"model returned unsafe or out-of-order step {step_index + 1}: {normalized!r}; expected {expected!r}")
action, selector, value_key = normalized
control = next(item for item in controls if f"#{item['id']}" == selector)
before = {
"url": page.url,
"visible": await page.locator(selector).is_visible(),
"page_status": status,
}
if action == "input_text":
await page.locator(selector).fill(FIRST[str(value_key)])
else:
await page.locator(selector).click()
await page.wait_for_function("window.__agentState.persistedCount === 1")
captured.append({
"action": action, "selector": selector, "value_key": value_key,
"action_parameters": ({"text_template": "{" + str(value_key) + "}"} if value_key else {}),
"before": before,
"after": {
"url": page.url,
"value": await page.locator(selector).input_value() if action == "input_text" else None,
"status_text": await page.locator("#status").inner_text(),
},
"locator_evidence": {
"xpath": control["xpath"], "css": selector, "id": control["id"],
"name": control["name"], "type": control["type"], "role": control["role"],
"aria-label": control["aria_label"], "data-testid": control["data_testid"],
"captured_url": page.url,
},
})
receipts.append({
"kind": "browser_agent_step",
"step": step_index + 1,
"backend": {**backend, "credential_value_recorded": False},
"request": request_payload,
"response": raw_response,
"elapsed_seconds": round(time.perf_counter() - api_started, 6),
})
final_text = await page.locator("#sent-list").inner_text()
await page.screenshot(path=str(screenshot_path), full_page=True)
await browser.close()
return {
"success": all(value in final_text for value in FIRST.values()),
"captured_actions": captured,
"llm_calls": len(receipts),
"elapsed_seconds": round(time.perf_counter() - started, 6),
"browser": {"engine": "playwright-chromium", "version": browser_version, "headless": True},
"api_receipts": receipts,
"usage": _sum_usage(receipts),
"screenshot": screenshot_path.name,
}
def compile_workflow(base_url: str, captured: list[dict[str, Any]]) -> Workflow:
workflow = Workflow(
workflow_id="local-mail-send-v1",
intent="send message",
initial_url=base_url + "/app",
example_parameters=dict(FIRST),
description="Model-explored localhost mail workflow",
)
for item in captured:
selector = item["selector"]
locator = item.get("locator_evidence") or {}
attributes = {
key: str(value) for key, value in locator.items()
if key in {"id", "name", "type", "role", "aria-label", "data-testid"} and value is not None
}
if item["action"] == "input_text":
key = item["value_key"]
workflow.add_step(WorkflowStep(
action_type=ActionType.INPUT_TEXT,
xpath=locator.get("xpath"),
css_selector=selector,
element_attributes=attributes,
parameters={"text": "{" + key + "}", "clear_existing": True},
description=f"fill {key}",
preconditions=[StatePredicate(
PredicateType.ELEMENT_VISIBLE, True, selector=selector,
description=f"{key} field is visible",
)],
postconditions=[StatePredicate(
PredicateType.ELEMENT_VALUE_EQUALS, "{" + key + "}", selector=selector,
description=f"{key} field contains this run's value",
)],
))
else:
workflow.add_step(WorkflowStep(
action_type=ActionType.CLICK,
xpath=locator.get("xpath"),
css_selector=selector,
element_attributes=attributes,
description="submit message",
preconditions=[StatePredicate(
PredicateType.ELEMENT_VISIBLE, True, selector=selector,
description="send button is visible before the side effect",
)],
postconditions=[
StatePredicate(
PredicateType.ELEMENT_TEXT_CONTAINS, "Message sent", selector="#status",
description="server-confirmed send status is visible",
),
StatePredicate(
PredicateType.PAGE_STATE_EQUALS, True, state_key="sent",
description="page state confirms persistence",
),
],
))
if workflow.steps:
workflow.steps[0].preconditions.insert(0, StatePredicate(
PredicateType.URL_CONTAINS, "/app",
description="workflow is on the captured application route",
))
for key in ("recipient", "subject", "content"):
workflow.final_predicates.append(StatePredicate(
PredicateType.ELEMENT_TEXT_CONTAINS, "{" + key + "}", selector="#sent-list",
description=f"sent-list contains current {key}",
))
return workflow
async def replay(workflow: Workflow, parameters: dict[str, str], *, validate_state: bool = True):
runner = WorkflowReplayer(headless=True)
await runner.setup()
try:
return await runner.replay_workflow(workflow, parameters=parameters, validate_state=validate_state)
finally:
await runner.cleanup()
def _sha(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
async def campaign(args: argparse.Namespace) -> dict[str, Any]:
client, backend = _provider_client(args.provider)
stamp = datetime.now(timezone.utc).strftime("real_%Y%m%dT%H%M%SZ")
output_dir = args.output_dir or ROOT / "validation" / stamp
output_dir.mkdir(parents=True, exist_ok=False)
store_dir = output_dir / "workflow_store"
with mail_sandbox() as (base_url, state):
state.reset(version=1)
screenshot_path = output_dir / "exploration-final.png"
exploration = await model_explore(base_url, client, backend, args.model, args.seed, screenshot_path)
workflow = compile_workflow(base_url, exploration["captured_actions"])
store = KnowledgeBase(str(store_dir))
store.save_candidate(workflow)
candidate_file = store_dir / f"candidate_{workflow.workflow_id}.json"
candidate_before_reset = candidate_file.exists() and not store.workflows
candidate_snapshot = output_dir / "candidate_snapshot.json"
shutil.copyfile(candidate_file, candidate_snapshot)
# An independent HTTP reset is the validation_reset boundary.
_post(base_url + "/api/reset", {"version": 1, "fault": "normal"})
validation = await replay(workflow, FIRST)
if validation["success"]:
workflow.mark_validated()
store.publish_validated(workflow)
# A workflow with no reset callback remains a candidate and cannot be retrieved.
no_reset = compile_workflow(base_url, exploration["captured_actions"])
no_reset.workflow_id = "no-reset-negative-control"
store.save_candidate(no_reset)
no_reset_stayed_candidate = no_reset.workflow_id not in store.workflows
# Match and replay different literals without another model call.
_post(base_url + "/api/reset", {"version": 1, "fault": "normal"})
match = store.find_workflow_for_task(
'send message to second@example.org with subject "季度报告" and content "完全不同的回放正文"'
)
transfer = await replay(match.workflow, TRANSFER) if match else {"success": False, "actions_executed": []}
transfer_state = state.snapshot()
transfer_exact = transfer_state["messages"] == [TRANSFER]
false_success_cases = []
for name, parameters, fault in (
("blank_content", {**TRANSFER, "content": ""}, "normal"),
("dropped_persistence", dict(TRANSFER), "drop_persistence"),
):
_post(base_url + "/api/reset", {"version": 1, "fault": fault})
naive = await replay(workflow, parameters, validate_state=False)
naive_persisted = bool(state.snapshot()["messages"])
_post(base_url + "/api/reset", {"version": 1, "fault": fault})
validated = await replay(workflow, parameters, validate_state=True)
validated_persisted = bool(state.snapshot()["messages"])
false_success_cases.append({
"case": name,
"naive_reported_success": naive["success"],
"naive_actual_success": naive_persisted,
"validated_reported_success": validated["success"],
"validated_actual_success": validated_persisted,
"validated_failure": validated.get("failed_predicate") or validated.get("errors"),
})
# UI version 2 changes the button locator. The precondition must stop
# before any POST /api/send occurs, then the store archives the version.
_post(base_url + "/api/reset", {"version": 2, "fault": "normal"})
before_change_events = len([e for e in state.snapshot()["events"] if e["event"] == "send_request"])
changed = await replay(workflow, TRANSFER)
after_change_events = len([e for e in state.snapshot()["events"] if e["event"] == "send_request"])
if not changed["success"]:
store.invalidate_workflow(workflow.workflow_id, changed.get("failed_predicate") or str(changed["errors"]))
invalid_file = store_dir / f"invalid_{workflow.workflow_id}.json"
post_invalidation_match = store.find_workflow_for_task("send message to audit@example.org")
naive_false_rate = sum(
row["naive_reported_success"] and not row["naive_actual_success"] for row in false_success_cases
) / len(false_success_cases)
validated_false_rate = sum(
row["validated_reported_success"] and not row["validated_actual_success"] for row in false_success_cases
) / len(false_success_cases)
metrics = {
"exploration_seconds": exploration["elapsed_seconds"],
"validation_replay_seconds": validation["execution_time"],
"parameterized_replay_seconds": transfer.get("execution_time"),
"exploration_llm_calls": exploration["llm_calls"],
"parameterized_replay_llm_calls": 0,
"exploration_success_rate": float(exploration["success"]),
"replay_success_rate": float(bool(transfer["success"])),
"workflow_match_rate": float(match is not None),
"naive_false_success_rate": naive_false_rate,
"state_validated_false_success_rate": validated_false_rate,
"page_change_detection_rate": float(not changed["success"]),
"fallback_relearn_count": int(changed["fallback_required"]),
"speedup_exploration_over_parameterized_replay": (
exploration["elapsed_seconds"] / transfer.get("execution_time")
if transfer.get("execution_time") else None
),
}
gates = {
"real_stepwise_model_exploration_succeeded": exploration["success"] and exploration["llm_calls"] == len(ALLOWED_PLAN),
"capture_has_actions_parameters_url_and_locator_evidence": all(
item.get("before", {}).get("url")
and item.get("locator_evidence", {}).get("xpath")
and item.get("locator_evidence", {}).get("css")
and item.get("locator_evidence", {}).get("id")
and "action_parameters" in item
for item in exploration["captured_actions"]
),
"workflow_has_pre_post_and_final_state_checks": (
all(step.preconditions and step.postconditions for step in workflow.steps)
and bool(workflow.final_predicates)
),
"first_success_created_candidate": candidate_before_reset,
"independent_reset_replay_validated": validation["success"] and workflow.validated_at is not None,
"missing_reset_never_published": no_reset_stayed_candidate,
"validated_workflow_was_matchable": match is not None,
"parameterized_replay_used_no_llm": transfer["success"] and transfer_exact,
"training_literals_not_reused": transfer_exact and FIRST not in transfer_state["messages"],
"naive_baseline_exposes_false_success": naive_false_rate == 1.0,
"state_validation_rejects_false_success": validated_false_rate == 0.0 and all(
not row["validated_reported_success"] for row in false_success_cases
),
"page_change_stopped_before_side_effect": (
not changed["success"] and changed["fallback_required"]
and before_change_events == after_change_events
),
"invalid_version_removed_from_retrieval": (
invalid_file.exists() and workflow.workflow_id not in store.workflows
and post_invalidation_match is None
),
}
result_claims = {
"validated_workflow_completed_parameterized_transfer": bool(transfer["success"] and transfer_exact),
"replay_eliminated_stepwise_llm_calls": exploration["llm_calls"] > 0 and metrics["parameterized_replay_llm_calls"] == 0,
"state_validation_reduced_false_success_rate": validated_false_rate < naive_false_rate,
"page_change_was_detected_before_side_effect": gates["page_change_stopped_before_side_effect"],
"replay_was_faster_than_exploration": bool(
transfer.get("execution_time") and exploration["elapsed_seconds"] > transfer["execution_time"]
),
}
report = {
"schema_version": 2,
"experiment": "9-5",
"canonical_source": "book/chapter9.md#实验-9-5-从浏览器轨迹生成可验证工作流",
"executed_at": datetime.now(timezone.utc).isoformat(),
"execution_mode": "real_model_plus_local_http_plus_real_playwright_chromium",
"model": args.model,
"seed": args.seed,
"backend": {**backend, "credential_value_recorded": False},
"credential_value_recorded": False,
"host": {"python": platform.python_version(), "platform": platform.platform()},
"lifecycle": {
"exploration": exploration,
"candidate_file": str(candidate_file.relative_to(output_dir)),
"validation_reset_count": state.reset_count,
"validation_replay": validation,
"transfer_replay": transfer,
"page_change_replay": changed,
"invalid_file": str(invalid_file.relative_to(output_dir)),
},
"false_success_comparison": false_success_cases,
"metrics": metrics,
"cost": exploration["usage"],
"sandbox": {
"url_origin": base_url,
"external_side_effects": False,
"final_state": state.snapshot(),
},
"gates": gates,
"acceptance": {
"gates": [{"name": name, "passed": passed} for name, passed in gates.items()],
"execution_accepted": all(gates.values()),
"result_claims": result_claims,
"all_manuscript_result_claims_observed": all(result_claims.values()),
},
"accepted": all(gates.values()),
}
report_path = output_dir / "evidence.json"
report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
report["artifact_hashes"] = {
"candidate_snapshot_sha256": _sha(candidate_snapshot),
"invalid_sha256": _sha(invalid_file),
"exploration_screenshot_sha256": _sha(screenshot_path),
}
report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
latest = ROOT / "validation" / "latest.json"
latest.parent.mkdir(exist_ok=True)
shutil.copyfile(report_path, latest)
print(json.dumps({
"evidence": str(report_path.relative_to(ROOT)),
"evidence_sha256": _sha(report_path),
"accepted": report["accepted"],
"metrics": report["metrics"],
"cost": report["cost"],
}, ensure_ascii=False, indent=2))
return report
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--provider", choices=("openrouter", "openai", "ark", "moonshot"), default="ark")
parser.add_argument("--model", default="doubao-seed-1-6-flash-250615")
parser.add_argument("--seed", type=int, default=8401)
parser.add_argument("--output-dir", type=Path)
args = parser.parse_args()
report = asyncio.run(campaign(args))
return 0 if report["accepted"] else 1
if __name__ == "__main__":
raise SystemExit(main())