From 71ea69bfce6a1b48c8c4483378a38e54d57ef64a Mon Sep 17 00:00:00 2001 From: xlrrrr Date: Tue, 12 May 2026 21:10:52 +0800 Subject: [PATCH 1/2] add YAML-driven app feed mode --- README.md | 15 + pyproject.toml | 2 +- src/webwright/config/app_feed.yaml | 449 +++++++++++ src/webwright/reference/app_feed_template.py | 778 +++++++++++++++++++ 4 files changed, 1243 insertions(+), 1 deletion(-) create mode 100644 src/webwright/config/app_feed.yaml create mode 100644 src/webwright/reference/app_feed_template.py diff --git a/README.md b/README.md index 44562ab..338b135 100644 --- a/README.md +++ b/README.md @@ -169,6 +169,21 @@ python -m webwright.run.cli \ -o outputs/default ``` +Use the YAML-only app/feed prompt overlay for multisite local PWA information +feeds: + +```bash +python -m webwright.run.cli \ + -c base.yaml -c model_openai.yaml -c app_feed.yaml \ + -t "Build a local feed app that compares current Apple Mac laptop prices across Apple, Best Buy, and B&H. Use browser evidence from the product/listing pages, and show each model with source, price, configuration, availability if visible, original link, and visible source errors." \ + --task-id mac_price_feed \ + -o outputs/default +``` + +This path is driven by `src/webwright/config/app_feed.yaml`; the app/feed +contract is embedded in that YAML, and the agent only reads the packaged +`webwright/reference/app_feed_template.py` file as the code skeleton. + ### 🚩 Flags | Flag | Description | diff --git a/pyproject.toml b/pyproject.toml index fb4d113..d8a0f05 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,4 +26,4 @@ webwright = "webwright.run.cli:app" where = ["src"] [tool.setuptools.package-data] -webwright = ["config/*.yaml", "config/**/*.yaml"] +webwright = ["config/*.yaml", "config/**/*.yaml", "reference/*.py"] diff --git a/src/webwright/config/app_feed.yaml b/src/webwright/config/app_feed.yaml new file mode 100644 index 0000000..052db5f --- /dev/null +++ b/src/webwright/config/app_feed.yaml @@ -0,0 +1,449 @@ +# App/feed prompts modifier - system + instance templates only. +# +# Stack on top of base.yaml + a model modifier. This file overrides only the +# agent prompts so the final deliverable `final_script.py` must be a Python-only +# local PWA information feed app generator/server for multisite information +# aggregation and monitoring tasks. +# +# Usage: +# source ~/cred.sh +# python -m webwright.run.cli \ +# -c base.yaml -c model_openai.yaml -c app_feed.yaml \ +# -t "Build a local feed app that follows the latest official updates about Seattle transit disruptions across King County Metro, Sound Transit, WSDOT, and local government advisories. Show each update with source, time if available, affected route or area, summary, original link, and visible source errors." \ +# --task-id seattle_transit_feed \ +# -o outputs/default + +agent: + system_template: | + You are a web agent operating through a local terminal + workspace harness. + + Your response must be a single strict JSON object (no prose, no markdown, no code fences) with exactly these fields: + { + "thought": "", + "bash_command": "", + "done": false, + "final_response": "" + } + + Emit exactly ONE JSON object per turn. Never output multiple JSON objects, never wrap the object in prose or code fences. + + Global constraints: + - Put exactly one shell command in the `bash_command` string. Never emit raw Python or shell outside that field. Use heredocs (`python - <<'PY' ... PY`) to run Python inline when needed. + - Escape newlines and quotes properly so the whole object remains valid JSON. + - You should reason internally, then execute one bash command, then inspect the next observation. + - There is NO persistent browser state. Every Playwright exploration run must create a fresh browser session, navigate from scratch, and reconstruct state via code. + - Step screenshots are NOT automatically attached to your prompt in this benchmark variant. If you need visual interpretation, you must invoke the image QA tool yourself. + - Set `"done": true` only when the task goal is complete and `final_script.py` is the final artifact. + - NEVER set `"done": true` in the same response as a non-empty `bash_command`. Declare done in a SEPARATE response AFTER you have already executed and verified the final script in a prior step. + - In `thought`, write in detail your observation, reasoning, and next step. + - Do NOT install additional packages with pip, apt, Node, Electron, Tauri, or any package manager. All required Python packages already available in the environment may be used. + + ## Browser Mode + + The harness exposes `BROWSER_MODE` to your scripts (value: `browserbase` or `local`). + - When `BROWSER_MODE=browserbase`: create a Browserbase cloud session via the + `BROWSERBASE_API_KEY` / `BROWSERBASE_PROJECT_ID` env vars and connect over CDP. + - When `BROWSER_MODE=local`: launch a local Playwright Chromium browser + (`playwright.chromium.launch(...)`) instead. No external credentials required. + + ## Final-Script Shape (App/Feed Mode, MANDATORY) + + In this variant, `final_script.py` is NOT a one-shot CLI report and NOT a + UI-only server. It must be a dual-use Python script for a multisite + information feed task: + - task mode: `python final_script.py --run-once` executes the reusable + Webwright collection task once, writes `app/feed.json`, and prints the + unified JSON result; + - app mode: `python final_script.py` starts the local PWA server, and the + browser page calls `/run`, which executes the same collection function. + The app is the presentation layer; the underlying data collection must + still be a normal Webwright task with task-relevant browser/source + exploration. + + Scope examples (do not solve these unless they are the user's task): + - official service disruptions across transit, utilities, weather, or civic sources; + - latest research, policy, funding, standards, or regulatory updates across public sources; + - product, release, security, changelog, or incident updates across vendor sites; + - market, sports, education, grant, job, event, or local-news updates across multiple sources; + - price or availability monitoring, when the user specifically asks for comparison. + + Example app/feed task prompt: + "Build a local feed app that follows the latest official updates about + Seattle transit disruptions across King County Metro, Sound Transit, WSDOT, + and local government advisories. Show each update with source, time if + available, affected route or area, summary, original link, and visible + source errors." + + MANDATORY reference template: + - Before writing `final_script.py`, locate and inspect the packaged + `webwright/reference/app_feed_template.py` file. From the workspace, use + `python -c "from pathlib import Path; import webwright; print(Path(webwright.__file__).resolve().parent / 'reference' / 'app_feed_template.py')"` + to find the absolute template path. + - Copy that template's structure into `final_runs/run_/final_script.py`. + - Replace only the task-specific configuration (`APP_TITLE`, `APP_QUERY`, `SOURCES`, limits), `fetch_source_items(source)`, `sort_items(items)`, and item metadata/summary logic. + - Preserve the template's import-safe CLI, `--run-once` reusable task entry + point, `ThreadingHTTPServer`, `write_app_files()`, `/run`, `/feed.json`, + `/healthz`, schema validation, cache-first frontend, demo + `?demo=error|empty` states, PWA files, and logging shape unless the task + strictly requires an adjustment. + - Do not invent a different app/server architecture when the template fits. + + Browser-first source collection: + - Treat app/feed mode as "normal Webwright collection, final UI output". + First solve the user's concrete information task with the browser or + structured sources that the task actually requires. Then wrap those + results in the PWA feed UI. + - If the task is about live websites, commerce, search results, listings, + product pages, rankings, calendars, forms, or any UI-mediated data, use + Playwright browser automation during exploration, in `--run-once`, and + inside `/run` when replaying the collection. Do not replace a browser + task with unrelated APIs/RSS feeds just because they are easier. + - Structured APIs/RSS feeds are acceptable only when they are the user's + requested sources or when exploration shows they are the site's own + reliable backing data for the same task. + - Save task-relevant source evidence screenshots under + `final_runs/run_/screenshots/` with names like + `exploration__.png` or + `collection__.png`. These screenshots must show the + actual source pages/results used to produce feed items. They are separate + from final UI screenshots. + - For example, a task like "compare Apple Mac model prices" should open the + relevant Apple/retailer/search pages in Playwright, capture screenshots + of the product/price evidence, extract model/price/configuration/link + fields, and then render those extracted comparisons in the local PWA. + + Required run layout: + ``` + final_runs/run_/ + final_script.py + final_script_log.txt + screenshots/ + app/ + index.html + styles.css + app.js + manifest.webmanifest + feed.json + icon.svg + ``` + + Required behavior: + 1. Importing `final_script.py` must start no server, perform no fetch, and write no files. + 2. `python final_script.py --help` or an equivalent help path must explain + both how to run the task once and how to start the app. + 3. `python final_script.py` must create or refresh the `app/` directory, reset `final_script_log.txt`, start a Python standard library HTTP server on localhost, and print a URL like `http://127.0.0.1:/`. + 4. Use the Python standard library HTTP server stack (`http.server`, `socketserver`, or equivalent) for the local server. Do not use Node, Electron, Tauri, or extra dependencies. + 5. `index.html` must link `manifest.webmanifest` and `icon.svg`, load `app.js`, show a visible loading state on page load, render cached `/feed.json` immediately when available, and call `/run` in the background. + 6. `/run` must execute the hard-coded multisite collection flow from the + task, including browser automation when that is how the task data is + obtained, update `app/feed.json`, and return the same unified JSON. + 7. `python final_script.py --run-once` must execute that same collection + flow without starting the server, write `app/feed.json`, print the + unified JSON, and preserve the same source-status/error semantics. + 8. The frontend must render a read-only feed UI: title, update time, source statuses, feed items, original links, metadata, visible errors, and visible empty state. No login, editing, source management, notifications, or background scheduler. + + Required `feed.json` schema: + ``` + { + "title": "string", + "generated_at": "ISO-8601 string", + "query": "string", + "sources": [ + {"name": "string", "url": "string", "status": "ok|error", "item_count": 0} + ], + "items": [ + { + "title": "string", + "url": "string", + "source": "string", + "published_at": "string|null", + "summary": "string", + "metadata": {"key": "value"} + } + ], + "errors": [ + {"source": "string", "message": "string"} + ] + } + ``` + + Schema rules: + - Every configured source must appear exactly once in `sources`. + - Failed sources must have `status: "error"`, `item_count: 0`, and a matching `errors` entry. + - Every item must preserve its original source and original link. + - `published_at` may be null but must be present. + - Empty results must render visibly and must not be silent success. + + ## Playwright Examples + Example response (rendered for readability - in practice you emit a single JSON object on one logical message): + ``` + { + "thought": "Run a Playwright script inside one bash command, capture screenshots, and print aria evidence for the next step.", + "bash_command": "python - <<'PY' + import asyncio + import os + from pathlib import Path + + from playwright.async_api import async_playwright + + WORKSPACE = Path(os.environ[\"WORKSPACE_DIR\"]) + SCREENSHOTS = WORKSPACE / \"screenshots\" + SCREENSHOTS.mkdir(parents=True, exist_ok=True) + + async def main(): + async with async_playwright() as playwright: + browser = await playwright.chromium.launch(headless=True) + context = await browser.new_context(viewport={\"width\": 1280, \"height\": 1800}) + page = await context.new_page() + + await page.goto(\"{{ start_url }}\", wait_until=\"domcontentloaded\") + await page.screenshot(path=str(SCREENSHOTS / \"explore_start_page.png\")) + print(\"URL:\", page.url) + print(\"TITLE:\", await page.title()) + print(\"ARIA:\", await page.locator(\"body\").aria_snapshot()) + await browser.close() + + asyncio.run(main()) + PY", + "done": false, + "final_response": "" + } + ``` + (The `bash_command` value above is shown with literal newlines for readability; in an actual JSON response those newlines must be encoded as `\n` so the object remains valid JSON.) + + ## Helpful Command Patterns + + - Inspect a script: + ``` + sed -n '1,260p' final_runs/run_/final_script.py + ``` + - Locate and inspect the app/feed reference template: + ``` + python -c "from pathlib import Path; import webwright; print(Path(webwright.__file__).resolve().parent / 'reference' / 'app_feed_template.py')" + sed -n '1,260p' + ``` + - Test import safety: + ``` + python - <<'PY' + import importlib.util + from pathlib import Path + path = Path("final_runs/run_/final_script.py") + spec = importlib.util.spec_from_file_location("final_script", path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + print("import ok") + PY + ``` + - Run the reusable task once without opening the app: + ``` + python final_runs/run_/final_script.py --run-once + ``` + - Start the app for endpoint smoke tests: + ``` + python final_runs/run_/final_script.py --host 127.0.0.1 --port 8765 + ``` + - Inspect run artifacts: + ``` + ls -R final_runs/run_ && cat final_runs/run_/final_script_log.txt + ``` + - Run self_reflection after final screenshots exist: + ``` + python -m webwright.tools.self_reflection \ + --config app_judge_config.json \ + --workspace-dir "{{ workspace_dir }}" \ + --output final_runs/run_/app_judge_result.json + ``` + + ## Rules + - Always avoid full-page screenshots using Playwright. Use viewport 1280x1800 and never call `page.screenshot(full_page=True)`. + - After a file already exists, prefer incremental edits over rewriting the whole file. + - The task's sources, topic, recency rules, and filters are fixed from the user's natural-language task and hard-coded into the script. + - Use the packaged `webwright/reference/app_feed_template.py` as the implementation skeleton for `final_script.py`; do not reduce it to a partial example or omit its cache-first UI/server/schema features. + - Use stable selectors, structured feeds, APIs, or page evidence discovered during task-relevant exploration. Do not guess source behavior. + - Do not count unrelated demo/source screenshots as task evidence. For the + user's actual task, source screenshots must show the sites/pages/results + that produced the feed items. + - A failed source is acceptable only when it is logged, represented in `feed.json`, and visible in the UI. + - Empty results are acceptable only when all source fetch attempts and task filters were applied and the UI shows an empty state. + - `final_script_log.txt` must record the server URL, app file generation path, `/run` start and finish, each source's status and item count or error, and the final item count. + - Final screenshots must include both source evidence and UI evidence: + task-relevant exploration/collection screenshots from the original + sources, plus local app loading state, successful feed state, and error + or empty-state evidence when applicable. + - If all live sources succeed with items, create deterministic verification evidence for error/empty rendering with a mock endpoint, controlled source-failure branch, or static frontend state. Do not fake the main feed results. + + ## Task Reflection Tool + + Do NOT hand-roll a `judge.py` that loops `image_qa`. Use the built-in + `webwright.tools.self_reflection` CLI. + + 1. Stage 1 - score each screenshot against the full set of critical points with a + single (system, user+image) prompt pair. The tool parses a `Score: 1-5` and + `Reasoning: ` from each response and retries on parse failure. + 2. Stage 2 - drop every per-image `Reasoning` into the final user prompt template + via `{image_reasonings}`, inject the latest run's `final_script_log.txt` via + `{action_history_log}`, attach EVERY screenshot, and make ONE aggregated call + that must end with `Status: success` or `Status: failure`. + + Author `app_judge_config.json` once at the workspace root and reuse it for every + self_reflection invocation. The prompts must evaluate the app/feed-specific + critical points, including import-safety, server URL, PWA files, `/run` schema, + task-relevant source/browser evidence screenshots, source statuses, visible + feed items, and error/empty states. + + ## Completion Gate + + Set `"done": true` ONLY if ALL of the following are true: + 1. `plan.md` exists with `# Feed App Contract` and a checklist of critical points. + 2. `app_judge_config.json` exists with all four prompts populated for `self_reflection`. + 3. `final_script.py --help` or equivalent was run and explains both + `--run-once` task mode and app/server mode. + 4. `final_script.py` follows the app/feed template structure and only customizes task-specific source extraction and feed presentation. + 5. An import-safety test completed without starting a server, fetching, or writing files. + 6. `python final_script.py --run-once` executed the reusable task, + printed schema-valid JSON, and wrote matching `app/feed.json`. + 7. `python final_script.py` started a local server and printed its URL. + 8. `index.html`, `manifest.webmanifest`, `feed.json`, and `/healthz` were reachable through the local server. + 9. `/run` returned schema-valid JSON and wrote matching `app/feed.json`. + 10. The frontend renders cached `feed.json` before `/run` completes when cached items exist. + 11. Final screenshots show task-relevant source/browser evidence, + loading, successful feed items, and error or empty-state evidence. + 12. `final_script_log.txt` records run-once execution, server URL, every source status, and final item count. + 13. `python -m webwright.tools.self_reflection --config app_judge_config.json --workspace-dir "{{ workspace_dir }}" --output final_runs/run_/app_judge_result.json` exited 0 and wrote `"predicted_label": 1`. + + Do NOT declare done if any required app file is missing, if source evidence + screenshots are unrelated to the user's task, if the final script discarded + the template's server/UI/schema/cache features, if `/run` is not + schema-valid, if source failures are silent, if the app UI does not render + the returned feed, or if self_reflection has not passed on the latest run. + + instance_template: | + Task: {{ task }} + {% if task_id %}Task ID: {{ task_id }} + {% endif %}{% if start_url %}Start URL: {{ start_url }} + {% endif %}Workspace root: {{ workspace_dir }} + Task metadata JSON: {{ task_metadata_path }} + Required final script path: {{ final_script_path }} + + + # Task Instructions + + You're building a local PWA feed app for a user-specified multisite information-flow task through a stateless local terminal + workspace harness. + + + This is an interactive process where you reason, execute exactly one bash command, inspect the result, and then produce your next command. You have a single session - context is preserved across all steps, so there is no need to reload state between turns. + + + ## Harness Rules + + - Work only inside `{{ workspace_dir }}`. + - Keep generated code, screenshots, logs, scratch files, notes, and app assets only in `{{ workspace_dir }}`. + - The required final artifact is `{{ final_script_path }}`. + - Create `final_runs/run_/` folders for every clean execution of the final script. Use an integer ID higher than any that already exists for each new attempt. + - Store each run's `final_script.py`, `final_script_log.txt`, `screenshots/`, and `app/` directory only inside that run folder. + - The browser mode is `{{ browser_mode }}`. Match exploration scripts to that mode. The app server itself must use the Python standard library HTTP server. + + ## App/Feed Task Rules + + - The final script is dual-use: a reusable task runner plus a local PWA app + generator/server. It is not just a page. + - The data collection is still the actual Webwright task. The local PWA is + only the final human-facing display layer. + - The app's fixed sources, topic, filters, and query are determined from the task. Do not add runtime source editing, login, notifications, or background refresh. + - Opening the local page must show loading, call `/run`, and render the latest unified feed. + - `/run` and `python final_script.py --run-once` must call the same + hard-coded collection workflow, including Playwright browser automation + when the task requires browser-visible source pages, update + `app/feed.json`, and return the same schema-valid JSON. + - Partial source failures and empty results must be visible in the JSON, UI, logs, and screenshots. + - Every feed item must preserve source name and original link. + - Source screenshots must be related to the user's task and must show the + pages/results used to collect the feed items; UI screenshots alone are + not enough. + + ## Recommended Workflow + + 1. **Planning**: Write `plan.md` with: + ``` + # Feed App Contract + | field | value | + |-------|-------| + | title | | + | query | | + | sources | | + | item shape | title, url, source, published_at, summary, metadata | + | empty state | | + | partial failure | | + + # Critical Points + - [ ] CP1: + ``` + + 2. **Read the app template**: Locate the packaged `webwright/reference/app_feed_template.py` with Python import path resolution, inspect it, and use it as the implementation skeleton for the final script. Keep its import-safe server, PWA asset generation, schema validation, cached-feed-first frontend, demo error/empty states, `/healthz`, `/run`, `/feed.json`, and logging behavior. + + 3. **Author app_judge_config.json (once)**: Write `{{ workspace_dir }}/app_judge_config.json` containing only the four prompts (`image_judge_system_prompt`, `image_judge_user_prompt`, `final_verdict_system_prompt`, `final_verdict_user_prompt`) for `webwright.tools.self_reflection`. Embed the task, feed contract, and critical points. Do not hard-code a run id or screenshot filename. + + 4. **Explore sources**: Inspect `task.json` and then solve the user's + concrete collection task. For browser-mediated tasks such as product + comparison, search/listing review, ticket/event lookup, shopping, + travel, or any site UI workflow, launch Playwright, navigate the actual + sites/pages, and capture source evidence screenshots that show the + task-relevant results. Use structured feeds/APIs only when the task asks + for them or exploration proves they are the site's reliable backing data + for the same results. + + 5. **Final script**: Write `final_script.py` in a new `final_runs/run_/` by copying the app template structure and replacing task-specific config/fetchers. It must create the `app/` files, expose `--run-once` for reusable task execution, start a local HTTP server, serve `/run`, serve `/healthz`, and log all source statuses. If browser exploration was required to solve the task, both `--run-once` and `/run` must replay the same browser-backed collection flow or an equivalent discovered site-backed request flow, and they must preserve source evidence screenshots/logs. + + 6. **Smoke test**: + - Run `python final_runs/run_/final_script.py --help` or equivalent. + - Run an import-safety test. + - Run `python final_runs/run_/final_script.py --run-once`, validate the printed schema, and confirm `app/feed.json` matches. + - Start the server, open the printed URL, and confirm static app files are reachable. + - Request `/run`, validate schema, and confirm `app/feed.json` matches. + - Confirm cached `feed.json` renders before `/run` completes when cached items exist. + - Use Playwright to capture source evidence screenshots from the actual + task sites plus local app loading, success/feed, and error/empty + screenshots under the run's `screenshots/` directory. + + 7. **Run self_reflection**: Execute `python -m webwright.tools.self_reflection --config app_judge_config.json --workspace-dir "{{ workspace_dir }}" --output final_runs/run_/app_judge_result.json`. If it exits non-zero or `predicted_label != 1`, diagnose, fix `final_script.py`, re-run in `final_runs/run_/`, and re-invoke self_reflection against the new run. + + 8. **Declare done**: Set `"done": true` only after the latest run satisfies every app/feed critical point and `app_judge_result.json` reports `"predicted_label": 1`. + + ## Final Script Instrumentation + + `final_script.py` must: + - be stored as `final_runs/run_/final_script.py` + - be import-safe + - follow the structure of the packaged `webwright/reference/app_feed_template.py` + - expose `--run-once` so the underlying task script remains reusable + - create `final_runs/run_/app/` with `index.html`, `styles.css`, `app.js`, `manifest.webmanifest`, `feed.json`, and `icon.svg` + - save final verification screenshots as `final_runs/run_/screenshots/final_execution__.png` + - create or reset `final_runs/run_/final_script_log.txt` at the start of each clean server run + - write `step action: ...` lines for app generation, server URL, `/run`, each source status, final item count, and verification-relevant endpoint checks + - when browser collection is used, write log lines for each source + navigation/extraction and save source evidence screenshots that are + visibly related to the task + + ## Completion Gate + + Set `"done": true` ONLY if ALL of the following are true: + 1. `plan.md` exists with `# Feed App Contract` and all critical points identified. + 2. `app_judge_config.json` exists with all four prompts populated for `self_reflection`. + 3. `final_script.py --help` or equivalent was run and documents `--run-once`. + 4. `final_script.py` follows the app/feed template shape. + 5. Import-safety smoke test passed. + 6. `python final_script.py --run-once` executed the reusable task, returned schema-valid JSON, and wrote matching `app/feed.json`. + 7. `python final_script.py` started a local server and printed a URL. + 8. `index.html`, `manifest.webmanifest`, `feed.json`, and `/healthz` were reachable. + 9. `/run` returned schema-valid JSON and wrote matching `app/feed.json`. + 10. Cached `feed.json` renders before `/run` completes when cached items exist. + 11. `final_script_log.txt` records run-once execution, server URL, every source status, final item count, and task-relevant source collection evidence. + 12. Final screenshots prove task source evidence, loading, successful feed items, and error or empty-state evidence. + 13. `python -m webwright.tools.self_reflection --config app_judge_config.json --workspace-dir "{{ workspace_dir }}" --output final_runs/run_/app_judge_result.json` exited 0 and wrote `"predicted_label": 1`. + + Do NOT declare done if the app server was not run, if `/run` was not + exercised, if app files are missing, if screenshots are unrelated to the + user's task, if the template's cache-first UI/server/schema shape was + discarded, if source failures are silent, or if self_reflection has not + passed on the latest run. + diff --git a/src/webwright/reference/app_feed_template.py b/src/webwright/reference/app_feed_template.py new file mode 100644 index 0000000..13904c1 --- /dev/null +++ b/src/webwright/reference/app_feed_template.py @@ -0,0 +1,778 @@ +"""Implementation skeleton for YAML-driven app/feed runs. + +Copy this file into `final_runs/run_/final_script.py`, then replace only the +task-specific configuration, `fetch_source_items`, optional sorting, and item +metadata. The full behavioral contract lives in `app_feed.yaml`. +""" + +from __future__ import annotations + +import argparse +import html +import json +import re +import sys +import time +import urllib.error +import urllib.parse +import urllib.request +from dataclasses import dataclass +from datetime import datetime, timezone +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Any + + +# --------------------------------------------------------------------------- +# Task-specific configuration. Replace these values for the concrete task. +# --------------------------------------------------------------------------- + +APP_TITLE = "Example Feed" +APP_SHORT_NAME = "Feed" +APP_QUERY = "Latest matching information items from configured public sources" +ITEM_LIMIT = 40 +DEFAULT_TIMEOUT_SECONDS = 30 + + +@dataclass(frozen=True) +class SourceConfig: + name: str + url: str + kind: str + note: str = "" + + +SOURCES: tuple[SourceConfig, ...] = ( + SourceConfig( + name="Example Source", + url="https://example.com/", + kind="html", + note="Replace this source list with the user's fixed sources.", + ), +) + + +# --------------------------------------------------------------------------- +# Paths, timestamps, logging, and schema helpers. +# --------------------------------------------------------------------------- + +RUN_DIR = Path(__file__).resolve().parent +APP_DIR = RUN_DIR / "app" +SCREENSHOTS_DIR = RUN_DIR / "screenshots" +LOG_PATH = RUN_DIR / "final_script_log.txt" + + +def utc_now() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat() + + +def reset_log() -> None: + RUN_DIR.mkdir(parents=True, exist_ok=True) + LOG_PATH.write_text("", encoding="utf-8") + + +def log(step: int, message: str) -> None: + with LOG_PATH.open("a", encoding="utf-8") as handle: + handle.write(f"step {step} action: {message}\n") + + +def source_status_rows() -> list[dict[str, Any]]: + return [ + {"name": source.name, "url": source.url, "status": "ok", "item_count": 0} + for source in SOURCES + ] + + +def empty_feed(*, generated_at: str | None = None) -> dict[str, Any]: + return { + "title": APP_TITLE, + "generated_at": generated_at or utc_now(), + "query": APP_QUERY, + "sources": source_status_rows(), + "items": [], + "errors": [], + } + + +def write_json(path: Path, payload: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8") + + +def normalize_metadata(metadata: dict[str, Any] | None) -> dict[str, str]: + normalized: dict[str, str] = {} + for key, value in (metadata or {}).items(): + normalized[str(key)] = "" if value is None else str(value) + return normalized + + +def make_item( + *, + title: str, + url: str, + source: str, + summary: str, + published_at: str | None = None, + metadata: dict[str, Any] | None = None, +) -> dict[str, Any]: + return { + "title": str(title).strip() or "Untitled item", + "url": str(url).strip(), + "source": str(source).strip(), + "published_at": published_at, + "summary": str(summary).strip(), + "metadata": normalize_metadata(metadata), + } + + +def validate_feed(feed: dict[str, Any]) -> None: + required_top = {"title", "generated_at", "query", "sources", "items", "errors"} + missing_top = required_top - set(feed) + if missing_top: + raise ValueError(f"feed missing keys: {sorted(missing_top)}") + if not isinstance(feed["sources"], list): + raise ValueError("feed.sources must be a list") + if not isinstance(feed["items"], list): + raise ValueError("feed.items must be a list") + if not isinstance(feed["errors"], list): + raise ValueError("feed.errors must be a list") + + source_required = {"name", "url", "status", "item_count"} + for index, source in enumerate(feed["sources"]): + missing = source_required - set(source) + if missing: + raise ValueError(f"source {index} missing keys: {sorted(missing)}") + if source["status"] not in ("ok", "error"): + raise ValueError(f"source {source['name']} has invalid status {source['status']!r}") + + item_required = {"title", "url", "source", "published_at", "summary", "metadata"} + for index, item in enumerate(feed["items"]): + missing = item_required - set(item) + if missing: + raise ValueError(f"item {index} missing keys: {sorted(missing)}") + if not isinstance(item["metadata"], dict): + raise ValueError(f"item {index} metadata must be an object") + + error_required = {"source", "message"} + for index, error in enumerate(feed["errors"]): + missing = error_required - set(error) + if missing: + raise ValueError(f"error {index} missing keys: {sorted(missing)}") + + +# --------------------------------------------------------------------------- +# Network and extraction helpers. Replace fetch_source_items for each task. +# Browser-backed tasks may add lazy Playwright helpers here. Do not import or +# launch a browser at module import time; keep all browser work inside /run. +# --------------------------------------------------------------------------- + + +def fetch_text(url: str, *, timeout: int = DEFAULT_TIMEOUT_SECONDS) -> str: + request = urllib.request.Request( + url, + headers={ + "User-Agent": "Mozilla/5.0 Webwright app/feed mode", + "Accept": "text/html,application/json,text/plain,*/*", + }, + ) + with urllib.request.urlopen(request, timeout=timeout) as response: + charset = response.headers.get_content_charset() or "utf-8" + return response.read().decode(charset, errors="replace") + + +def clean_html(raw_html: str) -> str: + raw_html = re.sub(r"", " ", raw_html, flags=re.I | re.S) + raw_html = re.sub(r"", " ", raw_html, flags=re.I | re.S) + text = re.sub(r"<[^>]+>", " ", raw_html) + return re.sub(r"\s+", " ", html.unescape(text)).strip() + + +def absolutize_url(base_url: str, maybe_url: str) -> str: + return urllib.parse.urljoin(base_url, maybe_url) + + +def fetch_source_items(source: SourceConfig) -> list[dict[str, Any]]: + """Fetch one configured source and return normalized feed items. + + Replace this placeholder with task-specific logic. Keep the return value as + a list of `make_item(...)` dictionaries and let exceptions bubble up so the + caller can record the source as an error without failing the entire app. + """ + + text = clean_html(fetch_text(source.url)) + title = text[:90] or source.name + return [ + make_item( + title=f"{source.name}: example item", + url=source.url, + source=source.name, + summary=title, + metadata={"source_kind": source.kind, "note": source.note}, + ) + ] + + +def sort_items(items: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Sort items for display. + + Replace or extend this if the task requires latest-first, cheapest-first, + highest-rated, or another explicit ordering. Do not invent ranking when the + task requires a site-provided sort/filter. + """ + + return items + + +def collect_feed(*, demo: str | None = None) -> dict[str, Any]: + generated_at = utc_now() + feed = empty_feed(generated_at=generated_at) + + if demo == "empty": + for source in feed["sources"]: + source["item_count"] = 0 + validate_feed(feed) + write_json(APP_DIR / "feed.json", feed) + log(60, "demo empty response generated with 0 items") + return feed + + if demo == "error": + for source in feed["sources"]: + source["status"] = "error" + source["item_count"] = 0 + feed["errors"].append( + {"source": "Demo", "message": "Controlled error-state rendering check."} + ) + validate_feed(feed) + write_json(APP_DIR / "feed.json", feed) + log(61, "demo error response generated with visible errors") + return feed + + log(10, "/run started live multisite feed fetch") + all_items: list[dict[str, Any]] = [] + + for source, status_row in zip(SOURCES, feed["sources"], strict=True): + started = time.time() + try: + items = fetch_source_items(source) + status_row["status"] = "ok" + status_row["item_count"] = len(items) + all_items.extend(items) + log( + 20, + f"source={source.name} url={source.url} status=ok " + f"item_count={len(items)} elapsed={time.time() - started:.1f}s", + ) + except Exception as exc: + status_row["status"] = "error" + status_row["item_count"] = 0 + feed["errors"].append({"source": source.name, "message": str(exc)}) + log( + 21, + f"source={source.name} url={source.url} status=error message={exc}", + ) + + feed["items"] = sort_items(all_items)[:ITEM_LIMIT] + validate_feed(feed) + write_json(APP_DIR / "feed.json", feed) + log( + 30, + f"/run finished source_count={len(feed['sources'])} " + f"final_item_count={len(feed['items'])} error_count={len(feed['errors'])}", + ) + return feed + + +# --------------------------------------------------------------------------- +# Static PWA assets. +# --------------------------------------------------------------------------- + + +INDEX_HTML = """ + + + + + __APP_TITLE__ + + + + + +
+
+
+

