diff --git a/README.md b/README.md index 9f017cf..69e0ba7 100644 --- a/README.md +++ b/README.md @@ -290,6 +290,29 @@ In one 6-cycle run this drove the dev speedup from **1.01x → 7.77x** (held-out test **1.00x → 7.22x**). See [`examples/algotune_knn/README.md`](examples/algotune_knn/README.md) for the research contract and tuning knobs. +### Collect a benchmark from a request (experimental) + +You don't have to assemble a benchmark by hand. `arbor benchmark add` turns a +one-line request into a runnable **draft task**: it finds the dataset/benchmark and, +on an interactive terminal, asks you **which dataset** to use and **where the +baseline comes from** — harvest an existing implementation, implement the method you +described, or find one online — then acquires the data and brings up a draft +(baseline + eval + `README` + provenance). It does *not* force-run the eval; a real +run may need your served model / API key. + +```bash +# name a work, or give a goal + a method +arbor benchmark add "get me the datasets WebThinker uses" +arbor benchmark add "I want to climb GPQA with a self-consistency baseline" + +# or point straight at a repo / HF dataset (add --bringup to also build the baseline) +arbor benchmark add https://github.com/owner/repo --name my-bench --bringup +``` + +Drafting is automated; acceptance stays human. `arbor benchmark verify ` +structurally checks a pack, and `arbor benchmark list` indexes the zoo. See the +[benchmark zoo overview](docs/zoo-overview.md) for the format and the full flow. + --- ## 🧠 How It Works diff --git a/docs/roadmap.md b/docs/roadmap.md index 2ad5e49..23a57b2 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -117,11 +117,14 @@ Still open: - **Grow the collection** to 3–5 high-quality, human-checked packs across distinct task shapes, using `algotune_knn` as the reference. Cap on quality, not count. -- **`arbor benchmark add`** — semi-automatic conversion: the intake agent *drafts* - a Task Pack from a raw benchmark, gated behind the verifier and a human accept - step (draft-automatic, accept-verified — never auto-accepted). The - baseline-implementing agent stays separate from the loop that later optimizes it, - so evaluation isn't self-certifying. *(Designed; not yet built.)* +- **`arbor benchmark add`** — semi-automatic conversion: from a one-line request the + agent finds the dataset, asks (on an interactive terminal) which dataset to use and + where the baseline comes from (harvest an existing one / implement the method you + described / find one online), and brings up a runnable draft — gated behind the + verifier and a human accept step (draft-automatic, accept-verified — never + auto-accepted). The baseline-implementing agent stays separate from the loop that + later optimizes it, so evaluation isn't self-certifying. *(Built: discovery + + interactive bring-up; bring-up reasoning still maturing.)* - **Lower a pack into a [plugin](plugins.md)** for one-line retargeting — the front-matter contract reuses the `plugin` vocabulary (`eval_contract` / `protected_paths`), so it should fall out with little rework (pairs with 2.2). diff --git a/docs/roadmap.zh.md b/docs/roadmap.zh.md index 8c45a8b..be3cfdc 100644 --- a/docs/roadmap.zh.md +++ b/docs/roadmap.zh.md @@ -97,9 +97,11 @@ - **扩充集合**到 3–5 个高质量、人工核对的 pack,覆盖不同任务形态,以 `algotune_knn` 为 参考。质量封顶,不是数量封顶。 -- **`arbor benchmark add`**——半自动转换:intake agent 从原始基准*起草*一个 Task Pack, - 再由校验器和人工接受这一步把关(起草自动、接受需校验——绝不自动接受)。*实现* baseline 的 - agent 与之后优化它的 loop 分开,使评测不自证。*(已设计;尚未实现。)* +- **`arbor benchmark add`**——半自动转换:从一句话需求出发,agent 找到数据集,在交互终端里 + 询问用户**用哪个数据集、baseline 从哪来**(收割现成的 / 按你描述的方法实现 / 上网找),并产出 + 一个可运行草稿,再由校验器和人工接受这一步把关(起草自动、接受需校验——绝不自动接受)。*实现* + baseline 的 agent 与之后优化它的 loop 分开,使评测不自证。*(已实现:discovery + 交互式 + bring-up;bring-up 的推理仍在打磨。)* - **把一个 pack 降级成 [plugin](plugins.md)** 以实现一行改写重定向——front-matter 契约 复用 `plugin` 词汇(`eval_contract` / `protected_paths`),应能几乎无返工地导出(与 2.2 配套)。 diff --git a/docs/zoo-overview.md b/docs/zoo-overview.md index f056d01..804fbc2 100644 --- a/docs/zoo-overview.md +++ b/docs/zoo-overview.md @@ -12,8 +12,12 @@ one folder per benchmark. point Arbor at it and it begins optimizing. - **Onboard your own task.** If you have code but no runnable eval, one command adds the eval scaffolding. -- **Collect new ones (in progress).** Have Arbor fetch a benchmark from GitHub / HuggingFace - and shape it into this format. +- **Collect new ones (in progress).** Describe what you want in one line — name a work + ("get me the datasets WebThinker uses") or a goal ("I want to climb GPQA with a + self-consistency method"). An agent finds the dataset/benchmark, asks you which dataset to + use and where the baseline should come from (harvest an existing one, implement the method + you described, or find one online), acquires the data, and brings up a runnable draft. You + can also point it straight at a repo URL. ## What a benchmark contains @@ -37,7 +41,7 @@ and repeat. | Run Arbor on a benchmark | copy it out of the repo, `git init`, run `arbor` inside it | | Verify a benchmark's structure | `arbor benchmark verify ` | | Make your code a runnable benchmark | `arbor benchmark scaffold ` | -| Fetch a benchmark from GitHub / HF | `arbor benchmark add --name ` | +| Find & build a benchmark from a request | `arbor benchmark add ""` (or a repo URL) | Running one: @@ -51,8 +55,9 @@ arbor # confirm the task, then it it - **Available:** the format, `verify`, `list`, `scaffold`, the `add` spine, and the first example benchmark, `algotune_knn`. -- **In progress:** strengthening `add` (research the benchmark and bring its baseline up), and - adding more benchmarks. +- **In progress:** strengthening `add` — from a one-line request, find the dataset, ask which + one and where the baseline comes from, and bring up a runnable draft — and adding more + benchmarks. For the exact format, see the [format reference](zoo.md); for the wider plan, see the [roadmap](roadmap.md). diff --git a/docs/zoo-overview.zh.md b/docs/zoo-overview.zh.md index fac6871..943f043 100644 --- a/docs/zoo-overview.zh.md +++ b/docs/zoo-overview.zh.md @@ -8,7 +8,7 @@ Arbor 的工作流程是:针对一个可评分的任务,迭代地修改代码、 - **可直接运行的优化任务。** 每个基准自带评测脚本与基线实现,将 Arbor 指向它即可开始优化。 - **接入你自己的任务。** 若你已有代码但缺少可运行的评测,一条命令即可补全评测脚手架。 -- **自动收集(开发中)。** 由 Arbor 从 GitHub / HuggingFace 获取一个 benchmark 并整理为本格式。 +- **自动收集(开发中)。** 用一句话描述需求——点名一个工作("帮我获取 WebThinker 用到的数据集"),或一个目标("我想用自一致性方法刷 GPQA")。由 agent 找到数据集/benchmark,**询问你要哪个数据集、baseline 从哪来**(收割现成的、按你描述的方法实现、或上网找现成实现),获取数据并产出一个可运行草稿;也可直接给一个 repo 地址。 ## 基准的组成 @@ -28,7 +28,7 @@ Arbor 的迭代循环为:修改基线 → 运行评测 → 若分数提升则保 | 在某个基准上运行 Arbor | 将其拷出仓库,`git init` 后在目录内运行 `arbor` | | 校验一个基准的结构 | `arbor benchmark verify <目录>` | | 将你的代码补全为可运行的基准 | `arbor benchmark scaffold <目录>` | -| 从 GitHub / HF 获取一个 benchmark | `arbor benchmark add <地址> --name <名称>` | +| 查找并构建一个 benchmark | `arbor benchmark add "<需求>"`(或 repo 地址) | 运行示例: @@ -42,6 +42,6 @@ arbor # 确认任务后开始迭代 - **已支持:** 基准格式、校验(`verify`)、列表(`list`)、脚手架(`scaffold`)、收集主体(`add`), 以及首个示例基准 `algotune_knn`。 -- **进行中:** 增强 `add`(自动调研 benchmark 并跑通基线),以及补充更多基准。 +- **进行中:** 增强 `add`(一句话需求 → 找到数据集、问你要哪个/baseline 从哪来、产出可运行草稿),以及补充更多基准。 撰写一个基准的详细格式见[格式参考](zoo.md);整体规划见[路线图](roadmap.md)。 diff --git a/src/cli/commands/benchmark_cmd.py b/src/cli/commands/benchmark_cmd.py index f9961ed..ff64d6b 100644 --- a/src/cli/commands/benchmark_cmd.py +++ b/src/cli/commands/benchmark_cmd.py @@ -8,9 +8,13 @@ leaderboard). * ``arbor benchmark scaffold `` — write the measurement plumbing (light) or a full zoo benchmark (zoo) into an existing local directory. -* ``arbor benchmark add --name `` — acquire a benchmark (git repo / HF - dataset) into the global cache and scaffold a draft pack (Phase 1: the deterministic - spine; the agent-driven survey + bring-up are the next sub-phase). +* ``arbor benchmark add "" | `` — turn a one-line request into a runnable + draft task. A natural-language request is handled end-to-end by an agent: it finds the + dataset/benchmark, asks (on a TTY) which dataset to use and where the baseline comes from + (harvest an existing one / implement the method you described / find one online), acquires + the data, and brings up a runnable draft (baseline + eval + README + PROVENANCE) — without + force-running the eval. A bare URL/HF spec skips discovery and just acquires + scaffolds; + ``--bringup`` also brings up its baseline. """ from __future__ import annotations @@ -22,7 +26,9 @@ from ...zoo import ( VerifyResult, + bringup, collect, + discover, discover_packs, select_acquirer, verify_pack, @@ -48,6 +54,24 @@ def _render(r: VerifyResult) -> None: typer.secho(f" hint: {r.hint}", fg=typer.colors.RED, err=True) +def _user_runner(*, with_search: bool, ask_user: bool = False): + """Build an agent runner that uses the user's configured provider (~/.arbor/config.yaml), + so the collection agents inherit the same LLM as `arbor run` (e.g. openai-oauth/gpt-5.5). + + ``ask_user`` adds a console AskUser tool so the agent can put a genuinely human decision + (e.g. which implementation is the baseline) to the user; enable it only on an interactive + terminal.""" + from ...zoo import real_agent_runner + from ..user_config import llm_defaults + + llm = llm_defaults() + return real_agent_runner( + with_search=with_search, ask_user=ask_user, + provider=llm.get("provider"), model=llm.get("model"), + api_key=llm.get("api_key"), base_url=llm.get("base_url"), + ) + + @benchmark_app.command("verify") def verify_command( pack_dir: Path = typer.Argument( @@ -147,29 +171,86 @@ def scaffold_command( def add_command( spec: str = typer.Argument( ..., - help="A git repo URL (optionally `url@commit`) or a HF dataset (`hf:`).", + help="A natural-language query, a git repo URL (optionally `url@commit`), or a HF " + "dataset (`hf:`). A query is resolved by the discovery agent.", ), - name: str = typer.Option( - ..., "--name", "-n", - help="Pack name (the arbor-zoo/ folder).", + name: str | None = typer.Option( + None, "--name", "-n", + help="Pack name (the arbor-zoo/ folder). Optional for a query (discovery suggests one).", ), dest: Path = typer.Option( Path("arbor-zoo"), "--dest", help="Where to write the draft pack (default: ./arbor-zoo).", ), + assume_yes: bool = typer.Option( + False, "--yes", "-y", help="Skip the confirmation prompt for a discovered benchmark.", + ), + do_bringup: bool = typer.Option( + False, "--bringup", + help="Force the bring-up agent (implement/wire the baseline + write the eval). " + "A natural-language query runs bring-up by default; use this to also trigger it " + "for a bare URL/HF spec. Needs a configured LLM provider / API key.", + ), + max_turns: int = typer.Option( + 100, "--max-turns", help="Agent turn budget (discovery / bring-up).", + ), ) -> None: - """Acquire a benchmark and scaffold a draft pack (deterministic spine). + """Turn a one-line request into a runnable draft task. - Phase 1: selects an acquirer, clones/downloads into the global cache, scaffolds a - draft pack, and structurally verifies it. The agent-driven survey + baseline bring-up - are the next sub-phase — this leaves a draft for a human (or a later agent pass) to - complete, then `arbor benchmark verify` and accept. + A **natural-language request** is handled by an agent end-to-end: it searches GitHub / + HuggingFace / arXiv, and — on an interactive terminal — asks you which dataset to use and + where the baseline should come from (harvest an existing one, implement the method you + described, or find one online). It then acquires the data and brings up a *runnable draft* + (baseline + eval + README + PROVENANCE). It does NOT force-run the eval — a real run may + need your served model / API key. A bare URL/HF spec skips discovery and just acquires + + scaffolds; add ``--bringup`` to also bring up its baseline. Acceptance stays human. """ - if select_acquirer(spec) is None: - typer.secho( - f"error: no acquirer matched {spec!r} — expected a git URL or `hf:`", - fg=typer.colors.RED, err=True, - ) + import asyncio + import sys + + interactive = sys.stdin.isatty() + request = spec # the user's original words (shapes an implemented baseline) + from_query = select_acquirer(spec) is None + baseline_plan: dict = {} + + # ── Natural-language request → discovery agent → a chosen source ────────── + if from_query: + import tempfile + + typer.secho(f"searching for a benchmark matching: {spec!r} …", fg=typer.colors.CYAN) + try: + disc = asyncio.run(discover( + spec, run_agent=_user_runner(with_search=True, ask_user=interactive), + work_dir=Path(tempfile.mkdtemp(prefix="arbor-discover-")), + max_turns=max_turns, + )) + except Exception as exc: # noqa: BLE001 + typer.secho(f" discovery could not start: {exc}", fg=typer.colors.RED, err=True) + typer.echo(" (configure a provider with `arbor setup` / set your API key)") + raise typer.Exit(code=1) from exc + for note in disc.notes: + typer.echo(f" • {note}") + if not disc.ok or not disc.url: + typer.secho("no suitable benchmark found — give a specific repo URL instead.", + fg=typer.colors.RED, err=True) + raise typer.Exit(code=1) + choice = disc.choice or {} + baseline_plan = disc.baseline_plan + typer.secho(f"\nchosen: {disc.name} → {disc.url}", fg=typer.colors.GREEN) + typer.echo(f" metric: {choice.get('metric', '?')}") + typer.echo(f" baseline: {choice.get('baseline', '?')}") + if baseline_plan: + typer.echo(f" plan: baseline via {baseline_plan.get('source', '?')} — " + f"{baseline_plan.get('detail', '')}") + typer.echo(f" why: {choice.get('why', '?')}") + if not assume_yes and not typer.confirm("\nacquire this benchmark?", default=True): + raise typer.Exit(code=0) + spec = disc.url + name = name or disc.name + + if not name: + typer.secho("error: --name is required (could not infer one).", + fg=typer.colors.RED, err=True) raise typer.Exit(code=2) typer.echo(f"collecting {name} from {spec} …") @@ -188,6 +269,35 @@ def add_command( typer.echo(f"structural verify: {len(fails)} fail(s) " f"(eval not run — the draft eval is a stub)") + # A query runs bring-up by default (producing a runnable draft is the whole point); a bare + # URL/HF spec only brings up when asked with --bringup. + if (from_query or do_bringup) and result.draft_pack_dir: + materials = result.acquired.materials_dir if result.acquired else None + typer.secho("\nbringing up the baseline (agent) …", fg=typer.colors.CYAN) + try: + br = asyncio.run(bringup( + result.draft_pack_dir, + run_agent=_user_runner(with_search=True, ask_user=interactive), + materials_dir=materials, + instruction=request if from_query else "", + baseline_plan=baseline_plan, + max_turns=max_turns, + )) + except Exception as exc: # noqa: BLE001 — surface provider/setup errors clearly + typer.secho(f" bring-up could not start: {exc}", fg=typer.colors.RED, err=True) + typer.echo(" (configure a provider with `arbor setup` / set your API key, then retry)") + raise typer.Exit(code=1) from exc + for note in br.notes: + typer.echo(f" • {note}") + if br.ran: + typer.secho(f" bring-up {'ok' if br.ok else 'incomplete'} — eval ran " + f"(dev score: {br.dev_score})", + fg=typer.colors.GREEN if br.ok else typer.colors.YELLOW) + else: + typer.secho(f" {'runnable draft ready' if br.ok else 'bring-up incomplete'} — " + f"eval not run here (needs your model / API key)", + fg=typer.colors.GREEN if br.ok else typer.colors.YELLOW) + typer.secho("\nstill to do (drafting is automated, acceptance is not):", fg=typer.colors.YELLOW) for step in result.pending: diff --git a/src/zoo/__init__.py b/src/zoo/__init__.py index 4196ac9..e12a958 100644 --- a/src/zoo/__init__.py +++ b/src/zoo/__init__.py @@ -13,6 +13,8 @@ from __future__ import annotations from .acquire import Acquired, Acquirer, GitRepoAcquirer, Sources, select_acquirer +from .agent_stages import BringupResult, DiscoveryResult, bringup, discover, real_agent_runner +from .ask_tool import ConsoleAskUserTool from .cache import Manifest, benchmark_cache_dir, cache_root from .collect import CollectResult, collect from .pack import ( @@ -29,7 +31,10 @@ "EVAL_ENTRYPOINTS", "Acquired", "Acquirer", + "BringupResult", "CollectResult", + "ConsoleAskUserTool", + "DiscoveryResult", "GitRepoAcquirer", "Manifest", "PackSummary", @@ -37,11 +42,14 @@ "Sources", "VerifyResult", "benchmark_cache_dir", + "bringup", "cache_root", "collect", + "discover", "discover_packs", "find_eval_entrypoint", "is_pack_dir", + "real_agent_runner", "scaffold_benchmark", "select_acquirer", "verify_pack", diff --git a/src/zoo/agent_stages.py b/src/zoo/agent_stages.py new file mode 100644 index 0000000..4e84bd0 --- /dev/null +++ b/src/zoo/agent_stages.py @@ -0,0 +1,349 @@ +"""Agent-driven collection stages (Stage 2: baseline bring-up). + +The collection *spine* (:mod:`arbor.zoo.collect`) is deterministic — it acquires +materials and scaffolds a draft. This module is the agent-driven part: given a live +LLM provider, :func:`bringup` spawns one agent in the draft folder to make a baseline +actually run — install deps, get the reference working, wrap a clean ``eval`` that +prints ``score:``, and write the README + PROVENANCE — then checks its work by running +the eval and the structural verifier. + +It reuses the core :class:`~arbor.core.agent.Agent` runtime (the same one the executor +uses) but stays a standalone flow: it never wires into the Coordinator/Executor research +loop (a §2.1 correctness requirement). + +The agent run is behind an injected ``run_agent`` callable so the orchestration is +testable without a live LLM (a fake runner writes the files a real agent would). The real +runner — :func:`real_agent_runner` — constructs the ``Agent`` with bash + file tools and +needs a configured provider (API key); validating its *reasoning* needs live iteration. +""" + +from __future__ import annotations + +import json +import os +import re +import subprocess +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Awaitable, Protocol + +from .pack import find_eval_entrypoint +from .verify import VerifyResult, verify_pack + + +class AgentRunner(Protocol): + """Runs an agent to completion in *cwd* and returns its final transcript text.""" + + def __call__(self, *, cwd: Path, system_prompt: str, task: str, + max_turns: int) -> Awaitable[str]: ... + + +BRINGUP_SYSTEM_PROMPT = """\ +You are a benchmark bring-up assistant. You turn acquired materials (a research repo, a +dataset) into a runnable Arbor benchmark in the current directory. You write the *measurement +plumbing and a working baseline* — never an optimized solution. + +Produce, in the current directory: + * a runnable eval: `bash eval.sh dev|test` (or `python eval.py --split dev|test`) prints + exactly one line `score: `, after a correctness check. dev and test must use + DISJOINT data (the held-out split). + * the editable baseline (e.g. `solution.py`) — the simplest correct reference, the thing + Arbor will later optimize. Do NOT optimize it. + * `README.md` — plain language: the task, the metric (and whether higher/lower is better), + which file(s) Arbor may edit, and how dev/test differ. + * `PROVENANCE.md` — for humans: Source, Setup & environment, Baseline, Contamination + assessment, Caveats. + +The "baseline" is the *starting point Arbor will optimize*, NOT a SOTA method. It may come +from three places — follow the baseline plan you are given, and use `AskUser` if it is unclear: + * harvest — take a simple runnable baseline already in the repo (direct generation, + naive RAG, an earlier system) rather than the repo's headline method; + * implement — write a baseline to the user's described method/instruction (you are given it + below as the user's request); + * web — find an existing baseline implementation online and adapt it. + +Use the acquired source materials at the path you are told. Install dependencies as needed. +You are DONE when the four artifacts exist, `arbor benchmark verify .` would pass, and the +eval is *runnable*. Do NOT block on actually running it to completion: a real run may need a +served model, an API key, or a search key the user has not set up. If you can run the eval +cheaply (e.g. CPU-only), do so to sanity-check it; otherwise make it runnable, document the +exact setup needed in README + PROVENANCE, and stop — leaving a runnable draft is success. +If you are blocked on something you cannot resolve, write what you have and explain it clearly. +""" + + +@dataclass +class BringupResult: + """Outcome of a bring-up run.""" + + transcript: str = "" + dev_score: float | None = None + ran: bool = False # the eval actually ran and produced a score + verify: list[VerifyResult] = field(default_factory=list) + ok: bool = False + notes: list[str] = field(default_factory=list) + + +def _parse_score(text: str) -> float | None: + # Mirrors arbor.mcp.session_ops.parse_score for the documented `score: ` line, + # kept local so arbor.zoo stays dependency-light. + import re + matches = re.findall(r"\bscore\s*[:=]\s*([-+]?\d+(?:\.\d+)?)", text, re.I) + return float(matches[-1]) if matches else None + + +def _run_eval_dev(pack_dir: Path, timeout: int) -> tuple[float | None, str]: + """Run the eval on the dev split and parse a score (the bring-up success check).""" + entry = find_eval_entrypoint(pack_dir) + if entry == "eval.sh": + cmd = ["bash", str(pack_dir / "eval.sh"), "dev"] + elif entry == "eval.py": + cmd = [os.environ.get("PYTHON", "python3"), str(pack_dir / "eval.py"), "--split", "dev"] + else: + return None, "no eval entrypoint" + try: + proc = subprocess.run(cmd, cwd=str(pack_dir), capture_output=True, text=True, + timeout=timeout) + except subprocess.TimeoutExpired: + return None, f"eval timed out after {timeout}s" + out = proc.stdout + proc.stderr + return _parse_score(out), out[-2000:] + + +async def bringup( + pack_dir: Path, + *, + run_agent: AgentRunner, + materials_dir: Path | None = None, + instruction: str = "", + baseline_plan: dict[str, Any] | None = None, + max_turns: int = 40, + eval_timeout: int = 600, +) -> BringupResult: + """Run the bring-up agent in *pack_dir*, then check its work. + + *run_agent* does the actual agent work (real: an :class:`Agent`; in tests: a fake that + writes the files). *materials_dir* is the acquired source the agent should draw from. + *instruction* is the user's original natural-language request (so a baseline can be + *implemented* to their described method), and *baseline_plan* records where the baseline + should come from (harvest / implement / web). + + Success is a **runnable draft**: the artifacts are present and the structural verify + passes. Actually running the eval is best-effort — a real run may need a served model / + API key the user hasn't set up — so a non-running eval is noted, not a failure. + """ + result = BringupResult() + parts = [f"Bring up the benchmark in this directory ({pack_dir}). Produce a runnable " + f"baseline and an eval that prints a `score:` line on dev and test, plus " + f"README.md + PROVENANCE.md."] + if materials_dir: + parts.append(f"The acquired source materials are at: {materials_dir}") + if instruction: + parts.append(f"The user's original request (use it to shape/implement the baseline):\n" + f"{instruction}") + if baseline_plan: + src = baseline_plan.get("source", "?") + detail = baseline_plan.get("detail", "") + parts.append(f"Baseline plan — source={src}: {detail}") + task = "\n\n".join(parts) + try: + result.transcript = await run_agent( + cwd=pack_dir, system_prompt=BRINGUP_SYSTEM_PROMPT, task=task, max_turns=max_turns) + except Exception as exc: # noqa: BLE001 — surface agent/provider errors as a blocker + result.notes.append(f"agent run failed: {exc}") + return result + + # ── success check: the pack verifies (a runnable draft); running the eval is best-effort ── + result.dev_score, eval_out = _run_eval_dev(pack_dir, eval_timeout) + result.ran = result.dev_score is not None + if not result.ran: + result.notes.append( + "eval did not produce a score here — left as a runnable draft (a real run may " + f"need a served model / API key):\n{eval_out}") + result.verify = verify_pack(pack_dir) + verify_ok = not any(r.status == "fail" for r in result.verify) + result.ok = verify_ok + if not verify_ok: + result.notes.append("structural verify still has failures") + return result + + +# ── Stage 0: discovery (natural-language query → a chosen benchmark source) ── + +DISCOVERY_SYSTEM_PROMPT = """\ +You are a benchmark discovery assistant. Given a natural-language request, you search the +web for a benchmark that fits, judge the candidates, and pick the single best one. + +Use the search and page-fetch tools to look across GitHub, HuggingFace, arXiv / +PapersWithCode, and leaderboards. For each candidate, judge: + * does it ship a runnable eval and a baseline (not just a dataset)? + * does the task have headroom to optimize (an artifact Arbor can edit), not just measure + a frozen model? + * compute fit and license — can it be cloned and run, and is it redistributable? + * is it a representative / actively-used benchmark for the request? + +Prefer a GitHub repo that already contains an eval + baseline. **Be efficient: as soon as +you have identified one suitable repo and can name its benchmark(s) and baseline(s) — usually +after reading the paper and the repo README — STOP and output your choice. Do not +exhaustively clone and grep.** + +The "baseline" is the *starting point Arbor will optimize*, NOT the repo's headline method. +A repo that proposes a method usually also ships simpler baselines (direct generation, naive +RAG, an earlier system); for Arbor those simpler baselines are the harvestable ones, because +they leave headroom to optimize. Name a concrete baseline implementation (a runnable script), +not a published number. When which one to treat as the baseline is genuinely the user's call +and an `AskUser` tool is available, ask them rather than guessing. + +The request may name a *work* that uses several datasets/benchmarks (e.g. "get me the +datasets in WebThinker"). In that case enumerate the datasets it uses and, if an `AskUser` +tool is available, ask the user **which single dataset** they want — then resolve that one. +Also decide where the baseline will come from and record it as `baseline_plan.source`: + * "harvest" — a runnable baseline script already in the repo, + * "implement" — write one to the user's described method/instruction (e.g. "design xxx"), + * "web" — find an existing implementation online. +Ask the user (AskUser) when the baseline source is genuinely their call. + +End your reply with a single fenced JSON block describing your choice (and nothing after it): + +```json +{ + "name": "short_kebab_name", + "source": {"kind": "git", "url": "https://github.com/owner/repo"}, + "metric": "what is optimized, and whether higher/lower is better", + "baseline": "where/what the harvestable baseline is", + "baseline_plan": {"source": "harvest", "detail": "which script / method / search to use"}, + "why": "one or two sentences on why this fits the request" +} +``` + +If nothing suitable is found, set "source" to null and explain in "why". +""" + + +@dataclass +class DiscoveryResult: + """Outcome of a discovery run: the chosen benchmark source (or none).""" + + choice: dict[str, Any] | None = None # {name, source:{kind,url}, metric, baseline, why} + transcript: str = "" + ok: bool = False + notes: list[str] = field(default_factory=list) + + @property + def url(self) -> str | None: + src = (self.choice or {}).get("source") or {} + return src.get("url") if isinstance(src, dict) else None + + @property + def name(self) -> str | None: + return (self.choice or {}).get("name") + + @property + def baseline_plan(self) -> dict[str, Any]: + """How/where the baseline should come from: {source: harvest|implement|web, detail}.""" + plan = (self.choice or {}).get("baseline_plan") + return plan if isinstance(plan, dict) else {} + + +def _extract_json(text: str) -> dict[str, Any] | None: + """Pull the last JSON object out of the agent's reply (a ```json fenced block, or a + bare top-level object).""" + blocks = re.findall(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.DOTALL) + if not blocks: + blocks = re.findall(r"(\{(?:[^{}]|\{[^{}]*\})*\})", text, re.DOTALL) + for block in reversed(blocks): + try: + obj = json.loads(block) + except json.JSONDecodeError: + continue + if isinstance(obj, dict) and "source" in obj: + return obj + return None + + +async def discover( + query: str, + *, + run_agent: AgentRunner, + work_dir: Path, + max_turns: int = 100, +) -> DiscoveryResult: + """Run the discovery agent on a natural-language *query*; return the chosen source. + + *run_agent* should be a search-enabled runner (``real_agent_runner(with_search=True)``); + *work_dir* is a scratch directory for the agent's tools. + """ + result = DiscoveryResult() + work_dir.mkdir(parents=True, exist_ok=True) + task = ( + f"Find a benchmark for this request and pick the single best one:\n\n{query}\n\n" + "Search across GitHub / HuggingFace / arXiv, judge the candidates, and end with the " + "JSON block described in your instructions." + ) + try: + result.transcript = await run_agent( + cwd=work_dir, system_prompt=DISCOVERY_SYSTEM_PROMPT, task=task, max_turns=max_turns) + except Exception as exc: # noqa: BLE001 — surface agent/provider errors + result.notes.append(f"agent run failed: {exc}") + return result + + choice = _extract_json(result.transcript) + if choice is None: + result.notes.append("no JSON choice found in the agent's reply") + return result + result.choice = choice + if not result.url: + result.notes.append(f"no source url chosen: {choice.get('why', '(no reason given)')}") + return result + result.ok = True + return result + + +def real_agent_runner( + *, + with_search: bool = False, + ask_user: bool = False, + provider: str | None = None, + model: str | None = None, + api_key: str | None = None, + base_url: str | None = None, +) -> AgentRunner: + """Build the real :class:`Agent`-backed runner. Needs a configured provider / API key. + + With ``with_search=True`` the agent also gets keyless web search + fetch tools + (alphaXiv + Jina) so it can browse for benchmarks. With ``ask_user=True`` it gets a + console-backed :class:`~arbor.zoo.ask_tool.ConsoleAskUserTool` so it can put a genuinely + human decision (e.g. which implementation is the baseline) to the user at the terminal — + only enable this when stdin is interactive. Heavy imports are deferred so importing + :mod:`arbor.zoo` stays light. + """ + async def _run(*, cwd: Path, system_prompt: str, task: str, max_turns: int) -> str: + from arbor.core import Agent, AgentConfig, create_provider + from arbor.core.tools import get_all_tools + + # Only pass provider fields that were set, so AgentConfig's defaults (which read + # the env / user config) apply when they're omitted. + llm_kw = {k: v for k, v in + {"provider": provider, "model": model, "api_key": api_key, + "base_url": base_url}.items() if v is not None} + cfg = AgentConfig(cwd=str(cwd), max_turns=max_turns, auto_git=False, **llm_kw) + prov = create_provider(cfg) + tools = list(get_all_tools(cwd=str(cwd), config=cfg)) + if ask_user: + from .ask_tool import ConsoleAskUserTool + tools.append(ConsoleAskUserTool(cwd=str(cwd))) + if with_search: + from arbor.coordinator.config import SearchConfig + from arbor.core.tools.web.factory import ( + build_web_search_tool, + build_web_visit_tool, + ) + sc = SearchConfig(builtin_backend="alphaxiv", visit_backend="jina") + for t in (build_web_search_tool(sc, cwd=str(cwd)), + build_web_visit_tool(sc, cwd=str(cwd))): + if t is not None: + tools.append(t) + agent = Agent(provider=prov, tools=tools, system_prompt=system_prompt, config=cfg) + return await agent.run(task) + + return _run diff --git a/src/zoo/ask_tool.py b/src/zoo/ask_tool.py new file mode 100644 index 0000000..54c80fb --- /dev/null +++ b/src/zoo/ask_tool.py @@ -0,0 +1,104 @@ +"""A console-backed AskUser tool for the collection agents. + +The discovery and bring-up agents (:mod:`arbor.zoo.agent_stages`) sometimes hit a +choice that is genuinely the *user's* to make — most often **which implementation to +treat as the baseline** when a repo ships both its own headline method and simpler +references (direct generation, naive RAG, an earlier system). Rather than guess, the +agent calls this tool and the question is put to the human at the terminal. + +This is deliberately *not* the Coordinator's :class:`~arbor.coordinator.tools.AskUserTool`: +that one talks to a live UI through an event bus (``IdeaTree``/``await_user_decision``) for +the async research loop. The collection flow runs in a plain CLI (``asyncio.run`` from a +Typer command, the user sitting at the prompt), so a direct console round-trip is the right +fit. The console read is injectable (``ask=``) so the tool is testable without real stdin, +and it is only added to the toolset when stdin is a TTY — in non-interactive runs the agent +never has it and so never stalls. +""" + +from __future__ import annotations + +import asyncio +from typing import Any, Callable + +from ..core.tools.base import Tool + +# A console reader: (question, options) -> the user's answer, or None if they declined / +# no input is available. Injectable so tests don't touch real stdin. +AskFn = Callable[[str, list[str]], "str | None"] + + +def _console_ask(question: str, options: list[str]) -> str | None: + """Default reader: print the question (and any options) and read one line of stdin.""" + import typer + + typer.secho("\n┃ the collection agent needs your input:", fg=typer.colors.MAGENTA, bold=True) + typer.secho(f"┃ {question}", fg=typer.colors.MAGENTA) + if options: + for i, opt in enumerate(options, 1): + typer.echo(f" {i}. {opt}") + typer.echo(" (type a number to pick one, or write your own answer)") + try: + raw = input("your answer> ").strip() + except (EOFError, KeyboardInterrupt): + return None + if not raw: + return None + if options and raw.isdigit(): + idx = int(raw) + if 1 <= idx <= len(options): + return options[idx - 1] + return raw + + +class ConsoleAskUserTool(Tool): + """Ask the human at the terminal for a decision, then return their answer.""" + + name = "AskUser" + description = ( + "Ask the human operator a question and wait for their answer at the terminal.\n\n" + "Use this ONLY for a choice that is genuinely the user's to make and that you " + "cannot settle from the repo, the paper, or your tools. The most important case: " + "when a repo ships both its own proposed method (the SOTA system) and simpler " + "baselines (direct generation, naive RAG, an earlier system), ask which one to " + "treat as the baseline — the baseline is the starting point Arbor will optimize, " + "not necessarily the repo's headline method. Do NOT use this for routine progress " + "updates or decisions you can make yourself.\n\n" + "If no answer comes back, you are told to proceed on your best assumption — never " + "block waiting on a reply, and never ask the same thing twice." + ) + input_schema: dict[str, Any] = { + "type": "object", + "properties": { + "question": { + "type": "string", + "description": "The question to ask (be specific and self-contained).", + }, + "options": { + "type": "array", + "items": {"type": "string"}, + "description": "Optional suggested choices. Omit for a free-form answer.", + }, + }, + "required": ["question"], + } + # Not read-only: serializing this in the agent loop keeps two questions from being + # put to the human at once. The collection agents run with auto_git off, so no commit. + is_read_only = False + + def __init__(self, *, cwd: str, workspace_dir: str | None = None, ask: AskFn | None = None): + super().__init__(cwd=cwd, workspace_dir=workspace_dir) + self._ask: AskFn = ask or _console_ask + + async def execute(self, **kwargs: Any) -> str: + question = (kwargs.get("question") or "").strip() + if not question: + return "Error: 'question' is required." + options = [str(o) for o in (kwargs.get("options") or [])] + # The reader blocks on stdin; run it off the event loop. + answer = await asyncio.to_thread(self._ask, question, options) + if not answer or not answer.strip(): + return ( + "No answer was provided. Proceed with your best assumption and state it " + "explicitly in your final output — do not ask this again." + ) + return f"User replied: {answer.strip()}" diff --git a/tests/test_zoo_ask_tool.py b/tests/test_zoo_ask_tool.py new file mode 100644 index 0000000..3a7d157 --- /dev/null +++ b/tests/test_zoo_ask_tool.py @@ -0,0 +1,63 @@ +"""Tests for the console-backed AskUser tool used by the collection agents. + +No LLM, no real stdin — the console reader is injected so the tool's contract (what it +returns to the agent for a given human answer) is exercised deterministically. +""" + +from __future__ import annotations + +import asyncio + +from arbor.zoo import ConsoleAskUserTool + + +def _run(tool: ConsoleAskUserTool, **kwargs) -> str: + return asyncio.run(tool.execute(**kwargs)) + + +def test_returns_user_answer() -> None: + tool = ConsoleAskUserTool(cwd=".", ask=lambda q, opts: "run_naive_rag.py") + assert _run(tool, question="which baseline?") == "User replied: run_naive_rag.py" + + +def test_passes_question_and_options_to_reader() -> None: + seen: dict = {} + + def ask(question: str, options: list[str]) -> str: + seen["q"], seen["opts"] = question, options + return options[0] + + tool = ConsoleAskUserTool(cwd=".", ask=ask) + out = _run(tool, question="pick the baseline", options=["a.py", "b.py"]) + assert seen == {"q": "pick the baseline", "opts": ["a.py", "b.py"]} + assert out == "User replied: a.py" + + +def test_no_answer_tells_agent_to_proceed() -> None: + # User declined / EOF / non-interactive: the reader returns None. + tool = ConsoleAskUserTool(cwd=".", ask=lambda q, opts: None) + out = _run(tool, question="which baseline?") + assert "best assumption" in out and "do not ask this again" in out + + +def test_blank_answer_treated_as_no_answer() -> None: + tool = ConsoleAskUserTool(cwd=".", ask=lambda q, opts: " ") + assert "best assumption" in _run(tool, question="which baseline?") + + +def test_missing_question_errors() -> None: + tool = ConsoleAskUserTool(cwd=".", ask=lambda q, opts: "x") + assert _run(tool, question=" ").startswith("Error:") + + +def test_answer_is_stripped() -> None: + tool = ConsoleAskUserTool(cwd=".", ask=lambda q, opts: " run_direct_gen.py \n") + assert _run(tool, question="which?") == "User replied: run_direct_gen.py" + + +def test_tool_schema_shape() -> None: + tool = ConsoleAskUserTool(cwd=".") + schema = tool.to_api_schema() + assert schema["name"] == "AskUser" + assert schema["input_schema"]["required"] == ["question"] + assert tool.is_read_only is False diff --git a/tests/test_zoo_bringup.py b/tests/test_zoo_bringup.py new file mode 100644 index 0000000..178c75f --- /dev/null +++ b/tests/test_zoo_bringup.py @@ -0,0 +1,111 @@ +"""Tests for the bring-up agent stage (``arbor.zoo.agent_stages``). + +The agent run is injected, so the orchestration (agent writes files → eval runs → score +parsed → verify) is exercised without a live LLM. A fake runner stands in for the agent. +""" + +from __future__ import annotations + +import asyncio +from pathlib import Path + +from arbor.zoo import bringup, real_agent_runner + +_PROVENANCE = ( + "# Provenance\n\n## Source\nx\n## Setup & environment\nx\n## Baseline\nx\n" + "## Contamination assessment\nx\n## Caveats\nx\n" +) +_README = "# demo\n\nA demo benchmark.\n\n## The task\nx\n## Metric\nx\n" + + +def _fake_runner(files: dict[str, str]): + """A stand-in agent: writes the files a real bring-up agent would, returns a transcript.""" + async def _run(*, cwd: Path, system_prompt: str, task: str, max_turns: int) -> str: + for rel, content in files.items(): + p = cwd / rel + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(content) + return "bring-up done" + return _run + + +def _good_files(score: str = "1.0") -> dict[str, str]: + return { + "eval.sh": f"#!/usr/bin/env bash\necho 'score: {score}'\n", + "solution.py": "# baseline\n", + "README.md": _README, + "PROVENANCE.md": _PROVENANCE, + } + + +def test_bringup_success(tmp_path: Path) -> None: + pack = tmp_path / "p" + pack.mkdir() + res = asyncio.run(bringup(pack, run_agent=_fake_runner(_good_files("0.5")))) + assert res.dev_score == 0.5 + assert res.ran + assert res.ok + assert not [r for r in res.verify if r.status == "fail"] + assert res.transcript == "bring-up done" + + +def test_bringup_no_score_still_drafts(tmp_path: Path) -> None: + # A non-running eval is a runnable draft, not a failure (we don't force-run): ok stays + # True (artifacts verify), but ran is False and a note explains why. + pack = tmp_path / "p" + pack.mkdir() + files = _good_files() + files["eval.sh"] = "#!/usr/bin/env bash\necho 'nothing here'\n" + res = asyncio.run(bringup(pack, run_agent=_fake_runner(files))) + assert res.dev_score is None + assert not res.ran + assert res.ok + assert any("runnable draft" in n for n in res.notes) + + +def test_bringup_failed_verify_is_incomplete(tmp_path: Path) -> None: + pack = tmp_path / "p" + pack.mkdir() + files = _good_files() + del files["PROVENANCE.md"] # missing PROVENANCE → verify fails + res = asyncio.run(bringup(pack, run_agent=_fake_runner(files))) + assert not res.ok + assert any(r.status == "fail" for r in res.verify) + + +def test_bringup_threads_instruction_and_plan(tmp_path: Path) -> None: + # The user's request and the baseline plan must reach the agent's task text. + pack = tmp_path / "p" + pack.mkdir() + seen: dict = {} + + async def _spy(*, cwd: Path, system_prompt: str, task: str, max_turns: int) -> str: + seen["task"] = task + for rel, content in _good_files().items(): + (cwd / rel).write_text(content) + return "ok" + + asyncio.run(bringup( + pack, run_agent=_spy, + instruction="climb GPQA, design a self-consistency method", + baseline_plan={"source": "implement", "detail": "self-consistency over 5 samples"}, + )) + assert "self-consistency method" in seen["task"] + assert "implement" in seen["task"] and "5 samples" in seen["task"] + + +def test_bringup_surfaces_agent_error(tmp_path: Path) -> None: + pack = tmp_path / "p" + pack.mkdir() + + async def _boom(*, cwd, system_prompt, task, max_turns): + raise RuntimeError("provider exploded") + + res = asyncio.run(bringup(pack, run_agent=_boom)) + assert not res.ok + assert any("agent run failed" in n for n in res.notes) + + +def test_real_agent_runner_is_callable() -> None: + # Construction is cheap and import-light; running it needs a live provider. + assert callable(real_agent_runner()) diff --git a/tests/test_zoo_collect.py b/tests/test_zoo_collect.py index 7aa7173..2e38a6b 100644 --- a/tests/test_zoo_collect.py +++ b/tests/test_zoo_collect.py @@ -133,6 +133,51 @@ def test_cli_add(cache: Path, tmp_path: Path) -> None: assert "still to do" in result.output -def test_cli_add_rejects_bad_spec(tmp_path: Path) -> None: - result = CliRunner().invoke(app, ["benchmark", "add", "!!!", "--name", "x"]) +def test_cli_add_url_without_name_errors(cache: Path, tmp_path: Path) -> None: + # A URL/path spec skips discovery; without --name (and none to infer) it errors. + repo = _make_repo(tmp_path) + result = CliRunner().invoke(app, ["benchmark", "add", str(repo)]) assert result.exit_code == 2 + + +def test_cli_add_query_path_brings_up(cache: Path, tmp_path: Path, monkeypatch) -> None: + # A natural-language request: discovery + bring-up are faked (no LLM/network), but the + # spine in between (acquire + scaffold) runs for real, and the original request + + # baseline plan must be threaded into bring-up. + from arbor.cli.commands import benchmark_cmd + from arbor.zoo import BringupResult, DiscoveryResult + + repo = _make_repo(tmp_path) + zoo = tmp_path / "zoo" + captured: dict = {} + + async def fake_discover(query, *, run_agent, work_dir, max_turns): + captured["query"] = query + return DiscoveryResult( + choice={"name": "demo", "source": {"kind": "git", "url": str(repo)}, + "metric": "accuracy, higher better", "baseline": "naive rag", + "baseline_plan": {"source": "implement", "detail": "naive rag baseline"}, + "why": "fits"}, + ok=True, + ) + + async def fake_bringup(pack_dir, *, run_agent, materials_dir=None, instruction="", + baseline_plan=None, max_turns=40, eval_timeout=600): + captured["instruction"] = instruction + captured["plan"] = baseline_plan + return BringupResult(ok=True, ran=False, notes=["runnable draft ready"]) + + monkeypatch.setattr(benchmark_cmd, "discover", fake_discover) + monkeypatch.setattr(benchmark_cmd, "bringup", fake_bringup) + + result = CliRunner().invoke( + app, ["benchmark", "add", "get me the WebThinker GPQA benchmark", "--yes", + "--dest", str(zoo)] + ) + assert result.exit_code == 0, result.output + assert (zoo / "demo" / "README.md").exists() + assert captured["query"] == "get me the WebThinker GPQA benchmark" + # the user's original words reach bring-up (so a baseline can be implemented to them) + assert captured["instruction"] == "get me the WebThinker GPQA benchmark" + assert captured["plan"] == {"source": "implement", "detail": "naive rag baseline"} + assert "runnable draft ready" in result.output diff --git a/tests/test_zoo_discovery.py b/tests/test_zoo_discovery.py new file mode 100644 index 0000000..a78dccc --- /dev/null +++ b/tests/test_zoo_discovery.py @@ -0,0 +1,81 @@ +"""Tests for the discovery agent stage (``arbor.zoo.agent_stages.discover``). + +The agent run is injected, so the orchestration (query → agent → parse the chosen source) +is exercised without a live LLM or live web search. +""" + +from __future__ import annotations + +import asyncio +from pathlib import Path + +from arbor.zoo import discover + + +def _fake_runner(reply: str): + async def _run(*, cwd: Path, system_prompt: str, task: str, max_turns: int) -> str: + return reply + return _run + + +_CHOICE = ( + "I searched GitHub and arXiv. KernelBench fits best.\n\n" + '```json\n' + '{"name": "kernelbench", "source": {"kind": "git", ' + '"url": "https://github.com/ScalingIntelligence/KernelBench"}, ' + '"metric": "speedup, higher is better", "baseline": "torch reference in repo", ' + '"why": "ships an eval + baseline, real headroom"}\n' + '```\n' +) + + +def test_discover_picks_a_source(tmp_path: Path) -> None: + res = asyncio.run(discover("a GPU kernel optimization benchmark", + run_agent=_fake_runner(_CHOICE), work_dir=tmp_path / "w")) + assert res.ok + assert res.url == "https://github.com/ScalingIntelligence/KernelBench" + assert res.name == "kernelbench" + assert res.choice and res.choice["metric"].startswith("speedup") + + +def test_discover_no_json_is_not_ok(tmp_path: Path) -> None: + res = asyncio.run(discover("something", run_agent=_fake_runner("I couldn't find anything useful."), + work_dir=tmp_path / "w")) + assert not res.ok and res.url is None + assert any("no JSON" in n for n in res.notes) + + +def test_discover_captures_baseline_plan(tmp_path: Path) -> None: + reply = ( + '```json\n' + '{"name": "gpqa", "source": {"kind": "hf", "url": "Idavidrein/gpqa"}, ' + '"metric": "accuracy, higher is better", "baseline": "naive RAG", ' + '"baseline_plan": {"source": "implement", "detail": "write a naive RAG baseline"}, ' + '"why": "user wants to climb GPQA"}\n```' + ) + res = asyncio.run(discover("climb GPQA with a naive RAG baseline", + run_agent=_fake_runner(reply), work_dir=tmp_path / "w")) + assert res.ok + assert res.baseline_plan == {"source": "implement", "detail": "write a naive RAG baseline"} + + +def test_discover_baseline_plan_defaults_empty(tmp_path: Path) -> None: + # The KernelBench reply has no baseline_plan → property is an empty dict, not None. + res = asyncio.run(discover("a kernel benchmark", + run_agent=_fake_runner(_CHOICE), work_dir=tmp_path / "w")) + assert res.baseline_plan == {} + + +def test_discover_null_source_is_not_ok(tmp_path: Path) -> None: + reply = '```json\n{"name": null, "source": null, "why": "nothing suitable found"}\n```' + res = asyncio.run(discover("obscure", run_agent=_fake_runner(reply), work_dir=tmp_path / "w")) + assert not res.ok + assert any("no source url" in n for n in res.notes) + + +def test_discover_surfaces_agent_error(tmp_path: Path) -> None: + async def _boom(*, cwd, system_prompt, task, max_turns): + raise RuntimeError("provider missing") + + res = asyncio.run(discover("x", run_agent=_boom, work_dir=tmp_path / "w")) + assert not res.ok and any("agent run failed" in n for n in res.notes)