Download web/runner.py from AProver/aprover: direct link, hf CLI and curl.
- Browser
- Download file 19.9 kB
-
https://huggingface.co/spaces/AProver/aprover/resolve/main/web/runner.py
- Command line
-
hf download hf://spaces/AProver/aprover/web/runner.py
-
curl -L -o runner.py https://huggingface.co/spaces/AProver/aprover/resolve/main/web/runner.py
19.9 kB
| """ | |
| Streaming wrappers around AMCPipeline for the web chat front-end. | |
| Runs the pipeline in a background thread and yields JSON-serialisable progress | |
| events as they happen so the chat UI can paint a live timeline. Two entry | |
| points share one streaming harness (``_stream_pipeline``): | |
| - ``run_file_streaming`` β one existing file (e.g. from a cloned repo) | |
| - ``run_directory_streaming`` β every supported source file under a directory (a repo) | |
| A web run uses the same defaults as a bare ``bmc-agent verify`` (``_make_config`` | |
| no longer pins any knob before the options overlay, so ``Config.from_env()`` | |
| stands: realism + dynamic validation on, 120s solver budget, 5 refinement rounds, | |
| 3 spec retries). A visitor's "Run settings" panel overrides any of them via the | |
| validated ``options`` dict β see ``web.options`` and ``_make_config``. | |
| Pipeline log lines are captured per-run via ``bmc_agent.logger.set_log_sink`` | |
| (a context-local sink set inside each run's worker thread), so concurrent runs | |
| stream their own logs with no shared logging state and no serialisation. | |
| """ | |
| from __future__ import annotations | |
| import queue | |
| import shutil | |
| import tempfile | |
| import threading | |
| from pathlib import Path | |
| from typing import Callable, Iterator | |
| from bmc_agent.config import Config | |
| from bmc_agent.logger import reset_log_sink, set_log_sink | |
| from bmc_agent.pipeline import AMCPipeline | |
| from web.limits import MAX_VERIFY_FILES as _MAX_VERIFY_FILES | |
| from web.limits import WALL_TIMEOUT_SEC as _WALL_TIMEOUT_SEC | |
| def _make_config( | |
| work_dir: Path, | |
| *, | |
| provider: str = "", | |
| model: str = "", | |
| base_url: str = "", | |
| api_key: str = "", | |
| k2_backend: str = "", | |
| progress: "Callable[[dict], None] | None" = None, | |
| scale_down: bool = False, | |
| options: "dict | None" = None, | |
| ) -> Config: | |
| """Build a web-demo Config, routing the LLM through the visitor's selection. | |
| Setting ``llm_provider`` explicitly bypasses auto-detect, so the chosen | |
| backend ("anthropic" / "openai") is honoured; ``resolved_api_key()`` checks | |
| ``llm_api_key`` first, so this routes the whole pipeline (spec gen + | |
| refinement) through the caller's own key/model/endpoint. | |
| """ | |
| config = Config.from_env() | |
| if provider: | |
| config.llm_provider = provider | |
| if model: | |
| config.llm_model = model | |
| if base_url: | |
| config.llm_base_url = base_url | |
| if api_key: | |
| config.llm_api_key = api_key | |
| if k2_backend: | |
| config.llm_k2_backend = k2_backend | |
| config.artifact_dir = str(work_dir / "artifacts") | |
| # Structured-progress hook for the workbench (phase / function / finding / | |
| # cost events). Attached dynamically β Config has no slots β so the CLI path | |
| # (progress=None) is unaffected. See AMCPipeline._emit. | |
| if progress is not None: | |
| config.progress = progress # type: ignore[attr-defined] | |
| # The web inherits the same defaults as a bare ``bmc-agent verify`` run β i.e. | |
| # ``Config.from_env()`` (realism + dynamic validation on, 120s solver budget, | |
| # 5 refinement rounds, 3 spec retries). A run with options=None is therefore | |
| # CLI-identical. The visitor's "Run settings" panel overrides any knob via the | |
| # validated, clamped ``options`` (see web.options); the estimator assumes the | |
| # same defaults (see web.estimate). | |
| _apply_options(config, options or {}, api_key) | |
| # Recovery retry on a solver blow-up (the design's "retry Β· scaled"): bound | |
| # ML/numerics parametric sizes + restrict specs to safety so a function that | |
| # timed out at full size becomes tractable. Applied last so a scaled retry | |
| # wins over any scale-down toggle the options carried. See Config.scale_down. | |
| if scale_down: | |
| config.scale_down = True | |
| config.safety_only = True | |
| return config | |
| # Config field groups whose option keys are 1:1 Config attributes β overlaid with | |
| # a guarded setattr. Web-only keys (spec mode / per-role routing / run_mode) are | |
| # handled explicitly, not by the generic loop. | |
| _OVERLAY_GROUPS = ("depth", "ai_layers", "harness", "threat") | |
| def _apply_options(config: Config, opts: dict, api_key: str) -> None: | |
| """Overlay the validated, clamped run ``options`` (``web.options.parse_options``) | |
| onto ``config``. Only keys the visitor actually sent are present, so this | |
| never resets an untouched knob away from its Config (CLI) default.""" | |
| for group in _OVERLAY_GROUPS: | |
| for key, val in (opts.get(group) or {}).items(): | |
| if hasattr(config, key): | |
| setattr(config, key, val) | |
| # agentic group: the bool/int knobs are 1:1 Config fields; the nested ``llm`` | |
| # block carries per-role routing and is applied separately below. | |
| for key, val in (opts.get("agentic") or {}).items(): | |
| if key != "llm" and hasattr(config, key): | |
| setattr(config, key, val) | |
| # math_ints is the only knob the web honors from the spec_mode group. The | |
| # CLI-only synthesis modes (specs-bench / standalone / loop-invariants) run a | |
| # different code path than pipeline.run(), so they're not exposed on the web. | |
| spec_mode = opts.get("spec_mode") or {} | |
| if "math_ints" in spec_mode: | |
| config.math_ints = bool(spec_mode["math_ints"]) | |
| # Per-role LLM routing: reuse the single BYOK key for every role (per-role | |
| # keys are deliberately not accepted in the body). The pipeline auto-disables | |
| # parallel validation when role overrides are set (pipeline.py), so this is | |
| # thread-safe. | |
| roles = ((opts.get("agentic") or {}).get("llm") or {}).get("roles") or {} | |
| if roles: | |
| config.llm_role_overrides = { | |
| role: {**spec, "api_key": api_key} for role, spec in roles.items() | |
| } | |
| def _bug_dict(report, file: str | None = None) -> dict: | |
| d = { | |
| "function": report.function_name, | |
| "bug_type": report.bug_type, | |
| "violated_property": report.violated_property, | |
| "confidence": report.confidence, | |
| "call_chain": report.call_chain or [], | |
| "reasoning": (report.reasoning_trail or "")[:600], | |
| } | |
| if file is not None: | |
| d["file"] = file | |
| return d | |
| def _stream_pipeline( | |
| config: Config, | |
| work_dir: Path, | |
| run_fn: Callable[[AMCPipeline], list[tuple[str | None, object]]], | |
| started_meta: dict, | |
| pause_check: "Callable[[], bool] | None" = None, | |
| ) -> Iterator[dict]: | |
| """Run ``run_fn(pipeline)`` in a thread, streaming log/started/result events. | |
| ``run_fn`` returns a flat list of ``(file_or_None, BugReport)`` pairs so both | |
| single-file and directory runs share one summary shape. ``work_dir`` is a | |
| scratch dir (artifacts live under it) and is removed when the run ends β it | |
| must NOT contain the caller's source (clone/session files live elsewhere). | |
| """ | |
| if config.resolved_provider() != "claude-code" and not config.resolved_api_key(): | |
| yield { | |
| "type": "error", | |
| "message": "No API key configured for the selected provider β verification cannot run.", | |
| } | |
| shutil.rmtree(work_dir, ignore_errors=True) | |
| return | |
| events: queue.Queue = queue.Queue() | |
| sentinel = object() | |
| holder: dict = {} | |
| def worker() -> None: | |
| # Route this run's bmc_agent log lines onto our queue. The sink is | |
| # context-local to this thread, so parallel runs don't cross streams. | |
| token = set_log_sink( | |
| lambda level, msg: events.put({"type": "log", "level": level, "message": msg}) | |
| ) | |
| try: | |
| pipeline = AMCPipeline(config) | |
| holder["pairs"] = run_fn(pipeline) | |
| except Exception as exc: # pragma: no cover - surfaced to user | |
| holder["error"] = f"{type(exc).__name__}: {exc}" | |
| finally: | |
| reset_log_sink(token) | |
| events.put(sentinel) | |
| t = threading.Thread(target=worker, daemon=True) | |
| t.start() | |
| yield {"type": "started", **started_meta} | |
| timed_out = False | |
| idle = 0.0 | |
| _POLL = 1.0 | |
| while True: | |
| try: | |
| ev = events.get(timeout=_POLL) | |
| idle = 0.0 | |
| except queue.Empty: | |
| # A deliberate pause (workbench) is not inactivity β don't let it | |
| # trip the wall timeout. Only genuine idle time counts. | |
| if pause_check is not None and pause_check(): | |
| continue | |
| idle += _POLL | |
| if idle >= _WALL_TIMEOUT_SEC: | |
| timed_out = True | |
| break | |
| continue | |
| if ev is sentinel: | |
| break | |
| yield ev | |
| t.join(timeout=5) | |
| shutil.rmtree(work_dir, ignore_errors=True) | |
| if timed_out: | |
| yield { | |
| "type": "result", | |
| "result": {"ok": False, "error": f"Pipeline exceeded {_WALL_TIMEOUT_SEC}s wall timeout."}, | |
| } | |
| return | |
| if "error" in holder: | |
| yield {"type": "result", "result": {"ok": False, "error": holder["error"]}} | |
| return | |
| pairs = holder.get("pairs") or [] | |
| bugs = [_bug_dict(r, file) for file, r in pairs] | |
| yield {"type": "result", "result": {"ok": True, "n_bugs": len(bugs), "bugs": bugs}} | |
| def run_file_streaming( | |
| file_path: str, | |
| function: str | None = None, | |
| domain_knowledge: str = "", | |
| api_key: str = "", | |
| provider: str = "", | |
| model: str = "", | |
| base_url: str = "", | |
| k2_backend: str = "", | |
| progress: "Callable[[dict], None] | None" = None, | |
| pause_check: "Callable[[], bool] | None" = None, | |
| scale_down: bool = False, | |
| options: "dict | None" = None, | |
| source_root: str = "", | |
| ) -> Iterator[dict]: | |
| """Verify one existing file (e.g. from a cloned repo). No paste-size cap. | |
| ``source_root`` is the cloned repo root (when the file came from one). It's | |
| used to discover the repo's include dirs so ``cc -E`` resolves project | |
| headers β without it a file that ``#include``\\s a project header fails to | |
| preprocess, the raw include survives into harness.c, and CBMC reports | |
| "harness build failed" for every function. Mirrors the directory path.""" | |
| src = Path(file_path) | |
| if not src.is_file(): | |
| yield {"type": "error", "message": f"File not found: {file_path}"} | |
| return | |
| work_dir = Path(tempfile.mkdtemp(prefix="aprover_web_")) | |
| config = _make_config(work_dir, provider=provider, model=model, base_url=base_url, | |
| api_key=api_key, k2_backend=k2_backend, progress=progress, | |
| scale_down=scale_down, options=options) | |
| # Discover the repo's include dirs and turn on cc -E preprocessing so a | |
| # single-file run resolves project headers (the directory path does this in | |
| # AMCPipeline.run_directory; the single-file path needs it set explicitly). | |
| if source_root: | |
| from bmc_agent.preprocessor import discover_include_dirs | |
| config.include_dirs = discover_include_dirs(source_root) | |
| config.preprocess = True | |
| def run_fn(pipeline: AMCPipeline) -> list[tuple[str | None, object]]: | |
| reports = pipeline.run( | |
| source_file=str(src), | |
| driver_name="webfile", | |
| domain_knowledge=domain_knowledge, | |
| ) or [] | |
| if function: | |
| reports = [r for r in reports if r.function_name == function] | |
| return [(None, r) for r in reports] | |
| yield from _stream_pipeline(config, work_dir, run_fn, {"file": src.name}, pause_check) | |
| def run_directory_streaming( | |
| source_dir: str, | |
| only_functions: list[str] | None = None, | |
| exclude_patterns: list[str] | None = None, | |
| domain_knowledge: str = "", | |
| api_key: str = "", | |
| provider: str = "", | |
| model: str = "", | |
| base_url: str = "", | |
| k2_backend: str = "", | |
| progress: "Callable[[dict], None] | None" = None, | |
| pause_check: "Callable[[], bool] | None" = None, | |
| scale_down: bool = False, | |
| options: "dict | None" = None, | |
| max_files: int = _MAX_VERIFY_FILES, | |
| ) -> Iterator[dict]: | |
| """Verify every supported source file under ``source_dir`` (a cloned repo). | |
| Dispatches by language (C via cross-file analysis, Rust/Java per-file) | |
| through ``AMCPipeline.verify_tree``, which owns the single tree walk and | |
| the file cap β so the runner no longer scans the tree itself. ``max_files`` | |
| defaults to the env-tunable web cap and may be overridden per run (the | |
| workbench surfaces it as a setting).""" | |
| root = Path(source_dir) | |
| if not root.is_dir(): | |
| yield {"type": "error", "message": f"Directory not found: {source_dir}"} | |
| return | |
| exclude = exclude_patterns or ["*test*", "*mock*"] | |
| funcs = set(only_functions) if only_functions else None | |
| work_dir = Path(tempfile.mkdtemp(prefix="aprover_web_")) | |
| config = _make_config(work_dir, provider=provider, model=model, base_url=base_url, | |
| api_key=api_key, k2_backend=k2_backend, progress=progress, | |
| scale_down=scale_down, options=options) | |
| def run_fn(pipeline: AMCPipeline) -> list[tuple[str | None, object]]: | |
| results = pipeline.verify_tree( | |
| source_dir=str(root), | |
| driver_name="webrepo", | |
| domain_knowledge=domain_knowledge, | |
| exclude_patterns=list(exclude), | |
| only_functions=funcs, | |
| max_files=max_files, | |
| ) or {} | |
| pairs: list[tuple[str | None, object]] = [] | |
| for fname, reports in results.items(): | |
| for r in reports or []: | |
| pairs.append((fname, r)) | |
| return pairs | |
| yield from _stream_pipeline(config, work_dir, run_fn, {"dir": root.name}, pause_check) | |
| def _pump_round(config: Config, run_fn, pause_check): | |
| """Run one pipeline call in a worker thread, streaming its log events; return | |
| ``(pairs, error)`` via the generator's StopIteration value (consume with | |
| ``yield from``). Unlike ``_stream_pipeline`` this does NOT remove the work | |
| dir β the autonomous loop reuses it across rounds and owns its lifetime.""" | |
| events: queue.Queue = queue.Queue() | |
| sentinel = object() | |
| holder: dict = {} | |
| def worker() -> None: | |
| token = set_log_sink( | |
| lambda level, msg: events.put({"type": "log", "level": level, "message": msg}) | |
| ) | |
| try: | |
| pipeline = AMCPipeline(config) | |
| holder["pairs"] = run_fn(pipeline) | |
| except Exception as exc: # pragma: no cover - surfaced to user | |
| holder["error"] = f"{type(exc).__name__}: {exc}" | |
| finally: | |
| reset_log_sink(token) | |
| events.put(sentinel) | |
| t = threading.Thread(target=worker, daemon=True) | |
| t.start() | |
| idle = 0.0 | |
| _POLL = 1.0 | |
| while True: | |
| try: | |
| ev = events.get(timeout=_POLL) | |
| idle = 0.0 | |
| except queue.Empty: | |
| # A deliberate pause isn't inactivity β only genuine idle counts. | |
| if pause_check is not None and pause_check(): | |
| continue | |
| idle += _POLL | |
| if idle >= _WALL_TIMEOUT_SEC: | |
| t.join(timeout=5) | |
| return [], f"Pipeline exceeded {_WALL_TIMEOUT_SEC}s wall timeout." | |
| continue | |
| if ev is sentinel: | |
| break | |
| yield ev | |
| t.join(timeout=5) | |
| if "error" in holder: | |
| return [], holder["error"] | |
| return holder.get("pairs") or [], None | |
| def run_autonomous_streaming( | |
| source_dir: str, | |
| only_functions: list[str] | None = None, | |
| exclude_patterns: list[str] | None = None, | |
| domain_knowledge: str = "", | |
| api_key: str = "", | |
| provider: str = "", | |
| model: str = "", | |
| base_url: str = "", | |
| k2_backend: str = "", | |
| progress: "Callable[[dict], None] | None" = None, | |
| pause_check: "Callable[[], bool] | None" = None, | |
| scale_down: bool = False, | |
| options: "dict | None" = None, | |
| max_files: int = _MAX_VERIFY_FILES, | |
| max_rounds: int = 3, | |
| ) -> Iterator[dict]: | |
| """Round-based autonomous verification β the web port of the CLI ``autonomous``. | |
| Re-runs ``verify_tree`` over the SAME Config up to ``max_rounds`` times, so the | |
| Phase-2b auto-retry session-strip sets accumulate across rounds, stopping early | |
| at a fixed point (a round that adds no new findings and no new recovery state). | |
| ``allow_self_patch`` is forced ``"deny"`` β the web never lets an agent edit | |
| AProver's own source. Each round emits a ``run`` event so the ETA estimator | |
| times rounds independently (see web.jobs / bmc_agent.eta).""" | |
| root = Path(source_dir) | |
| if not root.is_dir(): | |
| yield {"type": "error", "message": f"Directory not found: {source_dir}"} | |
| return | |
| exclude = exclude_patterns or ["*test*", "*mock*"] | |
| funcs = set(only_functions) if only_functions else None | |
| work_dir = Path(tempfile.mkdtemp(prefix="aprover_web_")) | |
| config = _make_config(work_dir, provider=provider, model=model, base_url=base_url, | |
| api_key=api_key, k2_backend=k2_backend, progress=progress, | |
| scale_down=scale_down, options=options) | |
| # Hard safety floor for a public, bring-your-own-key surface: the autonomous | |
| # loop may NEVER let an LLM patch AProver's own source, whatever the options. | |
| config.allow_self_patch = "deny" | |
| # Cross-round convergence relies on the feedback loop persisting learned | |
| # clauses, and on the auto-retry recovery state, into the shared Config. | |
| config.enable_feedback_loop = True | |
| if config.resolved_provider() != "claude-code" and not config.resolved_api_key(): | |
| yield { | |
| "type": "error", | |
| "message": "No API key configured for the selected provider β verification cannot run.", | |
| } | |
| shutil.rmtree(work_dir, ignore_errors=True) | |
| return | |
| rounds = max(1, int(max_rounds or 1)) | |
| all_pairs: list[tuple[str | None, object]] = [] | |
| last_fp = None | |
| try: | |
| for rnd in range(rounds): | |
| # A "run" event resets the per-round ETA clock (see Job.ingest). | |
| yield {"type": "run", "round": rnd + 1, "max_rounds": rounds, "dir": root.name} | |
| yield {"type": "log", "level": "info", | |
| "message": f"Autonomous round {rnd + 1}/{rounds}"} | |
| def run_fn(pipeline: AMCPipeline) -> list[tuple[str | None, object]]: | |
| results = pipeline.verify_tree( | |
| source_dir=str(root), | |
| driver_name="webrepo", | |
| domain_knowledge=domain_knowledge, | |
| exclude_patterns=list(exclude), | |
| only_functions=funcs, | |
| max_files=max_files, | |
| ) or {} | |
| pairs: list[tuple[str | None, object]] = [] | |
| for fname, reports in results.items(): | |
| for r in reports or []: | |
| pairs.append((fname, r)) | |
| return pairs | |
| pairs, err = yield from _pump_round(config, run_fn, pause_check) | |
| if err is not None: | |
| yield {"type": "result", "result": {"ok": False, "error": err}} | |
| return | |
| all_pairs = pairs # verify_tree re-reports the whole tree each round | |
| # Fixed point: a round that neither found anything new nor grew the | |
| # auto-retry recovery state. Cheap proxy for the CLI's coverage check. | |
| fp = ( | |
| len(pairs), | |
| len(config.session_strip_typedefs), | |
| len(config.session_strip_structs), | |
| len(config.session_opaque_param_structs), | |
| len(config.session_stub_functions), | |
| ) | |
| if last_fp is not None and fp == last_fp: | |
| yield {"type": "log", "level": "info", | |
| "message": f"Converged after round {rnd + 1} β no new findings or recovery state."} | |
| break | |
| last_fp = fp | |
| bugs = [_bug_dict(r, file) for file, r in all_pairs] | |
| yield {"type": "result", "result": {"ok": True, "n_bugs": len(bugs), "bugs": bugs}} | |
| finally: | |
| shutil.rmtree(work_dir, ignore_errors=True) | |