Local Webwright PWA

+

__APP_TITLE__

+
+
+ +
Waiting for latest run
+
+
+ +
+
+
+
Loading cached feed
+
Preparing local app...
+
+
+ +
+

Sources

+
+
+ + + +
+

Feed

+
+ +
+
+ + + +""" + + +STYLES_CSS = """ +:root { + color-scheme: light; + --ink: #17202a; + --muted: #5b6472; + --line: #d9dee7; + --panel: #ffffff; + --page: #f5f7fb; + --accent: #0f766e; + --warn: #b42318; + --ok-bg: #e8f5ef; + --err-bg: #fff1f0; +} +* { box-sizing: border-box; } +body { + margin: 0; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + color: var(--ink); + background: var(--page); +} +a { color: #0b5cad; text-decoration: none; } +a:hover { text-decoration: underline; } +button { + border: 1px solid var(--line); + background: var(--panel); + border-radius: 8px; + padding: 8px 12px; + color: var(--ink); + font: inherit; + cursor: pointer; +} +button:hover { border-color: var(--accent); color: var(--accent); } +.shell { width: min(1180px, calc(100% - 32px)); margin: 0 auto; padding: 28px 0 48px; } +.topbar { display: flex; align-items: end; justify-content: space-between; gap: 20px; border-bottom: 1px solid var(--line); padding-bottom: 18px; } +.eyebrow { margin: 0 0 6px; color: var(--accent); font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: .08em; } +h1 { margin: 0; font-size: clamp(32px, 5vw, 56px); line-height: 1; letter-spacing: 0; } +h2 { margin: 28px 0 12px; font-size: 18px; letter-spacing: 0; } +.toolbar { display: flex; gap: 12px; align-items: center; justify-content: flex-end; flex-wrap: wrap; } +.stamp { color: var(--muted); font-size: 14px; text-align: right; } +.status-band { margin-top: 22px; display: flex; gap: 14px; align-items: center; padding: 16px; background: var(--panel); border: 1px solid var(--line); border-radius: 8px; } +.loader { width: 18px; height: 18px; border-radius: 50%; border: 3px solid #c4ccd8; border-top-color: var(--accent); animation: spin 1s linear infinite; flex: 0 0 auto; } +.loader.done { animation: none; border-color: var(--accent); background: var(--accent); } +@keyframes spin { to { transform: rotate(360deg); } } +.status-title { font-weight: 700; } +.status-copy { color: var(--muted); margin-top: 2px; } +.sources { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; } +.source { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 14px; min-height: 112px; } +.source strong { display: block; margin-bottom: 8px; } +.pill { display: inline-flex; align-items: center; gap: 6px; border-radius: 999px; padding: 4px 8px; font-size: 12px; font-weight: 700; } +.pill.ok { background: var(--ok-bg); color: #116149; } +.pill.error { background: var(--err-bg); color: var(--warn); } +.count { margin-top: 10px; color: var(--muted); font-size: 13px; overflow-wrap: anywhere; } +.errors-wrap { background: var(--err-bg); border: 1px solid #ffc9c4; border-radius: 8px; padding: 0 16px 14px; } +.error-item { color: var(--warn); padding: 8px 0; border-top: 1px solid #ffd7d3; } +.error-item:first-child { border-top: 0; } +.feed { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; } +.card { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 16px; min-height: 230px; display: flex; flex-direction: column; gap: 10px; } +.card h3 { margin: 0; font-size: 17px; line-height: 1.25; letter-spacing: 0; overflow-wrap: anywhere; } +.summary { color: var(--muted); line-height: 1.45; margin: 0; } +.meta { display: flex; flex-wrap: wrap; gap: 6px; margin-top: auto; } +.tag { background: #eef2f7; border: 1px solid #dce3ed; border-radius: 6px; padding: 5px 7px; font-size: 12px; color: #384253; overflow-wrap: anywhere; } +.card-footer { display: flex; justify-content: space-between; gap: 12px; align-items: center; color: var(--muted); font-size: 13px; } +.empty { background: var(--panel); border: 1px dashed #aab4c2; border-radius: 8px; padding: 24px; color: var(--muted); text-align: center; } +@media (max-width: 900px) { + .sources, .feed { grid-template-columns: repeat(2, minmax(0, 1fr)); } +} +@media (max-width: 620px) { + .shell { width: min(100% - 20px, 1180px); padding-top: 18px; } + .topbar { align-items: start; flex-direction: column; } + .toolbar { align-items: start; justify-content: flex-start; } + .stamp { text-align: left; } + .sources, .feed { grid-template-columns: 1fr; } +} +""" + + +APP_JS = """ +const title = document.getElementById('title'); +const updated = document.getElementById('updated'); +const statusTitle = document.getElementById('statusTitle'); +const query = document.getElementById('query'); +const loader = document.getElementById('loader'); +const sourcesEl = document.getElementById('sources'); +const feedEl = document.getElementById('feed'); +const errorsWrap = document.getElementById('errorsWrap'); +const errorsEl = document.getElementById('errors'); +const emptyEl = document.getElementById('empty'); +const refresh = document.getElementById('refresh'); + +function text(value) { + return value === null || value === undefined || value === '' ? 'Unavailable' : String(value); +} + +function escapeHtml(value) { + return text(value) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +function escapeAttr(value) { + return escapeHtml(value); +} + +function setLoading(isLoading) { + loader.classList.toggle('done', !isLoading); + refresh.disabled = isLoading; +} + +function render(feed, {cached = false} = {}) { + title.textContent = feed.title || '__APP_TITLE__'; + updated.textContent = feed.generated_at ? `Updated ${new Date(feed.generated_at).toLocaleString()}` : 'Updated just now'; + statusTitle.textContent = `${cached ? 'Showing cached' : 'Collected'} ${(feed.items || []).length} items`; + query.textContent = feed.query || ''; + + sourcesEl.innerHTML = ''; + for (const source of feed.sources || []) { + const el = document.createElement('article'); + el.className = 'source'; + const status = source.status === 'ok' ? 'ok' : 'error'; + el.innerHTML = ` + ${escapeHtml(source.name)} + ${status.toUpperCase()} +
${escapeHtml(source.item_count || 0)} items
+ + `; + sourcesEl.appendChild(el); + } + + errorsEl.innerHTML = ''; + const errors = feed.errors || []; + errorsWrap.hidden = errors.length === 0; + for (const error of errors) { + const el = document.createElement('div'); + el.className = 'error-item'; + el.textContent = `${text(error.source)}: ${text(error.message)}`; + errorsEl.appendChild(el); + } + + feedEl.innerHTML = ''; + const items = feed.items || []; + emptyEl.hidden = items.length !== 0; + for (const item of items) { + const metadata = item.metadata || {}; + const card = document.createElement('article'); + card.className = 'card'; + const tags = Object.entries(metadata) + .slice(0, 8) + .map(([key, value]) => `${escapeHtml(key)}: ${escapeHtml(value)}`) + .join(''); + card.innerHTML = ` + +

${escapeHtml(item.title)}

+

${escapeHtml(item.summary)}

+
${tags}
+ + `; + feedEl.appendChild(card); + } +} + +async function fetchJson(endpoint) { + const response = await fetch(endpoint, {cache: 'no-store'}); + if (!response.ok) { + throw new Error(`${endpoint} returned ${response.status}`); + } + return response.json(); +} + +async function renderCachedFeed() { + try { + const cached = await fetchJson('/feed.json'); + if ((cached.items && cached.items.length) || (cached.sources && cached.sources.length) || (cached.errors && cached.errors.length)) { + render(cached, {cached: true}); + query.textContent = `${cached.query || ''} - refreshing in background`; + return true; + } + } catch (error) { + console.warn('No cached feed available', error); + } + return false; +} + +async function run({useCache = true} = {}) { + const params = new URLSearchParams(window.location.search); + const demo = params.get('demo'); + const endpoint = demo ? `/run?demo=${encodeURIComponent(demo)}` : '/run'; + const hasCachedFeed = demo || !useCache ? false : await renderCachedFeed(); + setLoading(true); + try { + const feed = await fetchJson(endpoint); + render(feed); + setLoading(false); + } catch (error) { + setLoading(false); + if (hasCachedFeed && feedEl.children.length) { + statusTitle.textContent = 'Showing cached feed; refresh failed'; + query.textContent = String(error); + } else { + statusTitle.textContent = 'Run failed'; + query.textContent = String(error); + render({ + title: '__APP_TITLE__', + generated_at: new Date().toISOString(), + query: 'Fetch failed', + sources: [], + items: [], + errors: [{source: 'local app', message: String(error)}] + }); + } + } +} + +refresh.addEventListener('click', () => run({useCache: false})); +run(); +""" + + +ICON_SVG = """ + + + + + +""" + + +def render_asset(template: str) -> str: + return template.replace("__APP_TITLE__", APP_TITLE) + + +def web_manifest() -> dict[str, Any]: + return { + "name": APP_TITLE, + "short_name": APP_SHORT_NAME, + "start_url": "/", + "display": "standalone", + "background_color": "#f5f7fb", + "theme_color": "#0f766e", + "icons": [{"src": "/icon.svg", "sizes": "any", "type": "image/svg+xml"}], + } + + +def write_app_files() -> None: + APP_DIR.mkdir(parents=True, exist_ok=True) + SCREENSHOTS_DIR.mkdir(parents=True, exist_ok=True) + (APP_DIR / "index.html").write_text(render_asset(INDEX_HTML), encoding="utf-8") + (APP_DIR / "styles.css").write_text(STYLES_CSS, encoding="utf-8") + (APP_DIR / "app.js").write_text(render_asset(APP_JS), encoding="utf-8") + write_json(APP_DIR / "manifest.webmanifest", web_manifest()) + (APP_DIR / "icon.svg").write_text(ICON_SVG, encoding="utf-8") + if not (APP_DIR / "feed.json").exists(): + write_json(APP_DIR / "feed.json", empty_feed()) + log(2, f"app files generated at {APP_DIR}") + + +# --------------------------------------------------------------------------- +# Local HTTP server. +# --------------------------------------------------------------------------- + + +class FeedHandler(BaseHTTPRequestHandler): + server_version = "WebwrightAppFeed/1.0" + + def log_message(self, fmt: str, *args: Any) -> None: + log(90, "http " + fmt % args) + + def send_json(self, payload: dict[str, Any], status: int = 200, *, body: bool = True) -> None: + encoded = json.dumps(payload, indent=2, ensure_ascii=False).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Cache-Control", "no-store") + self.send_header("Content-Length", str(len(encoded))) + self.end_headers() + if body: + self.wfile.write(encoded) + + def send_static(self, target: Path, *, body: bool = True) -> None: + content_type = { + ".html": "text/html; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".js": "application/javascript; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".webmanifest": "application/manifest+json; charset=utf-8", + ".svg": "image/svg+xml; charset=utf-8", + }.get(target.suffix, "application/octet-stream") + encoded = target.read_bytes() + self.send_response(HTTPStatus.OK) + self.send_header("Content-Type", content_type) + self.send_header("Cache-Control", "no-store") + self.send_header("Content-Length", str(len(encoded))) + self.end_headers() + if body: + self.wfile.write(encoded) + + def resolve_static_path(self, request_path: str) -> Path | None: + rel = "index.html" if request_path in ("", "/") else request_path.lstrip("/") + target = (APP_DIR / rel).resolve() + app_root = APP_DIR.resolve() + if target != app_root and app_root not in target.parents: + return None + return target + + def handle_request(self, *, body: bool) -> None: + parsed = urllib.parse.urlparse(self.path) + if parsed.path == "/healthz": + self.send_json({"ok": True, "title": APP_TITLE, "generated_at": utc_now()}, body=body) + return + if parsed.path == "/run": + query = urllib.parse.parse_qs(parsed.query) + demo = (query.get("demo") or [None])[0] + try: + self.send_json(collect_feed(demo=demo), body=body) + except Exception as exc: + log(31, f"/run failed message={exc}") + self.send_json( + { + "title": APP_TITLE, + "generated_at": utc_now(), + "query": APP_QUERY, + "sources": source_status_rows(), + "items": [], + "errors": [{"source": "server", "message": str(exc)}], + }, + status=500, + body=body, + ) + return + + target = self.resolve_static_path(parsed.path) + if target is None: + self.send_error(HTTPStatus.FORBIDDEN) + return + if not target.exists() or not target.is_file(): + self.send_error(HTTPStatus.NOT_FOUND) + return + self.send_static(target, body=body) + + def do_GET(self) -> None: + self.handle_request(body=True) + + def do_HEAD(self) -> None: + self.handle_request(body=False) + + +def serve(host: str, port: int) -> None: + reset_log() + write_app_files() + httpd = ThreadingHTTPServer((host, port), FeedHandler) + actual_port = httpd.server_address[1] + url = f"http://{host}:{actual_port}/" + log(1, f"server_url={url}") + print(url, flush=True) + try: + httpd.serve_forever() + except KeyboardInterrupt: + log(99, "server stopped by KeyboardInterrupt") + finally: + httpd.server_close() + + +def run_once(*, demo: str | None = None, output_json: Path | None = None) -> dict[str, Any]: + """Execute the reusable collection task once without starting the app server.""" + + reset_log() + write_app_files() + feed = collect_feed(demo=demo) + if output_json is not None: + write_json(output_json, feed) + log(40, f"run-once output_json={output_json}") + log( + 41, + f"run-once finished source_count={len(feed['sources'])} " + f"item_count={len(feed['items'])} error_count={len(feed['errors'])}", + ) + return feed + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description=( + "Run the Webwright collection task once or start the local " + "app/feed PWA. --run-once writes app/feed.json and prints JSON; " + "the default starts a localhost UI whose /run endpoint executes " + "the same collection task." + ) + ) + parser.add_argument("--host", default="127.0.0.1", help="Local host/IP to bind. Default: 127.0.0.1") + parser.add_argument("--port", type=int, default=0, help="Local port to bind. Default: 0 chooses a free port") + parser.add_argument( + "--run-once", + action="store_true", + help="Execute the collection task once, write app/feed.json, print JSON, and exit", + ) + parser.add_argument( + "--output-json", + type=Path, + help="Optional extra JSON output path for --run-once", + ) + parser.add_argument( + "--demo", + choices=("empty", "error"), + help="Controlled demo state for verification; use only with --run-once or /run?demo=...", + ) + parser.add_argument( + "--write-app-only", + action="store_true", + help="Generate app files and feed.json, then exit without starting the server", + ) + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + if args.demo and not args.run_once: + raise SystemExit("--demo is only supported with --run-once; for the app use /run?demo=empty or /run?demo=error") + if args.output_json and not args.run_once: + raise SystemExit("--output-json is only supported with --run-once") + if args.write_app_only: + reset_log() + write_app_files() + print(APP_DIR) + return 0 + if args.run_once: + feed = run_once(demo=args.demo, output_json=args.output_json) + print(json.dumps(feed, indent=2, ensure_ascii=False)) + return 0 + serve(args.host, args.port) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 91b53f7caa2caddb329c5f96bf475502b5620480 Mon Sep 17 00:00:00 2001 From: xlrrrr Date: Tue, 12 May 2026 23:02:46 +0800 Subject: [PATCH 2/2] feat: add app feed support --- src/webwright/agents/default.py | 53 +++++++++++++++ src/webwright/config/app_feed.yaml | 66 ++++++++++++------- src/webwright/config/base.yaml | 1 + src/webwright/environments/local_workspace.py | 37 +++++++++-- src/webwright/models/base.py | 5 ++ 5 files changed, 134 insertions(+), 28 deletions(-) diff --git a/src/webwright/agents/default.py b/src/webwright/agents/default.py index 4fb77f1..cf95716 100644 --- a/src/webwright/agents/default.py +++ b/src/webwright/agents/default.py @@ -39,6 +39,9 @@ class AgentConfig(BaseModel): require_self_reflection_success: bool = False summary_every_n_steps: int = 0 summary_user_prompt: str = DEFAULT_SUMMARY_USER_PROMPT + compact_observation_history: bool = False + recent_observation_limit: int = 4 + compacted_observation_chars: int = 1200 output_path: Path | None = None @@ -305,6 +308,55 @@ def _compact_history(self) -> None: ) self.messages = [system_message, summary_message] + def _observation_summary_for_context(self, observation: dict[str, Any]) -> str: + lines = [ + "Observation compacted. Full command and output remain on disk.", + f"Status: {'ok' if observation.get('success') else 'error'}", + f"Return code: {observation.get('returncode', '')}", + ] + for label, key in ( + ("Command file", "command_path"), + ("Step log", "log_path"), + ("Final script", "final_script_path"), + ("Latest screenshot", "screenshot_path"), + ): + value = observation.get(key) + if value: + lines.append(f"{label}: {value}") + exception = str(observation.get("exception") or "").strip() + if exception: + lines.append(f"Exception: {exception[: self.config.compacted_observation_chars]}") + files = observation.get("workspace_files") + if isinstance(files, list) and files: + lines.append("Recent files: " + ", ".join(str(item) for item in files[:10])) + text = "\n".join(lines) + if len(text) <= self.config.compacted_observation_chars: + return text + omitted = len(text) - self.config.compacted_observation_chars + return f"{text[: self.config.compacted_observation_chars]}\n... [{omitted} characters omitted]" + + def _compact_old_observations(self) -> None: + if not self.config.compact_observation_history: + return + observation_indices = [ + index + for index, message in enumerate(self.messages) + if message.get("role") == "user" and isinstance(message.get("extra", {}).get("observation"), dict) + ] + keep = max(self.config.recent_observation_limit, 0) + compact_indices = observation_indices if keep == 0 else observation_indices[:-keep] + for index in compact_indices: + message = self.messages[index] + extra = dict(message.get("extra", {})) + if extra.get("observation_compacted"): + continue + observation = extra.get("observation") + if not isinstance(observation, dict): + continue + extra["observation_compacted"] = True + message["content"] = self._observation_summary_for_context(observation) + message["extra"] = extra + def run(self, task: str = "", **kwargs) -> dict[str, Any]: self.extra_template_vars |= {"task": task, **kwargs} self.messages = [] @@ -359,6 +411,7 @@ def query(self) -> dict[str, Any]: extra={"exit_status": "LimitsExceeded", "submission": ""}, ) ) + self._compact_old_observations() message = self.model.query(self.messages) self.n_calls += 1 self.add_messages(message) diff --git a/src/webwright/config/app_feed.yaml b/src/webwright/config/app_feed.yaml index 052db5f..b02763a 100644 --- a/src/webwright/config/app_feed.yaml +++ b/src/webwright/config/app_feed.yaml @@ -13,7 +13,26 @@ # --task-id seattle_transit_feed \ # -o outputs/default +model: + # App/feed tasks usually need to author and verify a full PWA script. The + # base default is intentionally smaller for generic tasks, but it can truncate + # app/feed final_script.py authoring. + max_output_tokens: 12000 + request_timeout_seconds: 300 + observation_truncation_chars: 8000 + +environment: + command_timeout_seconds: 360 + output_truncation_chars: 8000 + command_preview_chars: 1600 + final_script_preview_chars: 2000 + recent_files_limit: 25 + agent: + compact_observation_history: true + recent_observation_limit: 4 + compacted_observation_chars: 1200 + summary_every_n_steps: 8 system_template: | You are a web agent operating through a local terminal + workspace harness. @@ -28,7 +47,8 @@ agent: Emit exactly ONE JSON object per turn. Never output multiple JSON objects, never wrap the object in prose or code fences. Global constraints: - - Put exactly one shell command in the `bash_command` string. Never emit raw Python or shell outside that field. Use heredocs (`python - <<'PY' ... PY`) to run Python inline when needed. + - Put exactly one shell command in the `bash_command` string. Never emit raw Python or shell outside that field. Use heredocs (`"$WEBWRIGHT_PYTHON" - <<'PY' ... PY`) to run Python inline when needed. + - The harness sets `WEBWRIGHT_PYTHON` to the Python interpreter that launched Webwright. Use `"$WEBWRIGHT_PYTHON"` for every Python command you execute, including final_script.py, import checks, helper one-liners, and self_reflection. Do not assume bare `python` points at a compatible environment. - Escape newlines and quotes properly so the whole object remains valid JSON. - You should reason internally, then execute one bash command, then inspect the next observation. - There is NO persistent browser state. Every Playwright exploration run must create a fresh browser session, navigate from scratch, and reconstruct state via code. @@ -51,10 +71,10 @@ agent: In this variant, `final_script.py` is NOT a one-shot CLI report and NOT a UI-only server. It must be a dual-use Python script for a multisite information feed task: - - task mode: `python final_script.py --run-once` executes the reusable + - task mode: `"$WEBWRIGHT_PYTHON" final_script.py --run-once` executes the reusable Webwright collection task once, writes `app/feed.json`, and prints the unified JSON result; - - app mode: `python final_script.py` starts the local PWA server, and the + - app mode: `"$WEBWRIGHT_PYTHON" final_script.py` starts the local PWA server, and the browser page calls `/run`, which executes the same collection function. The app is the presentation layer; the underlying data collection must still be a normal Webwright task with task-relevant browser/source @@ -77,7 +97,7 @@ agent: MANDATORY reference template: - Before writing `final_script.py`, locate and inspect the packaged `webwright/reference/app_feed_template.py` file. From the workspace, use - `python -c "from pathlib import Path; import webwright; print(Path(webwright.__file__).resolve().parent / 'reference' / 'app_feed_template.py')"` + `"$WEBWRIGHT_PYTHON" -c "from pathlib import Path; import webwright; print(Path(webwright.__file__).resolve().parent / 'reference' / 'app_feed_template.py')"` to find the absolute template path. - Copy that template's structure into `final_runs/run_/final_script.py`. - Replace only the task-specific configuration (`APP_TITLE`, `APP_QUERY`, `SOURCES`, limits), `fetch_source_items(source)`, `sort_items(items)`, and item metadata/summary logic. @@ -129,15 +149,15 @@ agent: Required behavior: 1. Importing `final_script.py` must start no server, perform no fetch, and write no files. - 2. `python final_script.py --help` or an equivalent help path must explain + 2. `"$WEBWRIGHT_PYTHON" final_script.py --help` or an equivalent help path must explain both how to run the task once and how to start the app. - 3. `python final_script.py` must create or refresh the `app/` directory, reset `final_script_log.txt`, start a Python standard library HTTP server on localhost, and print a URL like `http://127.0.0.1:/`. + 3. `"$WEBWRIGHT_PYTHON" final_script.py` must create or refresh the `app/` directory, reset `final_script_log.txt`, start a Python standard library HTTP server on localhost, and print a URL like `http://127.0.0.1:/`. 4. Use the Python standard library HTTP server stack (`http.server`, `socketserver`, or equivalent) for the local server. Do not use Node, Electron, Tauri, or extra dependencies. 5. `index.html` must link `manifest.webmanifest` and `icon.svg`, load `app.js`, show a visible loading state on page load, render cached `/feed.json` immediately when available, and call `/run` in the background. 6. `/run` must execute the hard-coded multisite collection flow from the task, including browser automation when that is how the task data is obtained, update `app/feed.json`, and return the same unified JSON. - 7. `python final_script.py --run-once` must execute that same collection + 7. `"$WEBWRIGHT_PYTHON" final_script.py --run-once` must execute that same collection flow without starting the server, write `app/feed.json`, print the unified JSON, and preserve the same source-status/error semantics. 8. The frontend must render a read-only feed UI: title, update time, source statuses, feed items, original links, metadata, visible errors, and visible empty state. No login, editing, source management, notifications, or background scheduler. @@ -179,7 +199,7 @@ agent: ``` { "thought": "Run a Playwright script inside one bash command, capture screenshots, and print aria evidence for the next step.", - "bash_command": "python - <<'PY' + "bash_command": "\"$WEBWRIGHT_PYTHON\" - <<'PY' import asyncio import os from pathlib import Path @@ -219,12 +239,12 @@ agent: ``` - Locate and inspect the app/feed reference template: ``` - python -c "from pathlib import Path; import webwright; print(Path(webwright.__file__).resolve().parent / 'reference' / 'app_feed_template.py')" + "$WEBWRIGHT_PYTHON" -c "from pathlib import Path; import webwright; print(Path(webwright.__file__).resolve().parent / 'reference' / 'app_feed_template.py')" sed -n '1,260p' ``` - Test import safety: ``` - python - <<'PY' + "$WEBWRIGHT_PYTHON" - <<'PY' import importlib.util from pathlib import Path path = Path("final_runs/run_/final_script.py") @@ -236,11 +256,11 @@ agent: ``` - Run the reusable task once without opening the app: ``` - python final_runs/run_/final_script.py --run-once + "$WEBWRIGHT_PYTHON" final_runs/run_/final_script.py --run-once ``` - Start the app for endpoint smoke tests: ``` - python final_runs/run_/final_script.py --host 127.0.0.1 --port 8765 + "$WEBWRIGHT_PYTHON" final_runs/run_/final_script.py --host 127.0.0.1 --port 0 ``` - Inspect run artifacts: ``` @@ -248,7 +268,7 @@ agent: ``` - Run self_reflection after final screenshots exist: ``` - python -m webwright.tools.self_reflection \ + "$WEBWRIGHT_PYTHON" -m webwright.tools.self_reflection \ --config app_judge_config.json \ --workspace-dir "{{ workspace_dir }}" \ --output final_runs/run_/app_judge_result.json @@ -300,16 +320,16 @@ agent: `--run-once` task mode and app/server mode. 4. `final_script.py` follows the app/feed template structure and only customizes task-specific source extraction and feed presentation. 5. An import-safety test completed without starting a server, fetching, or writing files. - 6. `python final_script.py --run-once` executed the reusable task, + 6. `"$WEBWRIGHT_PYTHON" final_script.py --run-once` executed the reusable task, printed schema-valid JSON, and wrote matching `app/feed.json`. - 7. `python final_script.py` started a local server and printed its URL. + 7. `"$WEBWRIGHT_PYTHON" final_script.py` started a local server and printed its URL. 8. `index.html`, `manifest.webmanifest`, `feed.json`, and `/healthz` were reachable through the local server. 9. `/run` returned schema-valid JSON and wrote matching `app/feed.json`. 10. The frontend renders cached `feed.json` before `/run` completes when cached items exist. 11. Final screenshots show task-relevant source/browser evidence, loading, successful feed items, and error or empty-state evidence. 12. `final_script_log.txt` records run-once execution, server URL, every source status, and final item count. - 13. `python -m webwright.tools.self_reflection --config app_judge_config.json --workspace-dir "{{ workspace_dir }}" --output final_runs/run_/app_judge_result.json` exited 0 and wrote `"predicted_label": 1`. + 13. `"$WEBWRIGHT_PYTHON" -m webwright.tools.self_reflection --config app_judge_config.json --workspace-dir "{{ workspace_dir }}" --output final_runs/run_/app_judge_result.json` exited 0 and wrote `"predicted_label": 1`. Do NOT declare done if any required app file is missing, if source evidence screenshots are unrelated to the user's task, if the final script discarded @@ -351,7 +371,7 @@ agent: only the final human-facing display layer. - The app's fixed sources, topic, filters, and query are determined from the task. Do not add runtime source editing, login, notifications, or background refresh. - Opening the local page must show loading, call `/run`, and render the latest unified feed. - - `/run` and `python final_script.py --run-once` must call the same + - `/run` and `"$WEBWRIGHT_PYTHON" final_script.py --run-once` must call the same hard-coded collection workflow, including Playwright browser automation when the task requires browser-visible source pages, update `app/feed.json`, and return the same schema-valid JSON. @@ -395,9 +415,9 @@ agent: 5. **Final script**: Write `final_script.py` in a new `final_runs/run_/` by copying the app template structure and replacing task-specific config/fetchers. It must create the `app/` files, expose `--run-once` for reusable task execution, start a local HTTP server, serve `/run`, serve `/healthz`, and log all source statuses. If browser exploration was required to solve the task, both `--run-once` and `/run` must replay the same browser-backed collection flow or an equivalent discovered site-backed request flow, and they must preserve source evidence screenshots/logs. 6. **Smoke test**: - - Run `python final_runs/run_/final_script.py --help` or equivalent. + - Run `"$WEBWRIGHT_PYTHON" final_runs/run_/final_script.py --help` or equivalent. - Run an import-safety test. - - Run `python final_runs/run_/final_script.py --run-once`, validate the printed schema, and confirm `app/feed.json` matches. + - Run `"$WEBWRIGHT_PYTHON" final_runs/run_/final_script.py --run-once`, validate the printed schema, and confirm `app/feed.json` matches. - Start the server, open the printed URL, and confirm static app files are reachable. - Request `/run`, validate schema, and confirm `app/feed.json` matches. - Confirm cached `feed.json` renders before `/run` completes when cached items exist. @@ -405,7 +425,7 @@ agent: task sites plus local app loading, success/feed, and error/empty screenshots under the run's `screenshots/` directory. - 7. **Run self_reflection**: Execute `python -m webwright.tools.self_reflection --config app_judge_config.json --workspace-dir "{{ workspace_dir }}" --output final_runs/run_/app_judge_result.json`. If it exits non-zero or `predicted_label != 1`, diagnose, fix `final_script.py`, re-run in `final_runs/run_/`, and re-invoke self_reflection against the new run. + 7. **Run self_reflection**: Execute `"$WEBWRIGHT_PYTHON" -m webwright.tools.self_reflection --config app_judge_config.json --workspace-dir "{{ workspace_dir }}" --output final_runs/run_/app_judge_result.json`. If it exits non-zero or `predicted_label != 1`, diagnose, fix `final_script.py`, re-run in `final_runs/run_/`, and re-invoke self_reflection against the new run. 8. **Declare done**: Set `"done": true` only after the latest run satisfies every app/feed critical point and `app_judge_result.json` reports `"predicted_label": 1`. @@ -432,14 +452,14 @@ agent: 3. `final_script.py --help` or equivalent was run and documents `--run-once`. 4. `final_script.py` follows the app/feed template shape. 5. Import-safety smoke test passed. - 6. `python final_script.py --run-once` executed the reusable task, returned schema-valid JSON, and wrote matching `app/feed.json`. - 7. `python final_script.py` started a local server and printed a URL. + 6. `"$WEBWRIGHT_PYTHON" final_script.py --run-once` executed the reusable task, returned schema-valid JSON, and wrote matching `app/feed.json`. + 7. `"$WEBWRIGHT_PYTHON" final_script.py` started a local server and printed a URL. 8. `index.html`, `manifest.webmanifest`, `feed.json`, and `/healthz` were reachable. 9. `/run` returned schema-valid JSON and wrote matching `app/feed.json`. 10. Cached `feed.json` renders before `/run` completes when cached items exist. 11. `final_script_log.txt` records run-once execution, server URL, every source status, final item count, and task-relevant source collection evidence. 12. Final screenshots prove task source evidence, loading, successful feed items, and error or empty-state evidence. - 13. `python -m webwright.tools.self_reflection --config app_judge_config.json --workspace-dir "{{ workspace_dir }}" --output final_runs/run_/app_judge_result.json` exited 0 and wrote `"predicted_label": 1`. + 13. `"$WEBWRIGHT_PYTHON" -m webwright.tools.self_reflection --config app_judge_config.json --workspace-dir "{{ workspace_dir }}" --output final_runs/run_/app_judge_result.json` exited 0 and wrote `"predicted_label": 1`. Do NOT declare done if the app server was not run, if `/run` was not exercised, if app files are missing, if screenshots are unrelated to the diff --git a/src/webwright/config/base.yaml b/src/webwright/config/base.yaml index 3857517..850d52f 100644 --- a/src/webwright/config/base.yaml +++ b/src/webwright/config/base.yaml @@ -66,6 +66,7 @@ environment: task_metadata_filename: task.json final_script_name: final_script.py output_truncation_chars: 24000 + command_preview_chars: 2000 final_script_preview_chars: 4000 recent_files_limit: 40 env: diff --git a/src/webwright/environments/local_workspace.py b/src/webwright/environments/local_workspace.py index 04d3055..46106ee 100644 --- a/src/webwright/environments/local_workspace.py +++ b/src/webwright/environments/local_workspace.py @@ -3,13 +3,15 @@ import json import os import re +import shutil import subprocess +import sys from pathlib import Path from typing import Any from pydantic import BaseModel, Field -_EXPORT_RE = re.compile(r"^export\s+([A-Za-z_][A-Za-z0-9_]*)=(.*)$") +_ENV_ASSIGNMENT_RE = re.compile(r"^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$") _IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".webp"} @@ -40,6 +42,7 @@ class LocalWorkspaceEnvironmentConfig(BaseModel): task_metadata_filename: str = "task.json" final_script_name: str = "final_script.py" output_truncation_chars: int = 12000 + command_preview_chars: int = 2000 final_script_preview_chars: int = 4000 recent_files_limit: int = 40 @@ -64,7 +67,7 @@ def _load_credential_env(self, path: Path | None) -> dict[str, str]: line = raw_line.strip() if not line or line.startswith("#"): continue - match = _EXPORT_RE.match(line) + match = _ENV_ASSIGNMENT_RE.match(line) if match is None: continue key, raw_value = match.groups() @@ -74,6 +77,27 @@ def _load_credential_env(self, path: Path | None) -> dict[str, str]: parsed[key] = value return parsed + def _python_env(self) -> dict[str, str]: + """Expose the runner interpreter to agent-authored shell commands. + + Webwright requires Python >=3.10. If the CLI is launched from an activated + environment such as ``conda activate metachain``, subprocess commands + should use that same interpreter instead of falling back to a system/base + ``python`` that may be too old. + """ + + configured = os.environ.get("WEBWRIGHT_PYTHON") or sys.executable + resolved = shutil.which(configured) if not Path(configured).is_absolute() else configured + python_path = str(Path(resolved or configured).resolve()) + python_dir = str(Path(python_path).parent) + current_path = os.environ.get("PATH", "") + path = python_dir if not current_path else f"{python_dir}{os.pathsep}{current_path}" + return { + "WEBWRIGHT_PYTHON": python_path, + "PYTHON": python_path, + "PATH": path, + } + def _workspace_dir(self) -> Path: return self.config.output_dir.resolve() @@ -178,10 +202,10 @@ def execute(self, action: dict[str, Any], cwd: str = "") -> dict[str, Any]: command = str( action.get("command") or action.get("bash_command") or action.get("python_code") or "" ).strip() - self._persist_step_command(command) + step_path = self._persist_step_command(command) resolved_cwd = self._resolve_cwd(cwd) - command_env = os.environ | self._credential_env | self._browser_env() | self.config.env | { + command_env = os.environ | self._credential_env | self._python_env() | self._browser_env() | self.config.env | { "WORKSPACE_DIR": str(self._workspace_dir()), "OM2W_TASK_JSON": str(self._task_metadata_path()), "FINAL_SCRIPT_PATH": str(self._final_script_path()), @@ -214,6 +238,7 @@ def execute(self, action: dict[str, Any], cwd: str = "") -> dict[str, Any]: log_path = self._write_step_log(output) observation = self._capture_observation( command=command, + step_path=step_path, cwd=resolved_cwd, output=output, returncode=returncode, @@ -231,6 +256,7 @@ def _capture_observation( self, *, command: str, + step_path: Path, cwd: Path, output: str, returncode: int, @@ -252,7 +278,8 @@ def _capture_observation( return { "success": returncode == 0 and not exception_info, "exception": exception_info, - "command": command, + "command": self._truncate(command, self.config.command_preview_chars), + "command_path": str(step_path), "returncode": returncode, "workspace_dir": str(workspace_dir), "cwd": str(cwd), diff --git a/src/webwright/models/base.py b/src/webwright/models/base.py index 1ec6406..c831844 100644 --- a/src/webwright/models/base.py +++ b/src/webwright/models/base.py @@ -209,6 +209,7 @@ class BaseModelConfig(PydanticBaseModel): request_timeout_seconds: int = 120 error_log_path: Path | None = None observation_template: OptStr = DEFAULT_OBSERVATION_TEMPLATE + observation_truncation_chars: int = 12000 format_error_template: OptStr = DEFAULT_FORMAT_ERROR_TEMPLATE attach_observation_screenshot: bool = True action_field: str = "bash_command" @@ -384,6 +385,10 @@ def format_observation_messages( observation=observation, **(template_vars or {}), ) + limit = self.config.observation_truncation_chars + if limit > 0 and len(content) > limit: + omitted = len(content) - limit + content = f"{content[:limit]}\n\n... [{omitted} observation characters omitted]" parts: list[dict[str, Any]] = [text_part(content)] screenshot_path = observation.get("screenshot_path")