From 6bf007fe37c7f578eadc8dff99b4e3b5d615492a Mon Sep 17 00:00:00 2001 From: hanhainebula <2512674094@qq.com> Date: Sat, 31 Oct 2026 03:15:33 +0800 Subject: [PATCH] Simplify evaluation workflows with CLI and agent skill Remove hardware-bound deletion and multivector tasks, and automate bounded storage setup for the retained retrieval tasks. Package the interactive CLI with model search, configuration, resource-aware batches and logs. Add an installable evaluation skill and align documentation, dependencies and CI. --- .agents/AGENTS.md | 5 +- .../references/validation.md | 9 +- .../references/publication.md | 2 +- .../references/task-audit.md | 5 +- .env.example | 13 +- .github/workflows/task-checks.yml | 2 +- .gitignore | 3 + MANIFEST.in | 3 + README.md | 211 ++------ README_zh.md | 191 ++------ docs/agent-evaluations.md | 90 ++++ docs/assets.md | 8 +- docs/benchmark.md | 4 +- docs/cli.md | 179 +++++++ docs/contributing.md | 2 +- docs/evaluation.md | 66 ++- docs/quickstart.md | 142 ++---- pyproject.toml | 37 ++ scripts/cpu_affinity.py | 35 ++ scripts/download_assets.py | 2 +- scripts/harbor_environments.py | 18 +- scripts/requirements.txt | 4 - scripts/run_task.py | 65 ++- scripts/task_storage.py | 215 +++++++++ scripts/tests/test_cli.py | 235 +++++++++ scripts/tests/test_cpu_affinity.py | 37 ++ scripts/tests/test_model_catalog.py | 145 ++++++ scripts/tests/test_openrouter_agents.py | 10 + scripts/tests/test_task_storage.py | 220 +++++++++ skills/run-searchswe-evaluation/SKILL.md | 132 +++++ .../agents/openai.yaml | 4 + src/search_swe/__init__.py | 3 + src/search_swe/__main__.py | 3 + src/search_swe/cli.py | 168 +++++++ src/search_swe/configuration.py | 130 +++++ src/search_swe/model_catalog.py | 114 +++++ src/search_swe/repository.py | 84 ++++ src/search_swe/resources.py | 123 +++++ src/search_swe/runner.py | 265 ++++++++++ src/search_swe/wizard.py | 152 ++++++ tasks/budgeted-bimetric-retrieval/README.md | 25 +- .../.gitignore | 4 - .../README.md | 110 ----- .../assets.json | 27 -- .../environment/.dockerignore | 3 - .../environment/Dockerfile | 24 - .../environment/docker-compose.yaml | 49 -- .../environment/docs/available_resources.md | 12 - .../environment/docs/environment.md | 41 -- .../environment/tools/check_submission.py | 93 ---- .../environment/tools/reference_server.py | 89 ---- .../environment/tools/runtime_limits.py | 442 ----------------- .../instruction.md | 19 - .../raw-instruction.md | 196 -------- .../task.toml | 46 -- .../tests/.dockerignore | 3 - .../tests/Dockerfile | 37 -- .../tests/docker-compose.yaml | 30 -- .../tests/finalize.py | 23 - .../tests/grader.py | 154 ------ .../tests/integrity/codex.toml | 45 -- .../tests/report.py | 55 --- .../tests/rewardkit_deepseek.py | 138 ------ .../tests/runtime.py | 258 ---------- .../tests/runtime_limits.py | 456 ------------------ .../tests/scoring.py | 77 --- .../tests/storage.py | 123 ----- .../tests/test.sh | 53 -- .../tests/test_cleanup.py | 52 -- .../tests/test_finalize.py | 40 -- .../tests/test_runtime.py | 136 ------ .../tests/test_scoring.py | 90 ---- .../tests/test_storage.py | 26 - .../tools/prepare_inputs.py | 112 ----- .../tools/prepare_storage.py | 147 ------ .../tools/prepare_truth.py | 86 ---- tasks/large-scale-formula-retrieval/README.md | 29 +- .../.gitignore | 5 - .../README.md | 119 ----- .../assets.json | 218 --------- .../environment/.dockerignore | 3 - .../environment/Dockerfile | 28 -- .../environment/budget_evidence.py | 69 --- .../environment/docker-compose.yaml | 56 --- .../environment/docs/available_resources.md | 11 - .../environment/docs/environment.md | 20 - .../environment/tools/baseline/README.md | 42 -- .../environment/tools/baseline/build_index.py | 128 ----- .../environment/tools/baseline/collect.cpp | 30 -- .../environment/tools/baseline/kernel.cpp | 64 --- .../environment/tools/baseline/run.sh | 5 - .../environment/tools/baseline/serve.py | 90 ---- .../environment/tools/check_submission.py | 63 --- .../environment/tools/exact_search.py | 73 --- .../environment/tools/maxsim.cpp | 67 --- .../environment/tools/runtime_limits.py | 17 - .../instruction.md | 19 - .../model-metadata/encoding.json | 101 ---- .../raw-instruction.md | 106 ---- .../task.toml | 57 --- .../tests/.dockerignore | 5 - .../tests/Dockerfile | 29 -- .../tests/bundled-assets/README.md | 11 - .../data/verifier/test/config.json | 10 - .../data/verifier/test/ids.json | 1 - .../data/verifier/test/offsets.i64 | Bin 80008 -> 0 bytes .../data/verifier/test/scores.f32 | Bin 4000000 -> 0 bytes .../data/verifier/test/truth.i32 | Bin 4000000 -> 0 bytes .../tests/development.py | 19 - .../tests/docker-compose.yaml | 31 -- .../tests/grader.py | 204 -------- .../tests/integrity_gate.py | 141 ------ .../tests/integrity_judge/codex.toml | 43 -- .../tests/reference/baseline/README.md | 42 -- .../tests/reference/baseline/build_index.py | 128 ----- .../tests/reference/baseline/collect.cpp | 30 -- .../tests/reference/baseline/kernel.cpp | 64 --- .../tests/reference/baseline/run.sh | 5 - .../tests/reference/baseline/serve.py | 90 ---- .../tests/reference/check_submission.py | 63 --- .../tests/reference/exact_search.py | 73 --- .../tests/reference/maxsim.cpp | 67 --- .../tests/rewardkit_deepseek.py | 138 ------ .../tests/runtime.py | 257 ---------- .../tests/runtime_limits.py | 49 -- .../tests/scoring.py | 50 -- .../tests/storage.py | 55 --- .../tests/test.sh | 50 -- .../tests/test_development.py | 32 -- .../tests/test_exact.py | 77 --- .../tests/test_integrity_gate.py | 174 ------- .../tests/test_runtime.py | 133 ----- .../tests/test_scoring.py | 56 --- .../tools/prepare_storage.py | 148 ------ 134 files changed, 2702 insertions(+), 7450 deletions(-) create mode 100644 MANIFEST.in create mode 100644 docs/agent-evaluations.md create mode 100644 docs/cli.md create mode 100644 pyproject.toml create mode 100644 scripts/cpu_affinity.py delete mode 100644 scripts/requirements.txt create mode 100644 scripts/task_storage.py create mode 100644 scripts/tests/test_cli.py create mode 100644 scripts/tests/test_cpu_affinity.py create mode 100644 scripts/tests/test_model_catalog.py create mode 100644 scripts/tests/test_task_storage.py create mode 100644 skills/run-searchswe-evaluation/SKILL.md create mode 100644 skills/run-searchswe-evaluation/agents/openai.yaml create mode 100644 src/search_swe/__init__.py create mode 100644 src/search_swe/__main__.py create mode 100644 src/search_swe/cli.py create mode 100644 src/search_swe/configuration.py create mode 100644 src/search_swe/model_catalog.py create mode 100644 src/search_swe/repository.py create mode 100644 src/search_swe/resources.py create mode 100644 src/search_swe/runner.py create mode 100644 src/search_swe/wizard.py delete mode 100644 tasks/deletion-resilient-vector-search/.gitignore delete mode 100644 tasks/deletion-resilient-vector-search/README.md delete mode 100644 tasks/deletion-resilient-vector-search/assets.json delete mode 100644 tasks/deletion-resilient-vector-search/environment/.dockerignore delete mode 100644 tasks/deletion-resilient-vector-search/environment/Dockerfile delete mode 100644 tasks/deletion-resilient-vector-search/environment/docker-compose.yaml delete mode 100644 tasks/deletion-resilient-vector-search/environment/docs/available_resources.md delete mode 100644 tasks/deletion-resilient-vector-search/environment/docs/environment.md delete mode 100755 tasks/deletion-resilient-vector-search/environment/tools/check_submission.py delete mode 100755 tasks/deletion-resilient-vector-search/environment/tools/reference_server.py delete mode 100644 tasks/deletion-resilient-vector-search/environment/tools/runtime_limits.py delete mode 100644 tasks/deletion-resilient-vector-search/instruction.md delete mode 100644 tasks/deletion-resilient-vector-search/raw-instruction.md delete mode 100644 tasks/deletion-resilient-vector-search/task.toml delete mode 100644 tasks/deletion-resilient-vector-search/tests/.dockerignore delete mode 100644 tasks/deletion-resilient-vector-search/tests/Dockerfile delete mode 100644 tasks/deletion-resilient-vector-search/tests/docker-compose.yaml delete mode 100644 tasks/deletion-resilient-vector-search/tests/finalize.py delete mode 100644 tasks/deletion-resilient-vector-search/tests/grader.py delete mode 100644 tasks/deletion-resilient-vector-search/tests/integrity/codex.toml delete mode 100644 tasks/deletion-resilient-vector-search/tests/report.py delete mode 100644 tasks/deletion-resilient-vector-search/tests/rewardkit_deepseek.py delete mode 100644 tasks/deletion-resilient-vector-search/tests/runtime.py delete mode 100644 tasks/deletion-resilient-vector-search/tests/runtime_limits.py delete mode 100644 tasks/deletion-resilient-vector-search/tests/scoring.py delete mode 100644 tasks/deletion-resilient-vector-search/tests/storage.py delete mode 100755 tasks/deletion-resilient-vector-search/tests/test.sh delete mode 100644 tasks/deletion-resilient-vector-search/tests/test_cleanup.py delete mode 100644 tasks/deletion-resilient-vector-search/tests/test_finalize.py delete mode 100644 tasks/deletion-resilient-vector-search/tests/test_runtime.py delete mode 100644 tasks/deletion-resilient-vector-search/tests/test_scoring.py delete mode 100644 tasks/deletion-resilient-vector-search/tests/test_storage.py delete mode 100644 tasks/deletion-resilient-vector-search/tools/prepare_inputs.py delete mode 100644 tasks/deletion-resilient-vector-search/tools/prepare_storage.py delete mode 100644 tasks/deletion-resilient-vector-search/tools/prepare_truth.py delete mode 100644 tasks/resource-bounded-multivector-search/.gitignore delete mode 100644 tasks/resource-bounded-multivector-search/README.md delete mode 100644 tasks/resource-bounded-multivector-search/assets.json delete mode 100644 tasks/resource-bounded-multivector-search/environment/.dockerignore delete mode 100644 tasks/resource-bounded-multivector-search/environment/Dockerfile delete mode 100644 tasks/resource-bounded-multivector-search/environment/budget_evidence.py delete mode 100644 tasks/resource-bounded-multivector-search/environment/docker-compose.yaml delete mode 100644 tasks/resource-bounded-multivector-search/environment/docs/available_resources.md delete mode 100644 tasks/resource-bounded-multivector-search/environment/docs/environment.md delete mode 100644 tasks/resource-bounded-multivector-search/environment/tools/baseline/README.md delete mode 100644 tasks/resource-bounded-multivector-search/environment/tools/baseline/build_index.py delete mode 100644 tasks/resource-bounded-multivector-search/environment/tools/baseline/collect.cpp delete mode 100644 tasks/resource-bounded-multivector-search/environment/tools/baseline/kernel.cpp delete mode 100755 tasks/resource-bounded-multivector-search/environment/tools/baseline/run.sh delete mode 100644 tasks/resource-bounded-multivector-search/environment/tools/baseline/serve.py delete mode 100644 tasks/resource-bounded-multivector-search/environment/tools/check_submission.py delete mode 100644 tasks/resource-bounded-multivector-search/environment/tools/exact_search.py delete mode 100644 tasks/resource-bounded-multivector-search/environment/tools/maxsim.cpp delete mode 100644 tasks/resource-bounded-multivector-search/environment/tools/runtime_limits.py delete mode 100644 tasks/resource-bounded-multivector-search/instruction.md delete mode 100644 tasks/resource-bounded-multivector-search/model-metadata/encoding.json delete mode 100644 tasks/resource-bounded-multivector-search/raw-instruction.md delete mode 100644 tasks/resource-bounded-multivector-search/task.toml delete mode 100644 tasks/resource-bounded-multivector-search/tests/.dockerignore delete mode 100644 tasks/resource-bounded-multivector-search/tests/Dockerfile delete mode 100644 tasks/resource-bounded-multivector-search/tests/bundled-assets/README.md delete mode 100644 tasks/resource-bounded-multivector-search/tests/bundled-assets/data/verifier/test/config.json delete mode 100644 tasks/resource-bounded-multivector-search/tests/bundled-assets/data/verifier/test/ids.json delete mode 100644 tasks/resource-bounded-multivector-search/tests/bundled-assets/data/verifier/test/offsets.i64 delete mode 100644 tasks/resource-bounded-multivector-search/tests/bundled-assets/data/verifier/test/scores.f32 delete mode 100644 tasks/resource-bounded-multivector-search/tests/bundled-assets/data/verifier/test/truth.i32 delete mode 100644 tasks/resource-bounded-multivector-search/tests/development.py delete mode 100644 tasks/resource-bounded-multivector-search/tests/docker-compose.yaml delete mode 100644 tasks/resource-bounded-multivector-search/tests/grader.py delete mode 100644 tasks/resource-bounded-multivector-search/tests/integrity_gate.py delete mode 100644 tasks/resource-bounded-multivector-search/tests/integrity_judge/codex.toml delete mode 100644 tasks/resource-bounded-multivector-search/tests/reference/baseline/README.md delete mode 100644 tasks/resource-bounded-multivector-search/tests/reference/baseline/build_index.py delete mode 100644 tasks/resource-bounded-multivector-search/tests/reference/baseline/collect.cpp delete mode 100644 tasks/resource-bounded-multivector-search/tests/reference/baseline/kernel.cpp delete mode 100755 tasks/resource-bounded-multivector-search/tests/reference/baseline/run.sh delete mode 100644 tasks/resource-bounded-multivector-search/tests/reference/baseline/serve.py delete mode 100644 tasks/resource-bounded-multivector-search/tests/reference/check_submission.py delete mode 100644 tasks/resource-bounded-multivector-search/tests/reference/exact_search.py delete mode 100644 tasks/resource-bounded-multivector-search/tests/reference/maxsim.cpp delete mode 100644 tasks/resource-bounded-multivector-search/tests/rewardkit_deepseek.py delete mode 100644 tasks/resource-bounded-multivector-search/tests/runtime.py delete mode 100644 tasks/resource-bounded-multivector-search/tests/runtime_limits.py delete mode 100644 tasks/resource-bounded-multivector-search/tests/scoring.py delete mode 100644 tasks/resource-bounded-multivector-search/tests/storage.py delete mode 100755 tasks/resource-bounded-multivector-search/tests/test.sh delete mode 100644 tasks/resource-bounded-multivector-search/tests/test_development.py delete mode 100644 tasks/resource-bounded-multivector-search/tests/test_exact.py delete mode 100644 tasks/resource-bounded-multivector-search/tests/test_integrity_gate.py delete mode 100644 tasks/resource-bounded-multivector-search/tests/test_runtime.py delete mode 100644 tasks/resource-bounded-multivector-search/tests/test_scoring.py delete mode 100644 tasks/resource-bounded-multivector-search/tools/prepare_storage.py diff --git a/.agents/AGENTS.md b/.agents/AGENTS.md index a6c2175..6298f91 100644 --- a/.agents/AGENTS.md +++ b/.agents/AGENTS.md @@ -15,7 +15,10 @@ Markdown links. [maintain-searchswe-task](skills/maintain-searchswe-task/SKILL.md), independently invocable as `$maintain-searchswe-task`. Default to read-only gh evidence review; use its bundled offline HF staging helper only at approved stages. -- **Run an existing task:** use `docs/quickstart.md`; do not scaffold a task. +- **Run an existing task:** use + [run-searchswe-evaluation](../skills/run-searchswe-evaluation/SKILL.md), also + installable into an external agent. The CLI workflow is in `docs/quickstart.md` + and `docs/cli.md`; the single-task backend is in `docs/evaluation.md`. - **Restore fixed inputs:** use `docs/assets.md` and the existing download tools. - **Edit project documentation:** keep `README.md` and `README_zh.md` aligned. Describe the current contract and workflow directly; keep migration rationale diff --git a/.agents/skills/create-searchswe-task/references/validation.md b/.agents/skills/create-searchswe-task/references/validation.md index 090ee7e..739bc77 100644 --- a/.agents/skills/create-searchswe-task/references/validation.md +++ b/.agents/skills/create-searchswe-task/references/validation.md @@ -8,10 +8,11 @@ the checkout's tools; the skill does not bundle a second checker or runtime. Verify the explicit checkout contains `scripts/check_tasks.py`, `check_release.py`, `download_assets.py`, `run_task.py`/`run_task.sh`, and their -imports. Use Python 3.12+, host dependencies from `scripts/requirements.txt` -(Harbor 0.22.0 and huggingface_hub 1.x), PyYAML for static Compose parsing, and -python-dotenv for launcher configuration. Install only in an approved host -virtual environment. Do not replace missing target tooling with invented commands. +imports. Use Python 3.12+ and the host dependencies declared in `pyproject.toml`, +including Harbor, huggingface_hub, PyYAML for static Compose parsing, and +python-dotenv for launcher configuration. Install with `python -m pip install -e .` +from the checkout root in an approved host virtual environment. +Do not replace missing target tooling with invented commands. Docker Engine + Compose are needed for builds/trials, published image access for pulls, and NVIDIA driver/Container Toolkit for GPU execution. Check provider, model, credentials, network, disk and runtime budgets before running a trial. diff --git a/.agents/skills/maintain-searchswe-task/references/publication.md b/.agents/skills/maintain-searchswe-task/references/publication.md index 496603f..eb989e9 100644 --- a/.agents/skills/maintain-searchswe-task/references/publication.md +++ b/.agents/skills/maintain-searchswe-task/references/publication.md @@ -26,7 +26,7 @@ Before authenticated downloads, upload or HF PR merge, run `hf auth whoami` in the same host environment used for publication. Reuse an existing authorized login or `HF_TOKEN`; report only identity, authentication status and relevant access findings. If `hf` is missing, point the operator to the trusted checkout's -`scripts/requirements.txt` and host environment setup. +`pyproject.toml` and host setup: `python -m pip install -e .` from that checkout. If authentication is missing or expired, the coding agent must explicitly remind the maintainer to configure their own HF account locally and identify the step diff --git a/.agents/skills/maintain-searchswe-task/references/task-audit.md b/.agents/skills/maintain-searchswe-task/references/task-audit.md index 777a77e..2ce005b 100644 --- a/.agents/skills/maintain-searchswe-task/references/task-audit.md +++ b/.agents/skills/maintain-searchswe-task/references/task-audit.md @@ -27,8 +27,9 @@ launcher or Harbor runtime. Inspect an explicitly selected **trusted** target checkout/version providing `scripts/check_tasks.py`, `check_release.py`, `download_assets.py`, `run_task.py` and their dependencies. Missing tools or an older workflow version stop that layer; do not improvise acceptance commands. -Python 3.12+, PyYAML, python-dotenv and host `scripts/requirements.txt` (Harbor -0.22.0, huggingface_hub 1.x) are target prerequisites. Docker Engine/Compose, +Python 3.12+ and the host dependencies declared in `pyproject.toml` are target +prerequisites; install with `python -m pip install -e .` from the trusted checkout. +These include Harbor, huggingface_hub, PyYAML and python-dotenv. Docker Engine/Compose, image access and appropriate GPU driver/Container Toolkit are needed for runtime. Accept public upstream or official datasets with immutable pins, verified hashes diff --git a/.env.example b/.env.example index 9bfdffc..5bccd39 100644 --- a/.env.example +++ b/.env.example @@ -6,9 +6,14 @@ # --------------------------------------------------------------------------- # 1. Coding agent # --------------------------------------------------------------------------- -# Set the model here or pass --model to scripts/run_task.sh. -# Quick start: AGENT_MODEL=openai/gpt-6-luna with --agent codex --openrouter -# and --reasoning-effort max; configure AGENT_OPENROUTER_API_KEY below. +# Defaults used by search-swe run (the interactive CLI). +SEARCH_SWE_AGENT=codex +SEARCH_SWE_PROVIDER=openrouter +SEARCH_SWE_CONCURRENCY=1 + +# Use search-swe configure, set the model here, or pass --model. +# CLI default: Codex + OpenRouter + openai/gpt-6-luna with max reasoning. +# Configure AGENT_OPENROUTER_API_KEY below for that setting. AGENT_MODEL= # Pi agent: fill exactly the key matching the selected model. @@ -33,7 +38,7 @@ AGENT_ANTHROPIC_API_KEY= # OPENROUTER_API_KEY used by task submissions and ANSWER_JUDGE_API_KEY. AGENT_OPENROUTER_API_KEY= -# Optional Codex or Claude Code reasoning effort. +# Optional Codex or Claude Code reasoning effort; default omits the override. AGENT_REASONING_EFFORT= # Optional Codex-only settings. This is a TOML path, relative to the repository diff --git a/.github/workflows/task-checks.yml b/.github/workflows/task-checks.yml index 6512407..5a0ab57 100644 --- a/.github/workflows/task-checks.yml +++ b/.github/workflows/task-checks.yml @@ -19,7 +19,7 @@ jobs: - uses: actions/setup-python@v5 with: python-version: '3.12' - - run: python -m pip install -r scripts/requirements.txt PyYAML python-dotenv + - run: python -m pip install -e . - run: python scripts/check_release.py - run: python scripts/check_tasks.py - name: Check authors of new tasks diff --git a/.gitignore b/.gitignore index 89838f3..d1d2d0e 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,6 @@ tasks/*/jobs/ tasks/*/solution/ __pycache__/ *.py[cod] +*.egg-info/ +/build/ +/dist/ diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..e890bf4 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,3 @@ +include LICENSE docs/cli.md +recursive-include src/search_swe *.py +global-exclude *.pyc diff --git a/README.md b/README.md index 9cdf116..f1edd87 100644 --- a/README.md +++ b/README.md @@ -40,205 +40,90 @@ models come from. ## 🚀 Quick Start -This walkthrough runs `reasoning-query-rewriting` on CPU with the Codex coding -agent and GPT-6 Luna through OpenRouter. Search-SWE requires Python 3.12 -or newer and Docker with the CPU, memory, storage, and optional GPU capacity of -the task you select. - -### 1. Install the host tools +Install the `search-swe` CLI in a Python 3.12+ environment. The PyPI release +installation command is: ```bash -git clone https://github.com/VectorSpaceLab/Search-SWE.git -cd Search-SWE -python -m pip install -r scripts/requirements.txt +python -m pip install search-swe ``` -`scripts/requirements.txt` provides the pinned Harbor launcher and the Hugging -Face asset client. Run the command in any existing Python 3.12+ environment; -an isolated venv or Conda environment is recommended if you do not already use -one, but no particular environment manager is required. Task-specific Python -packages are installed inside Docker. - -### 2. Restore the task inputs +The package in this checkout is currently unpublished. To test this development +version, clone the repository and use an editable install: ```bash -python scripts/download_assets.py --task reasoning-query-rewriting -python scripts/download_assets.py --task reasoning-query-rewriting --verify-only +git clone https://github.com/VectorSpaceLab/Search-SWE.git +cd Search-SWE +python -m pip install -e . ``` -The downloader checks sizes and SHA-256 checksums and reuses valid files. - -### 3. Configure the agent and verifier - -The tracked template documents common credentials. Each task's README and -`task.toml` specify its additional requirements. Copy it once; put real secrets -only in the ignored `.env` file: +### Run with the CLI ```bash -cp .env.example .env -chmod 600 .env +search-swe run ``` -For this Codex + OpenRouter walkthrough, set these four values in `.env` and -leave unneeded groups empty: +The wizard guides you through harness, provider, searchable model selection, +reasoning level and API keys, followed by tasks and sequential or parallel execution. Its +default setting is **Codex + OpenRouter + GPT-6 Luna (max)**. Keys use hidden +input; configuration is saved to the ignored `.env` only after confirmation. +You can also keep entered credentials only for the current run. -```dotenv -AGENT_MODEL=openai/gpt-6-luna -AGENT_OPENROUTER_API_KEY=YOUR_AGENT_OPENROUTER_KEY -VERIFIER_OPENAI_BASE_URL=https://openrouter.ai/api/v1 -VERIFIER_OPENAI_API_KEY=YOUR_OPENROUTER_KEY -``` +Use a native Linux x86_64 Docker host with enough CPU, RAM, disk and optional GPU +capacity. The CLI restores and checks selected task assets, assigns resources, +and prepares bounded task storage when needed. Those tasks require root/sudo +and FUSE; see [host setup](docs/evaluation.md#bounded-task-storage). -`AGENT_OPENROUTER_API_KEY` authenticates the Codex coding agent through -OpenRouter. Use `--openrouter` in the commands below; the launcher supplies -the API endpoint and Codex provider configuration. -The `VERIFIER_*` pair runs the independent `deepseek/deepseek-v4.1-flash` trajectory judge -through OpenRouter and RewardKit 0.2.0. Configure this pair for tasks whose -`task.toml` declares `VERIFIER_OPENAI_*`. The coding agent and verifier have -separate credential groups; the launcher passes verifier credentials only to -the verifier container. This walkthrough does not need `DEEPSEEK_API_KEY`, -`AGENT_OPENAI_BASE_URL`, or `AGENT_OPENAI_API_KEY`. - -For other runs, fill only the matching sections already present in `.env`: - -- Pi + GLM-5.3-Flash: `AGENT_MODEL=zai/glm-5.3-flash` and `ZAI_API_KEY`. -- Codex with a direct endpoint, without `--openrouter`: `AGENT_MODEL`, - `AGENT_OPENAI_BASE_URL`, and `AGENT_OPENAI_API_KEY`. -- Claude Code: `AGENT_MODEL` and `AGENT_ANTHROPIC_API_KEY`; the launcher uses - Anthropic's official API by default. -- OpenRouter Agent mode: `AGENT_OPENROUTER_API_KEY`, `--openrouter`, and a full - `provider/model` slug with Codex or Claude Code. -- Tasks with an answer judge: configure the `ANSWER_JUDGE_*` values declared by the task. -- Optional submission APIs: use `REASONING_QUERY_REWRITING_OPENROUTER_API_KEY`, - `OPENROUTER_API_KEY`, or `JINA_API_KEY` only when the selected task's README - and resource documentation permit them. - -If a proxy is required, set `EGRESS_CONFIG` in `.env` following the -[network guide](docs/network-policy.md); otherwise leave it empty. - -The [evaluation guide](docs/evaluation.md) documents credential isolation, -custom endpoints, proxies, and how to read task-owned configuration. - -### 4. Run with Codex and GPT-6 Luna through OpenRouter +After configuration, scripted runs are short: ```bash -bash scripts/run_task.sh --task reasoning-query-rewriting --agent codex --openrouter \ - --reasoning-effort max --dry-run - -bash scripts/run_task.sh --task reasoning-query-rewriting --agent codex --openrouter \ - --reasoning-effort max \ - --output jobs/reasoning-query-rewriting-codex-openrouter-luna +search-swe run reasoning-query-rewriting --yes +search-swe run agentic-search agentic-search-vectorless --concurrency 2 --yes +search-swe run code-embedding-finetuning --gpu-devices 0 --yes ``` -The dry run only prints the Harbor command. The second command builds the task -images, runs the agent and the separate verifier, and writes the reward and job -records under `jobs/reasoning-query-rewriting-codex-openrouter-luna`. +Parallel tasks receive separate CPU sets and GPUs within the selected resource +pool. Results, per-task logs and a `run.json` summary are written to a fresh +`jobs/evaluations/` directory. Use `search-swe list` to browse tasks, +`search-swe configure` to save settings without running, and `--dry-run` to +preview. Task definitions remain in the checkout; use `--repo PATH` when running +an installed CLI from elsewhere. -
-Alternative agent examples +See the [quick start](docs/quickstart.md), [CLI reference](docs/cli.md), and +[full evaluation guide](docs/evaluation.md) for custom providers, resource pools, +network configuration and the underlying single-task launcher. -These examples reuse the downloaded task inputs and the `VERIFIER_*` pair above. -Configure only the coding-agent credential group for the option you choose. +### Run through your agent -#### Pi and Z.AI GLM-5.3-Flash - -In `.env`, change the agent model and fill its matching key. Keep the -`VERIFIER_*` OpenRouter judge settings because the RewardKit judge does not change: - -```dotenv -AGENT_MODEL=zai/glm-5.3-flash -ZAI_API_KEY=YOUR_ZAI_KEY -``` +Install [run-searchswe-evaluation](skills/run-searchswe-evaluation/SKILL.md) into +your coding agent, then tell it which settings and tasks to run. Once this skill +is available on GitHub, use either installer (Codex shown here): ```bash -bash scripts/run_task.sh --task reasoning-query-rewriting --agent pi \ - --thinking xhigh \ - --output jobs/reasoning-query-rewriting-pi-glm -``` - -#### Codex and a GPT model - -Set the model name accepted by your GPT-compatible endpoint and its credentials: - -```dotenv -AGENT_MODEL=YOUR_GPT_MODEL -AGENT_OPENAI_BASE_URL=https://your-agent-endpoint.example/v1 -AGENT_OPENAI_API_KEY=YOUR_AGENT_KEY +npx skills add VectorSpaceLab/Search-SWE --skill run-searchswe-evaluation -a codex ``` ```bash -bash scripts/run_task.sh --task reasoning-query-rewriting --agent codex \ - --reasoning-effort xhigh --output jobs/reasoning-query-rewriting-codex +gh skill install VectorSpaceLab/Search-SWE skills/run-searchswe-evaluation --agent codex ``` -Omit `--reasoning-effort` when the selected model or provider does not support -it. - -#### Claude Code and the official Anthropic API +Then ask your agent: -Claude Code 2.1.309 is preinstalled in every task image. Set an Anthropic model -available to your API account and the dedicated coding-agent key: - -```dotenv -AGENT_MODEL=claude-sonnet-4-6 -AGENT_ANTHROPIC_API_KEY=YOUR_ANTHROPIC_KEY -``` - -```bash -bash scripts/run_task.sh --task reasoning-query-rewriting --agent claude-code \ - --reasoning-effort high --output jobs/reasoning-query-rewriting-claude -``` - -#### Codex or Claude Code through OpenRouter - -Add a dedicated OpenRouter Agent key to `.env`: - -```dotenv -AGENT_OPENROUTER_API_KEY=YOUR_AGENT_OPENROUTER_KEY -``` - -Choose an Agent and pass its full OpenRouter model ID: - -```bash -bash scripts/run_task.sh --task reasoning-query-rewriting --agent codex --openrouter \ - --model openai/gpt-6-luna --reasoning-effort max \ - --output jobs/reasoning-query-rewriting-codex-openrouter - -bash scripts/run_task.sh --task reasoning-query-rewriting --agent claude-code --openrouter \ - --model anthropic/claude-opus-5.5 --reasoning-effort xhigh \ - --output jobs/reasoning-query-rewriting-claude-openrouter -``` - -Add `--dry-run` to either command to preview it before launching. The launcher -sets the OpenRouter API addresses and keeps this key separate from the -submission `OPENROUTER_API_KEY` and verifier credentials. Keep the `VERIFIER_*` -settings from the main example. - -Other custom gateways, subscription OAuth, Bedrock, Vertex, ACP, and custom -Claude settings remain outside the supported scope. The -[quick start guide](docs/quickstart.md) covers the Codex + OpenRouter walkthrough; -the [evaluation guide](docs/evaluation.md) covers credential configuration, -GPU, network-policy, and custom-provider options. - -
- -For a GPU task, add `--gpu-device 0` (host index or full GPU UUID) to select the -same device for the agent and verifier. For example, after preparing its inputs -and credentials: - -```bash -bash scripts/run_task.sh --task code-embedding-finetuning --agent codex --openrouter \ - --reasoning-effort max --gpu-device 0 -``` +> Use run-searchswe-evaluation with my Search-SWE checkout. Run agentic-search +> and agentic-search-vectorless with Codex + OpenRouter + openai/gpt-6-luna, +> max reasoning and concurrency 2. Reuse my .env, monitor the runs and report +> scores, verifier health and result paths. -Omit the option to use the default single-GPU allocation. See -[GPU device selection](docs/evaluation.md#gpu-device-selection) for details. +The skill guides setup, resource checks, execution and monitoring through the +same CLI. It prompts you to configure missing keys locally. The +[agent evaluation guide](docs/agent-evaluations.md) covers local checkout +installation, other agents, user-wide installation and installer prerequisites. ## 📚 Documentation | Document | Contents | | --- | --- | | [Quick start guide](docs/quickstart.md) | A first CPU evaluation, end to end | +| [Run with your agent](docs/agent-evaluations.md) | Install the evaluation skill and describe settings/tasks | | [Evaluation guide](docs/evaluation.md) | Credential setup, coding agents, GPU, network policy, and custom providers | | [Network policy](docs/network-policy.md) | Phase-specific allowlists, default direct gateway, and optional proxy egress | | [Asset guide](docs/assets.md) | Downloading, verifying, and restoring fixed data and models | diff --git a/README_zh.md b/README_zh.md index 4d17ee0..c403b3f 100644 --- a/README_zh.md +++ b/README_zh.md @@ -35,193 +35,82 @@ Search-SWE 评估编码智能体能否在固定资源约束下**实现和优化* ## 🚀 快速开始 -下面的示例在 CPU 上使用 Codex 编码智能体,通过 OpenRouter 调用 GPT-6 Luna, -跑通 `reasoning-query-rewriting`。 -Search-SWE 需要 Python 3.12 或更新版本,以及满足所选任务 CPU、内存、存储和 -可选 GPU 要求的 Docker。 - -### 1. 安装宿主机工具 - -```bash -git clone https://github.com/VectorSpaceLab/Search-SWE.git -cd Search-SWE -python -m pip install -r scripts/requirements.txt -``` - -`scripts/requirements.txt` 提供固定版本的 Harbor 启动器和 Hugging Face 资源 -客户端。在任意已有的 Python 3.12+ 环境中运行即可;如果尚未使用隔离环境,建议 -使用 venv 或 Conda,但不强制指定环境管理工具。任务专属的 Python 依赖安装在 -Docker 镜像内。 - -### 2. 恢复任务资源 - -```bash -python scripts/download_assets.py --task reasoning-query-rewriting -python scripts/download_assets.py --task reasoning-query-rewriting --verify-only -``` - -下载器会核对文件大小和 SHA-256,并复用已通过校验的文件。 - -### 3. 配置智能体与验证器 - -仓库跟踪的模板说明通用凭证;各任务的 README 和 `task.toml` 说明额外配置。复制一次即可; -真实密钥只填写到被 Git 忽略的 `.env` 中: +在 Python 3.12+ 环境中安装 `search-swe` CLI。发布到 PyPI 后的安装命令为: ```bash -cp .env.example .env -chmod 600 .env -``` - -本例使用 Codex + OpenRouter,只需在 `.env` 中填写下面四项,其余暂时留空: - -```dotenv -AGENT_MODEL=openai/gpt-6-luna -AGENT_OPENROUTER_API_KEY=YOUR_AGENT_OPENROUTER_KEY -VERIFIER_OPENAI_BASE_URL=https://openrouter.ai/api/v1 -VERIFIER_OPENAI_API_KEY=YOUR_OPENROUTER_KEY +python -m pip install search-swe ``` -`AGENT_OPENROUTER_API_KEY` 用于 Codex 编码智能体的 OpenRouter 认证。 -运行下面的命令时需指定 `--openrouter`;启动器会提供 API 地址和 Codex provider 配置。 -`VERIFIER_*` 通过 RewardKit 0.2.0 和 OpenRouter 运行固定为 -`deepseek/deepseek-v4.1-flash` 的独立轨迹评审;在 `task.toml` 中声明了 -`VERIFIER_OPENAI_*` 的任务需要配置。编码智能体与 verifier 使用独立的凭证组; -启动器只把 verifier 凭据传进 verifier 容器。本例无需填写 `DEEPSEEK_API_KEY`、 -`AGENT_OPENAI_BASE_URL` 或 `AGENT_OPENAI_API_KEY`。 - -其他运行方式只需填写 `.env` 中对应的分组: - -- Pi + GLM-5.3-Flash:设置 `AGENT_MODEL=zai/glm-5.3-flash` 和 `ZAI_API_KEY`。 -- Codex 直连服务(不加 `--openrouter`):设置 `AGENT_MODEL`、 - `AGENT_OPENAI_BASE_URL` 和 `AGENT_OPENAI_API_KEY`。 -- Claude Code:设置 `AGENT_MODEL` 和 `AGENT_ANTHROPIC_API_KEY`;共享启动器 - 默认使用 Anthropic 官方 API。 -- OpenRouter Agent 模式:设置 `AGENT_OPENROUTER_API_KEY`,并为 Codex 或 - Claude Code 指定 `--openrouter` 和完整的 `provider/model` 模型 ID。 -- 配置答案评审的任务:填写任务声明的 `ANSWER_JUDGE_*` 变量。 -- 可选 submission API:只在所选任务 README 和资源文档允许且确实使用时,填写 - `REASONING_QUERY_REWRITING_OPENROUTER_API_KEY`、`OPENROUTER_API_KEY` 或 `JINA_API_KEY`。 - -需要代理时,按[网络配置](docs/network-policy.md)设置 `.env` 的 `EGRESS_CONFIG`,否则留空。 - -凭证隔离、自定义服务地址、网络权限和任务配置的读取方法见 -[评测指南](docs/evaluation.md)。 - -### 4. 使用 Codex,通过 OpenRouter 运行 GPT-6 Luna +当前 checkout 中的包尚未发布。测试这个开发版本时,克隆仓库并使用本地可编辑安装: ```bash -bash scripts/run_task.sh --task reasoning-query-rewriting --agent codex --openrouter \ - --reasoning-effort max --dry-run - -bash scripts/run_task.sh --task reasoning-query-rewriting --agent codex --openrouter \ - --reasoning-effort max \ - --output jobs/reasoning-query-rewriting-codex-openrouter-luna +git clone https://github.com/VectorSpaceLab/Search-SWE.git +cd Search-SWE +python -m pip install -e . ``` -dry-run 只打印 Harbor 命令、不启动容器。第二条命令会构建任务镜像、运行智能体 -和独立验证器,并把 reward 与任务记录写入 -`jobs/reasoning-query-rewriting-codex-openrouter-luna`。 - -
-其他智能体示例 - -以下示例复用已下载的任务资源和上面的 `VERIFIER_*` 配置。只需配置所选编码 -智能体对应的凭证组。 - -#### Pi 和 Z.AI GLM-5.3-Flash - -在 `.env` 中修改智能体模型并填写对应密钥。RewardKit judge 不会随之改变,因此 -仍需保留 `VERIFIER_*` OpenRouter 裁判配置: - -```dotenv -AGENT_MODEL=zai/glm-5.3-flash -ZAI_API_KEY=YOUR_ZAI_KEY -``` +### 通过 CLI 运行 ```bash -bash scripts/run_task.sh --task reasoning-query-rewriting --agent pi \ - --thinking xhigh \ - --output jobs/reasoning-query-rewriting-pi-glm +search-swe run ``` -#### Codex 和 GPT 模型 +向导会依次引导选择 harness、provider、搜索并选择模型、推理强度和配置 API key,然后多选 +任务,选择顺序或并行运行。默认 setting 是 **Codex + OpenRouter + GPT-6 Luna +(max)**。Key 使用隐藏输入;确认后才将配置保存到被 Git 忽略的 `.env`,也可以 +选择仅在本次运行使用输入的凭证。 -配置你的 GPT 兼容服务所接受的模型名称和凭证: +主机需为原生 Linux x86_64 Docker 环境,并具有足够的 CPU、RAM、磁盘和任务所需 +GPU。CLI 会自动恢复并校验所选任务的数据、分配资源,以及准备需要的限额存储。 +使用限额文件系统的任务需要 root/sudo 和 FUSE,详见 +[主机配置](docs/evaluation.md#bounded-task-storage)。 -```dotenv -AGENT_MODEL=YOUR_GPT_MODEL -AGENT_OPENAI_BASE_URL=https://your-agent-endpoint.example/v1 -AGENT_OPENAI_API_KEY=YOUR_AGENT_KEY -``` +配置完成后,可以直接启动: ```bash -bash scripts/run_task.sh --task reasoning-query-rewriting --agent codex \ - --reasoning-effort xhigh --output jobs/reasoning-query-rewriting-codex +search-swe run reasoning-query-rewriting --yes +search-swe run agentic-search agentic-search-vectorless --concurrency 2 --yes +search-swe run code-embedding-finetuning --gpu-devices 0 --yes ``` -如果所选模型或服务不支持推理强度,请省略 `--reasoning-effort`。 +并行任务会在所选资源池内分配不同的 CPU 集合和 GPU。结果、各任务日志与 +`run.json` 摘要保存在新建的 `jobs/evaluations/` 目录下。使用 `search-swe list` +浏览任务,`search-swe configure` 只保存配置,`--dry-run` 预览配置与命令。 +任务定义仍从 checkout 加载;在其他目录运行安装好的 CLI 时可指定 `--repo PATH`。 -#### Claude Code 和 Anthropic 官方 API +[快速开始](docs/quickstart.md)、[CLI 文档](docs/cli.md)及 +[完整评测指南](docs/evaluation.md)介绍自定义 provider、资源池、网络配置和底层单任务入口。 -所有任务镜像都预装 Claude Code 2.1.309。填写 API 账户可用的 Anthropic 模型 -和独立的编码智能体密钥: +### 通过自己的 agent 运行 -```dotenv -AGENT_MODEL=claude-sonnet-4-6 -AGENT_ANTHROPIC_API_KEY=YOUR_ANTHROPIC_KEY -``` +将 [run-searchswe-evaluation](skills/run-searchswe-evaluation/SKILL.md) 安装到 +自己的 coding agent,再告诉它要用什么 setting 跑哪些 tasks。这个 skill 发布到 +GitHub 后,可任选一种方式安装(以下以 Codex 为例): ```bash -bash scripts/run_task.sh --task reasoning-query-rewriting --agent claude-code \ - --reasoning-effort high --output jobs/reasoning-query-rewriting-claude +npx skills add VectorSpaceLab/Search-SWE --skill run-searchswe-evaluation -a codex ``` -#### 通过 OpenRouter 运行 Codex 或 Claude Code - -在 `.env` 中填写独立的 OpenRouter Agent 密钥: - -```dotenv -AGENT_OPENROUTER_API_KEY=YOUR_AGENT_OPENROUTER_KEY -``` - -选择 Agent,并传入完整的 OpenRouter 模型 ID: - ```bash -bash scripts/run_task.sh --task reasoning-query-rewriting --agent codex --openrouter \ - --model openai/gpt-6-luna --reasoning-effort max \ - --output jobs/reasoning-query-rewriting-codex-openrouter - -bash scripts/run_task.sh --task reasoning-query-rewriting --agent claude-code --openrouter \ - --model anthropic/claude-opus-5.5 --reasoning-effort xhigh \ - --output jobs/reasoning-query-rewriting-claude-openrouter +gh skill install VectorSpaceLab/Search-SWE skills/run-searchswe-evaluation --agent codex ``` -可以先为命令加上 `--dry-run` 预览。启动器已配置 OpenRouter 的 API 地址; -这个 Agent 密钥与任务提交用的 `OPENROUTER_API_KEY` 和裁判密钥分开。 -保留主示例中的 `VERIFIER_*` 配置。 +然后告诉 agent: -其他自定义 gateway、订阅 OAuth、Bedrock、Vertex、ACP 和自定义 Claude settings -仍不受支持。Codex + OpenRouter -流程见[快速开始指南](docs/quickstart.md);凭证配置、GPU、 -网络权限和自定义模型服务配置见[评测指南](docs/evaluation.md)。 - -
- -GPU 任务可以增加 `--gpu-device 0`,使用宿主机编号或完整 GPU UUID,让 agent -和 verifier 使用同一设备。例如,准备好该任务的数据和凭证后运行: - -```bash -bash scripts/run_task.sh --task code-embedding-finetuning --agent codex --openrouter \ - --reasoning-effort max --gpu-device 0 -``` +> 使用 run-searchswe-evaluation 和我的 Search-SWE checkout,以 Codex + OpenRouter + +> openai/gpt-6-luna、max 推理强度,运行 agentic-search 和 agentic-search-vectorless, +> 并发上限为 2。复用我的 .env,跟踪运行进展,最后汇报得分、verifier 状态和结果路径。 -省略该参数时使用默认单卡分配。详见[GPU 设备选择](docs/evaluation.md#gpu-device-selection)。 +Skill 会指导 agent 使用同一个 CLI 完成配置、资源检查、启动和监控;缺少 key 时, +会提醒你在本地配置。[Agent 评测指南](docs/agent-evaluations.md)提供从本地 checkout +安装、选择其他 agent、用户级安装及安装工具的环境要求。 ## 📚 文档索引 | 文档 | 内容 | | --- | --- | | [快速开始指南](docs/quickstart.md) | 完整的一次 CPU 评测流程 | +| [通过 agent 运行](docs/agent-evaluations.md) | 安装评测 skill,并指定 settings 和 tasks | | [评测指南](docs/evaluation.md) | 凭证配置、编码智能体、GPU、网络策略和自定义模型服务 | | [网络权限](docs/network-policy.md) | 各阶段的 host allowlist、默认直连网关与可选代理出口 | | [资源说明](docs/assets.md) | 固定数据与模型的下载、校验和恢复 | diff --git a/docs/agent-evaluations.md b/docs/agent-evaluations.md new file mode 100644 index 0000000..24beddd --- /dev/null +++ b/docs/agent-evaluations.md @@ -0,0 +1,90 @@ +# Run evaluations with your agent + +Install `run-searchswe-evaluation` into your coding agent, then describe the model +settings and tasks you want to evaluate. The skill guides configuration, resource +selection, CLI execution, monitoring and result inspection. Both this workflow +and the [interactive CLI](quickstart.md) use the same runner and task contracts. + +## Install the skill + +Run an installer from the workspace used by your coordinating agent. These +examples target Codex; replace `codex` with your supported agent, such as +`claude-code`. Install using either tool. + +Once the skill's revision is available on GitHub: + +```bash +npx skills add VectorSpaceLab/Search-SWE --skill run-searchswe-evaluation -a codex +``` + +Or, with a GitHub CLI version that provides `gh skill install`: + +```bash +gh skill install VectorSpaceLab/Search-SWE skills/run-searchswe-evaluation --agent codex +``` + +Both examples install at project scope. Use `-g` with `npx skills`, or +`--scope user` with `gh skill install`, for availability across projects. Check +`gh skill install --help` when choosing the GitHub CLI route; older `gh` versions +need an upgrade. The current `npx skills` release requires Node.js 22.20+. +See the [skills installer documentation](https://github.com/vercel-labs/skills) +and [GitHub CLI installation reference](https://cli.github.com/manual/gh_skill_install). + +For the current local development checkout, install directly from its path: + +```bash +npx skills add /path/to/Search-SWE --skill run-searchswe-evaluation -a codex +``` + +Or: + +```bash +gh skill install /path/to/Search-SWE run-searchswe-evaluation --from-local --agent codex +``` + +GitHub CLI resolves an unpinned remote install from the latest release, falling +back to the default branch. Use `--pin FULL_COMMIT_SHA` to select a particular +published revision. For `npx skills`, a GitHub tree URL can select a published +branch or tag. Match the installed skill to the checkout/CLI you intend to use. +Reload your agent's skills or start a new session after installation. + +## Prepare the evaluation host + +The agent still needs a Search-SWE checkout, the CLI, Python 3.12+ and a native +Linux x86_64 Docker host with the selected tasks' resources. Install the local +development package with `python -m pip install -e /path/to/Search-SWE`; the PyPI +release command is `python -m pip install search-swe`. The skill itself is an +instruction package and uses the separately installed CLI. + +Tell the agent which checkout and environment to use. It will inspect the task +catalog and host, resolve your setting, and prepare a non-interactive command. +If keys are missing, it identifies the required variables and asks you to enter +them through local hidden prompts or your private `.env`. Keep keys out of chat. +Existing environment credentials can be reused without displaying their values. + +## Describe the experiment + +For example: + +> Use run-searchswe-evaluation with the checkout at /path/to/Search-SWE. Evaluate +> agentic-search and agentic-search-vectorless using Codex, OpenRouter, +> openai/gpt-6-luna and max reasoning. Run at most two tasks concurrently, using +> the existing .env. Track progress and report scores, verifier health and result paths. + +For configuration only: + +> Use run-searchswe-evaluation to prepare a run of code-embedding-finetuning with +> Codex + OpenRouter + openai/gpt-6-luna, using GPU 0 and default reasoning. +> Check configuration and show the command; stop before launching the evaluation. + +Agents supporting explicit `$skill-name` invocation can use +`$run-searchswe-evaluation`. Otherwise ask the agent to load that installed skill. +The coordinating agent may differ from the harness/model being evaluated. + +Each CLI batch runs one model setting over one or more tasks. For several model +settings, the agent prepares separate batches and allocates resources across +them. Results and logs use `jobs/evaluations//` by default. The agent +reads actual Harbor scores and verifier health alongside the CLI process status. + +See the [skill source](../skills/run-searchswe-evaluation/SKILL.md) and +[CLI reference](cli.md) for the complete workflow and command options. diff --git a/docs/assets.md b/docs/assets.md index 19e763b..e726a7d 100644 --- a/docs/assets.md +++ b/docs/assets.md @@ -1,11 +1,11 @@ # Downloading task data and models -Use Python 3.12 or newer. Install the host-side tools in your environment; -`scripts/requirements.txt` provides both the Harbor launcher and the Hugging -Face asset client: +Use Python 3.12 or newer. From the repository root, install the package and its +host dependencies declared in `pyproject.toml`, including the Harbor launcher +and Hugging Face asset client: ```bash -python -m pip install -r scripts/requirements.txt +python -m pip install -e . ``` ## Commands diff --git a/docs/benchmark.md b/docs/benchmark.md index cf18883..d3c601b 100644 --- a/docs/benchmark.md +++ b/docs/benchmark.md @@ -33,6 +33,9 @@ Search-SWE/ ├── README.md ├── README_zh.md ├── LICENSE +├── pyproject.toml # Package metadata, dependencies and CLI entrypoint +├── src/search_swe/ # Interactive configuration and batch scheduler +├── skills/run-searchswe-evaluation/ # Installable evaluation skill for coding agents ├── assets/ # Hero image used by the README ├── docker/ # Reproducible CPU and GPU base-image contexts ├── tasks/ @@ -44,7 +47,6 @@ Search-SWE/ │ │ └── tests/ # Verifier and grading code │ └── ... # Additional task packages ├── scripts/ -│ ├── requirements.txt # Host-side launcher and asset dependencies │ ├── download_assets.py # Download and verify fixed data/model inputs │ ├── download_models.py # Download fixed models only │ ├── check_release.py # Check package structure and asset mappings diff --git a/docs/cli.md b/docs/cli.md new file mode 100644 index 0000000..f011b6a --- /dev/null +++ b/docs/cli.md @@ -0,0 +1,179 @@ +# Search-SWE CLI + +Configure coding-agent evaluations interactively, restore fixed inputs, and run +one model setting across selected Search-SWE tasks sequentially or concurrently. +For agent-driven operation, install the [evaluation skill](agent-evaluations.md) +and describe the settings and tasks to your agent. + +## Installation + +The distribution name and command are both `search-swe`. This checkout contains +the unpublished `0.1.0.dev0` development version. Once published, installation is: + +```bash +python -m pip install search-swe +``` + +For the current local version, install from the repository root: + +```bash +python -m pip install -e . +search-swe --help +``` + +Python 3.12+ is required. Package dependencies are declared in +`pyproject.toml`, including the pinned Harbor version. Use a virtual +environment to isolate these dependencies. The wheel contains the CLI; task +definitions, Docker integration and fixed assets live in a Search-SWE checkout: + +```bash +git clone https://github.com/VectorSpaceLab/Search-SWE.git +cd Search-SWE +search-swe run +``` + +Repository discovery uses `--repo`, then `SEARCH_SWE_REPO`, then the current +directory and its parents. Editable installations also recognize their source +checkout. A wheel installation can run from another directory with +`search-swe --repo /path/to/Search-SWE run`. + +## Interactive setup + +`search-swe` or `search-swe run` opens the terminal wizard: + +1. Choose the harness, then a supported provider. Search/select an OpenRouter + model, enter a direct-endpoint model ID, or select a supported Pi model. + Then choose its reasoning or thinking level. +2. Enter the coding-agent API key using hidden input, or retain an existing key. +3. Filter and select tasks with Space, then press Enter. The menu shows their + CPU, RAM, GPU and asset requirements. +4. Configure the selected tasks' required judge credentials. Optional APIs for + submitted programs have a separate opt-in prompt. +5. Choose sequential execution or a maximum parallelism, review the setting and + resource requirements, select an idle GPU pool for GPU tasks, then confirm + saving and starting. + +`search-swe configure` uses the same configuration flow without starting an +evaluation. Model/harness compatibility follows the existing backend: Codex +supports OpenRouter and direct endpoints; Claude Code supports its direct +Anthropic API and Anthropic models through OpenRouter; Pi uses the repository's +DeepSeek/Z.ai direct adapters. + +The default is Codex + OpenRouter + `openai/gpt-6-luna`, with `max` reasoning. +Custom models must support the selected harness/API; choosing a model does not +perform a paid connectivity test. Existing task-specific verifier models and +network allowlists continue to apply. + +### OpenRouter model search + +The model field searches the public OpenRouter directory by name and model ID +as you type, including multiple keywords. Use ↓/↑ to highlight a result and +Enter to select its full ID; matching rows show the name and context length. +Enter on an empty field keeps the displayed current/default model. A complete +model ID can also be typed manually for newly available or custom models. + +The directory is fetched once and searched locally; typing does not issue API +requests. Metadata is cached for 24 hours under +`$XDG_CACHE_HOME/search-swe/openrouter-models.json` (default +`~/.cache/search-swe/openrouter-models.json`). Use `search-swe run --refresh-models` +to refresh it, or `--offline` to use the cache without a catalog request. If the +directory is unavailable, cached results or manual model input remain usable. +Catalog requests use the public endpoint and carry no API keys. + +Suggestions use the selected harness and available text/tool/API metadata; +Claude Code suggestions are limited to Anthropic models. Metadata filtering +does not replace an actual harness/model compatibility test. The directory may +not expose every supported reasoning level. When selecting a new model, the +wizard starts with **Model/config default**, which omits the explicit effort +override. Models that list no reasoning support use that setting automatically. +Other models offer an explicit level selection; choose a level supported by the +model/provider. The saved model setting is reused on subsequent runs. + +`--reasoning-effort default` selects the same behavior in scripted runs. This +uses the model/harness configuration, including any custom Codex configuration; +it does not mean that reasoning is disabled. Catalog selection and configuration +do not start paid model requests. + +## Credentials and configuration + +Configuration is read from the checkout `.env`, or `--env-file PATH`. Command-line +and explicit wizard answers take priority over exported variables, then file +values, then defaults. A saved configuration does not override exported shell +variables in a later invocation; the wizard identifies conflicting variable names. + +Keys are hidden during entry and summaries contain no key values. Saving uses an +atomic replacement with mode `0600`, preserves unrelated dotenv settings/comments, +and treats values literally. Saved environment files inside the checkout must be +Git-ignored. Cancelling before the final confirmation leaves the +file unchanged. Choose not to save to use entered values only for this run. + +`SEARCH_SWE_AGENT`, `SEARCH_SWE_PROVIDER`, `AGENT_MODEL`, +`AGENT_REASONING_EFFORT` / `PI_THINKING`, and `SEARCH_SWE_CONCURRENCY` hold the +selected defaults. Keys retain their existing purpose-specific variable names. +Reusing the OpenRouter agent key for the trajectory judge requires an explicit +choice; submitted programs receive only their task-declared credentials. + +## Scripted runs + +```bash +search-swe run reasoning-query-rewriting --yes +search-swe run agentic-search agentic-search-vectorless \ + --agent codex --provider openrouter --model openai/gpt-6-luna \ + --reasoning-effort max --concurrency 2 --yes +search-swe run code-embedding-finetuning --gpu-devices 0 --yes +search-swe run reasoning-query-rewriting --dry-run +search-swe list --hardware cpu +search-swe list --json +search-swe doctor +``` + +`--task NAME` may also be repeated. Non-interactive runs use `--yes`; missing keys +produce a configuration error instead of waiting for input. `--dry-run` previews +configuration and commands without writing files, downloading assets, allocating +devices or calling model APIs. `--offline` uses local asset caches; evaluation +still requires access to the selected model APIs. Asset restoration verifies +SHA-256 and replaces corrupt manifest-owned files with verified copies. + +## Parallel execution and results + +`--concurrency N` limits simultaneous tasks. The scheduler allocates disjoint +logical CPU sets and one idle physical GPU per GPU task, and admits tasks only +within the RAM pool. It preserves each task's CPU quota, memory cap, storage and +scoring rules. Fixed task CPU-set environment variables remain respected. +Use `--cpu-set 0-31`, `--gpu-devices 0,1`, and `--memory-gb 64` to bound the pool. +RAM accounting leaves 2 GiB for the host and 512 MiB per running task for trusted +infrastructure. GPUs with more than 512 MiB used or over 5% utilization are +considered busy at startup. +GPU availability is checked again while scheduling, so a GPU taken by another +process during asset preparation stays out of the next allocation. + +Allocations coordinate this CLI invocation. They do not reserve the machine +against other users or independent launchers; select a dedicated resource pool +on shared hosts. CPU placement can affect timing, so retain allocations when +reproducing performance measurements. + +Runs use fresh `jobs/evaluations//` directories, or a new `--output` +directory. `run.json` records the model setting, checkout revision/dirty state, +per-task resource allocation, process status, log and result paths. A `completed` +status means the launcher exited successfully; inspect Harbor results for task +scores and verifier health. Asset logs and evaluation logs are under `logs/`. + +Ctrl-C / SIGTERM stops launching new tasks, forwards cancellation to running +launchers and allows teardown before releasing managed storage. Retained Docker +containers prevent storage unmounting and produce a recovery instruction. +Tasks using bounded filesystems need the host prerequisites described in the +repository evaluation guide. Their storage helpers run with root/sudo while +Harbor retains the invoking user's identity. For unattended runs, arrange +non-interactive sudo access in advance. + +## Building for publication + +```bash +python -m pip install build +python -m build +``` + +Inspect both wheel and sdist before publishing. They should contain the CLI, +metadata, license and documentation, with no `.env`, task +datasets, trajectories or job outputs. Publication is a separate maintainer +action; the development package has not been uploaded to PyPI. diff --git a/docs/contributing.md b/docs/contributing.md index 6af3521..eb80e9f 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -16,7 +16,7 @@ root, use Python 3.12+ and install the host tools and static-test dependencies: ```bash python -m venv .venv source .venv/bin/activate -python -m pip install -r scripts/requirements.txt PyYAML python-dotenv +python -m pip install -e . python .agents/skills/create-searchswe-task/scripts/scaffold_task.py example-search \ --author 'Alice Example' --hardware cpu ``` diff --git a/docs/evaluation.md b/docs/evaluation.md index 8f7336e..664225e 100644 --- a/docs/evaluation.md +++ b/docs/evaluation.md @@ -3,6 +3,12 @@ This guide documents the complete local evaluation setup. For a first CPU run with Codex, use the shorter [quick start](quickstart.md). +The [CLI](cli.md) provides interactive configuration and resource-aware batches: +`search-swe run` opens the wizard, while `search-swe run TASK --yes` uses saved +settings. It restores assets and invokes the single-task backend documented below. +Install the current development checkout with `python -m pip install -e .`; +the PyPI release installation command is `python -m pip install search-swe`. + Run commands from the Search-SWE repository root unless a section says otherwise. @@ -30,7 +36,7 @@ keys may remain unset when the submission does not call those optional services. Install the host-side tools in any existing Python 3.12+ environment: ```bash -python -m pip install -r scripts/requirements.txt +python -m pip install -e . ``` An isolated venv or Conda environment is recommended if needed, but no @@ -43,8 +49,9 @@ python scripts/download_assets.py --task TASK_NAME python scripts/download_assets.py --task TASK_NAME --verify-only ``` -The launcher checks asset presence and size; `--verify-only` performs the full +The single-task backend checks asset presence and size; `--verify-only` performs the full SHA-256 check. See [assets.md](assets.md) for local caches and offline restore. +The CLI performs asset restoration and SHA-256 checking automatically before a batch. Task Dockerfiles pull versioned public images from the [Search-SWE Docker Hub repository](https://hub.docker.com/r/hanhainebula/search-swe-base): @@ -75,6 +82,46 @@ Add task-specific variables to your ignored `.env` or `--env-file` as documented by the task. Shared `.env.example` contains common settings and examples, not an exhaustive inventory. Memory, storage, time limits and asset sizes vary by task. +## Bounded task storage + +Tasks with a `tools/prepare_storage.py` helper and `*_STORAGE_ROOT` / `*_CPUSET` +Compose settings use automatic host preparation through `scripts/run_task.sh`. +Use a native Linux x86_64 host with local rootful Docker, without user namespace +remapping, plus root or sudo access. On Ubuntu/Debian, install `e2fsprogs` and +`dpkg` for `mkfs.ext4` and `dpkg-deb`. The host must expose `/dev/fuse`; if the +kernel module is available but unloaded, enable it with `sudo modprobe fuse`. +The helper downloads and verifies its pinned FUSE userspace tools on first use. + +The launcher chooses the required number of logical CPUs from its own allowed +affinity. Set the task's `*_CPUSET` variable in `.env` or the shell for a specific +allocation; its CPU IDs and count are checked before startup. Automatic selection +does not reserve idle CPUs across simultaneous evaluations. Assign disjoint sets +when running these tasks concurrently, and preserve the original allocation when +reproducing performance measurements. + +Each launch prepares a unique directory under `jobs/.task-storage//` +before Harbor starts. Existing filesystem capacities, resource accounting, task +timers and scoring apply. Allow host disk space for public inputs, images, logs +and both phases' writable filesystems; a task's 64 GiB storage budget is not the +total host disk requirement. The filesystem images are sparse and grow with use. + +For an ordinary user, the launcher authenticates sudo once and keeps a dedicated +storage helper alive through teardown. Only that helper is privileged; Harbor +runs as the invoking user. Model API keys are not passed to the helper. For a +background launch, configure non-interactive sudo or authenticate before starting. + +On normal exit or a forwarded interrupt, the launcher waits for Harbor and checks +Docker containers before unmounting. Backing images remain available for inspection. +If containers remain, Docker cannot be checked, or the launcher is forcibly killed, +storage is retained and the helper prints its exact recovery command. Stop/remove +only that run's containers before running the displayed `prepare_storage.py ... +--unmount` command. Delete retained images only after their results are no longer +needed and all mounts have been released. + +For explicitly managed runs, prepare storage with the task helper as root and +set the task's `*_STORAGE_ROOT` to its absolute path. The launcher uses that +directory; its owner manages unmounting after all associated containers exit. + ## Environment-file behavior The launcher reads `.env` in the repository root by default. Use `--env-file` @@ -104,6 +151,8 @@ AGENT_OPENAI_API_KEY=YOUR_AGENT_KEY `--model` overrides `AGENT_MODEL`. `--reasoning-effort` overrides the optional `AGENT_REASONING_EFFORT` environment variable. +Use `--reasoning-effort default` to omit the effort override and use the +model/harness configuration. Pi provides the equivalent `--thinking default`. For a native Codex TOML, add an absolute path or a path relative to the repository root: @@ -249,7 +298,7 @@ any authenticated client. The backend uses the [OpenRouter Responses API](https://openrouter.ai/docs/api/api-reference/responses/create-responses). A task may additionally declare a Chat Completions-compatible answer-equivalence -judge. When its `task.toml` declares this group, configure: +judge. Configure the environment variables referenced by its `task.toml`, for example: ```dotenv ANSWER_JUDGE_MODEL_NAME=YOUR_ANSWER_JUDGE_MODEL @@ -259,6 +308,17 @@ ANSWER_JUDGE_API_KEY=YOUR_ANSWER_JUDGE_KEY Choose an endpoint allowed by that task's `[verifier].allowed_hosts`. Answer and trajectory judges are separate credential groups; configure the ones the task declares. +Literal model names and endpoints in `task.toml` remain fixed. A task may map its +answer-judge key to an existing verifier key. The CLI prompts for the actual +referenced variables and keeps these mappings intact. + +## CPU allocation + +The CLI assigns each simultaneous task a disjoint CPU set. For a native single-task +run, `scripts/run_task.sh --cpu-set 0-7 ...` selects exactly the number of logical +CPUs declared by that task. The generated phase-specific Compose copies apply the +set to all task services, preserving their CPU quotas, RAM limits and mounts. +Use the CLI's `--cpu-set` to specify the available pool for a batch instead. ## Configure optional submission APIs diff --git a/docs/quickstart.md b/docs/quickstart.md index 7ec5408..ab80450 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -1,117 +1,73 @@ # Quick start -This guide runs one CPU task with the Codex coding agent and GPT-6 Luna -through OpenRouter. It keeps the first setup small; other tasks, Pi providers, -GPU requirements, optional submission APIs, Claude Code, proxies, and custom -Codex providers are covered in the [full evaluation guide](evaluation.md). +Use Python 3.12+ and a native Linux x86_64 Docker host with enough resources for +the selected tasks. The CLI and its pinned Harbor dependency run on the host; +task-specific libraries run inside Docker. -Run every command below from the Search-SWE repository root. +You can follow the CLI workflow below, or install the +[evaluation skill](agent-evaluations.md) and ask your agent to run your chosen +settings and tasks. -## 1. Install the launcher +## Install -Search-SWE requires Python 3.12+, Docker, and enough CPU, memory, and storage -for the selected task. In any existing Python 3.12+ environment, install the -launcher and asset-downloader dependencies directly: +The PyPI release installation command is `python -m pip install search-swe`. +This development version is not yet published; install it from the checkout: ```bash -python -m pip install -r scripts/requirements.txt +git clone https://github.com/VectorSpaceLab/Search-SWE.git +cd Search-SWE +python -m pip install -e . +search-swe doctor ``` -An isolated venv or Conda environment is recommended if you do not already use -one, but Search-SWE does not require a particular environment manager. -Task-specific Python packages are installed in Docker images, not in this host -environment. Confirm that Docker is available before continuing: +Use an isolated Python environment if needed. Task definitions and assets remain +in the checkout; a wheel installation also needs that checkout, selected through +the current directory, `--repo PATH`, or `SEARCH_SWE_REPO`. -```bash -docker info -``` - -## 2. Restore one task - -Start with `reasoning-query-rewriting`, a CPU task with a small asset bundle. Do not download -every task for a first run: +## Configure and run ```bash -python scripts/download_assets.py --task reasoning-query-rewriting -python scripts/download_assets.py --task reasoning-query-rewriting --verify-only +search-swe run ``` -The second command verifies file sizes and SHA-256 checksums. See the -[asset guide](assets.md) for cache, offline, and per-kind options. - -## 3. Configure the agent and verifier +Choose the harness and provider, then search for a model and select its reasoning +level before entering the required API keys. For OpenRouter, type a model name +or ID, use the arrow keys to select a match, and press Enter; a complete model +ID can also be entered manually. The default is Codex + OpenRouter + `openai/gpt-6-luna` with `max` +reasoning. Keys are hidden while typing, and an existing key can be reused +without displaying it. -Copy the complete environment template, then fill only the Codex-agent and -RewardKit-verifier sections used by this walkthrough: +Select tasks with Space and confirm with Enter. For a first CPU run, +`reasoning-query-rewriting` has a small asset bundle. Choose sequential execution +initially, or select parallel execution and its concurrency limit. The wizard +asks for the selected tasks' judge credentials and optionally their submission +API keys. Review the summary, choose whether to save `.env`, and confirm starting. -```bash -cp .env.example .env -``` - -For `reasoning-query-rewriting`, fill in these values: - -```dotenv -AGENT_MODEL=openai/gpt-6-luna -AGENT_OPENROUTER_API_KEY=YOUR_AGENT_OPENROUTER_KEY -VERIFIER_OPENAI_BASE_URL=https://openrouter.ai/api/v1 -VERIFIER_OPENAI_API_KEY=YOUR_OPENROUTER_KEY -``` +The CLI downloads missing assets, validates checksums, assigns CPU/GPU resources, +prepares task-specific storage and launches the existing Harbor integration. +Cancellation before the final confirmation does not modify `.env`. -`AGENT_OPENROUTER_API_KEY` runs the coding agent. With `--openrouter`, the -launcher supplies the API endpoint and Codex provider configuration; leave -`AGENT_OPENAI_BASE_URL` and `AGENT_OPENAI_API_KEY` empty. `--model` can override -`AGENT_MODEL` for a run. - -The `VERIFIER_*` pair runs the independent `deepseek/deepseek-v4.1-flash` -trajectory judge through RewardKit 0.2.0 and OpenRouter. Agent and verifier -credentials remain separate groups, and only the verifier receives the -`VERIFIER_*` values. -`reasoning-query-rewriting` also permits OpenRouter and Jina as submission resources; -their keys are optional and are not needed for an implementation that uses only -the provided corpus and local runtime. - -If a proxy is required, set `EGRESS_CONFIG` in `.env` following the -[network guide](network-policy.md); otherwise leave it empty. - -The local `.env` is ignored by Git. Do not commit or print credentials. On a -multi-user Unix host, restrict it after adding credentials: +## Run with saved settings ```bash -chmod 600 .env +search-swe run reasoning-query-rewriting --yes +search-swe run agentic-search agentic-search-vectorless --concurrency 2 --yes +search-swe run code-embedding-finetuning --gpu-devices 0 --yes ``` -## 4. Preview, then run +Use `search-swe configure` to configure without launching, `search-swe list` to +list tasks, and `--dry-run` to preview without downloads, API calls or writes. +Non-interactive scripts supply `--yes`. Keys stay in `.env` or process environment +variables, never command-line arguments. Exported values override saved file +values on subsequent invocations. -First inspect the Harbor command without starting containers or making API -calls: +## Inspect results -```bash -bash scripts/run_task.sh --task reasoning-query-rewriting --agent codex --openrouter \ - --reasoning-effort max --dry-run -``` - -The preview checks command construction only; it does not validate credentials, -assets, Docker, hardware, or service availability. Start a fresh evaluation -after those prerequisites are ready: - -```bash -bash scripts/run_task.sh --task reasoning-query-rewriting --agent codex --openrouter \ - --reasoning-effort max \ - --output jobs/reasoning-query-rewriting-codex-openrouter-luna -``` +The CLI prints the batch directory and each task's log path. Under +`jobs/evaluations//`, `run.json` records settings, allocations and +result locations; `logs/` contains preparation and evaluation logs. Read Harbor's +reports for the score and judge health: process completion is not a passing score. -When switching models, omit `--reasoning-effort` if the selected model or -provider does not support it. Results are written below the output directory as -Harbor job records. -Inspect the job reward, verifier logs, and transferred artifacts before treating -the run as successful. - -## Next steps - -- Read the [full evaluation guide](evaluation.md) before selecting another task - or agent. It documents Codex, Pi, and the pinned Claude Code 2.1.309 launcher, - and explains how to read each task's own credential, hardware and network - configuration. -- Read the selected task's `README.md` and `instruction.md` for its resource - budget, submission contract, and scoring rules. -- Use the [asset guide](assets.md) to restore only that task's fixed inputs. +See the [CLI reference](cli.md) for resource pools, offline assets and packaging; +the [full evaluation guide](evaluation.md) for credentials and native launcher +options; and [network policy](network-policy.md) for proxy configuration. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..ec8ab41 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,37 @@ +[build-system] +requires = ["setuptools>=77"] +build-backend = "setuptools.build_meta" + +[project] +name = "search-swe" +version = "0.1.0.dev0" +description = "Configure and run Search-SWE coding-agent evaluations" +readme = "docs/cli.md" +requires-python = ">=3.12" +license = "Apache-2.0" +license-files = ["LICENSE"] +dependencies = [ + "harbor==0.22.0", + "huggingface_hub>=1.0,<2.0", + "questionary>=2.1,<3", + "prompt_toolkit>=3.0.48,<4", + "python-dotenv>=1.0,<2", + "psutil>=7,<8", + "PyYAML>=6,<7", +] +classifiers = [ + "Development Status :: 3 - Alpha", + "Operating System :: POSIX :: Linux", + "Programming Language :: Python :: 3", + "Topic :: Scientific/Engineering :: Artificial Intelligence", +] + +[project.urls] +Repository = "https://github.com/VectorSpaceLab/Search-SWE" +Homepage = "https://search-swe.github.io/" + +[project.scripts] +search-swe = "search_swe.cli:main" + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/scripts/cpu_affinity.py b/scripts/cpu_affinity.py new file mode 100644 index 0000000..f6526a0 --- /dev/null +++ b/scripts/cpu_affinity.py @@ -0,0 +1,35 @@ +"""Per-run logical CPU selection without changing task CPU quotas.""" + +import copy +import os +import re + + +def parse_cpu_set(value): + selected = set() + for item in value.split(","): + if not re.fullmatch(r"\d+(?:-\d+)?", item): + raise ValueError("CPU sets contain IDs or ranges, for example 0-3,8-11") + bounds = list(map(int, item.split("-"))) + first, last = bounds[0], bounds[-1] + if last < first or last > 1_000_000: + raise ValueError("Invalid CPU range") + selected.update(range(first, last + 1)) + return selected + + +def validate_cpu_set(value, count, *, check_host=True): + selected = parse_cpu_set(value) + if len(selected) != count: + raise ValueError(f"This task requires exactly {count} logical CPUs") + if check_host and not selected <= os.sched_getaffinity(0): + raise ValueError("Requested CPUs are outside this process's available affinity") + return ",".join(map(str, sorted(selected))) + + +def select_compose_cpus(document, value): + parse_cpu_set(value) + result = copy.deepcopy(document) + for service in result["services"].values(): + service["cpuset"] = value + return result diff --git a/scripts/download_assets.py b/scripts/download_assets.py index b5e8975..94c16ed 100755 --- a/scripts/download_assets.py +++ b/scripts/download_assets.py @@ -151,7 +151,7 @@ def obtain_source(task, entry, args): configure_hf_xet() from huggingface_hub import hf_hub_download except ImportError as error: - raise ValueError("Install dependencies: python -m pip install -r scripts/requirements.txt") from error + raise ValueError("Install host dependencies from the repository root: python -m pip install -e .") from error download_args = dict( repo_id=source["repo_id"], repo_type=source["repo_type"], filename=source["filename"], revision=source["revision"], diff --git a/scripts/harbor_environments.py b/scripts/harbor_environments.py index 8ff0838..a1383dd 100644 --- a/scripts/harbor_environments.py +++ b/scripts/harbor_environments.py @@ -21,6 +21,7 @@ from scripts.egress.ownership import ProjectLock, PrivateDirectory, owner_root from environments.egress.gateway import LEASE_SECONDS from scripts.gpu_device import GPU_UUID, select_compose_gpu +from scripts.cpu_affinity import parse_cpu_set, select_compose_cpus # Containerd creation/removal can exceed a minute on busy hosts. These bounded @@ -55,23 +56,30 @@ def gateway_image_info(reference): class GpuDocker(DockerEnvironment): - """Use a per-phase copy of the task Compose file for explicit GPU selection.""" + """Select task CPU/GPU devices in independent per-phase Compose copies.""" - def __init__(self, *args, gpu_device=None, **kwargs): + def __init__(self, *args, gpu_device=None, cpu_set=None, **kwargs): if gpu_device is not None and not GPU_UUID.fullmatch(gpu_device): raise ValueError("gpu_device must be a resolved UUID; use scripts/run_task.sh --gpu-device") self.gpu_device = gpu_device + if cpu_set is not None: + parse_cpu_set(cpu_set) + self.cpu_set = cpu_set super().__init__(*args, **kwargs) @property def _docker_compose_paths(self): paths = super()._docker_compose_paths - if not getattr(self, "gpu_device", None): + if not getattr(self, "gpu_device", None) and not getattr(self, "cpu_set", None): return paths source = self._environment_docker_compose_path if source not in paths: - raise ValueError("The GPU task must supply environment/tests docker-compose.yaml") - document = select_compose_gpu(yaml.safe_load(source.read_text()), self.gpu_device) + raise ValueError("The task must supply environment/tests docker-compose.yaml") + document = yaml.safe_load(source.read_text()) + if getattr(self, "gpu_device", None): + document = select_compose_gpu(document, self.gpu_device) + if getattr(self, "cpu_set", None): + document = select_compose_cpus(document, self.cpu_set) # Harbor keeps --project-directory at the original environment directory, # so relative bind mounts/build paths retain their original meaning. directory = self.trial_paths.trial_dir / "gpu" diff --git a/scripts/requirements.txt b/scripts/requirements.txt deleted file mode 100644 index ac0755d..0000000 --- a/scripts/requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -# Host-side tools for Search-SWE: the pinned Harbor launcher and the -# Hugging Face client used to restore fixed task inputs. -harbor==0.22.0 -huggingface_hub>=1.0,<2.0 diff --git a/scripts/run_task.py b/scripts/run_task.py index ca7f621..d9dcf5d 100755 --- a/scripts/run_task.py +++ b/scripts/run_task.py @@ -7,6 +7,7 @@ import re import shlex import shutil +import subprocess import sys import tomllib from urllib.parse import urlsplit @@ -17,12 +18,16 @@ from .download_assets import destination_path, read_manifest, relative_path from .gpu_device import resolve_gpu_device, validate_gpu_device from .harness_versions import CODEX_VERSION, CLAUDE_CODE_VERSION, PI_VERSION, PI_THINKING_LEVELS + from .task_storage import StoragePlan, prepared_storage, run_command + from .cpu_affinity import validate_cpu_set else: from docker_dns import parse_servers from task_paths import select_task, task_key from download_assets import destination_path, read_manifest, relative_path from gpu_device import resolve_gpu_device, validate_gpu_device from harness_versions import CODEX_VERSION, CLAUDE_CODE_VERSION, PI_VERSION, PI_THINKING_LEVELS + from task_storage import StoragePlan, prepared_storage, run_command + from cpu_affinity import validate_cpu_set REPO = Path(__file__).resolve().parents[1] @@ -32,7 +37,7 @@ OPENROUTER_HOST = "openrouter.ai" OPENROUTER_CLAUDE_BASE_URL = "https://openrouter.ai/api" OPENROUTER_MODEL = re.compile( - r"^[a-z0-9][a-z0-9._-]*/[a-zA-Z0-9][a-zA-Z0-9._:+-]*$" + r"^~?[a-z0-9][a-z0-9._-]*/[a-zA-Z0-9][a-zA-Z0-9._:+-]*$" ) CLAUDE_HOST_ENV_VARS = ( "ANTHROPIC_API_KEY", @@ -113,24 +118,28 @@ def main(): help="Route Codex or Claude Code through OpenRouter using AGENT_OPENROUTER_API_KEY", ) parser.add_argument("--model", help="Agent model; defaults to AGENT_MODEL") - parser.add_argument("--env-file", type=Path, help="Defaults to the repository .env if present") + env_options = parser.add_mutually_exclusive_group() + env_options.add_argument("--env-file", type=Path, help="Defaults to the repository .env if present") + env_options.add_argument("--no-env-file", action="store_true", help="Use only process environment configuration") parser.add_argument( "--reasoning-effort", help="Override AGENT_REASONING_EFFORT for Codex or Claude Code", ) parser.add_argument("--codex-config", type=Path, help="Optional native Codex TOML configuration") - parser.add_argument("--thinking", choices=PI_THINKING_LEVELS, help="Pi thinking level; overrides PI_THINKING") + parser.add_argument("--thinking", choices=("default", *PI_THINKING_LEVELS), help="Pi thinking level; default omits the override") parser.add_argument("--container-dns", help="Comma-separated upstream IPv4 DNS servers; overrides CONTAINER_DNS. Keeps Harbor's API allowlist.") parser.add_argument("--egress-config", type=Path, help="Direct/proxy gateway JSON; overrides EGRESS_CONFIG. Without it, restricted tasks use the direct gateway.") parser.add_argument("--egress-image", help="Direct gateway image (pulled if missing); overrides EGRESS_IMAGE. Cannot be combined with EGRESS_CONFIG.") parser.add_argument("--output", type=Path, help="Job output directory; relative to the current directory") parser.add_argument("--gpu-device", help="Host GPU index or full GPU UUID, shared by the agent and verifier") + parser.add_argument("--cpu-set", help="Logical CPU IDs/ranges for all task services in both phases") parser.add_argument("--dry-run", action="store_true", help="Print the command with variable references; do not launch") + parser.add_argument("--check", action="store_true", help="Validate model, credentials and configuration without restoring assets or launching") args = parser.parse_args() env_file = args.env_file or REPO / ".env" file_env = {} - if env_file.is_file(): + if env_file.is_file() and not args.no_env_file: try: from dotenv import dotenv_values except ImportError: @@ -178,9 +187,9 @@ def main(): if args.codex_config is not None: parser.error("--codex-config is only valid with --agent codex") effort = args.reasoning_effort or env.get("AGENT_REASONING_EFFORT") - if effort and effort not in CLAUDE_CODE_EFFORT_LEVELS: + if effort and effort not in ("default", *CLAUDE_CODE_EFFORT_LEVELS): parser.error( - "Claude Code reasoning effort must be one of: " + "Claude Code reasoning effort must be one of: default, " + ", ".join(CLAUDE_CODE_EFFORT_LEVELS) ) @@ -189,6 +198,18 @@ def main(): except ValueError as error: parser.error(str(error)) task_config = tomllib.loads((task / "task.toml").read_text()) + try: + storage = StoragePlan.discover(task, task_config) + except ValueError as error: + parser.error(str(error)) + if args.cpu_set is not None: + try: + args.cpu_set = validate_cpu_set(args.cpu_set, task_config["environment"]["cpus"], + check_host=not args.dry_run) + except ValueError as error: + parser.error(str(error)) + if storage: + env[storage.cpu_variable] = args.cpu_set if args.gpu_device is not None: if task_config.get("environment", {}).get("gpus", 0) != 1: parser.error("--gpu-device requires a single-GPU task") @@ -217,7 +238,7 @@ def main(): command.extend(["--ak", f"version={CODEX_VERSION}"]) effort = args.reasoning_effort or env.get("AGENT_REASONING_EFFORT") if effort: - command.extend(["--ak", f"reasoning_effort={effort}"]) + command.extend(["--ak", f"reasoning_effort={'null' if effort == 'default' else effort}"]) config = REPO / "scripts/openrouter_codex.toml" if args.openrouter else args.codex_config if config is None and env.get("AGENT_CODEX_CONFIG"): config = REPO / env["AGENT_CODEX_CONFIG"] @@ -247,7 +268,7 @@ def main(): command.extend(["--ak", f"version={CLAUDE_CODE_VERSION}"]) effort = args.reasoning_effort or env.get("AGENT_REASONING_EFFORT") if effort: - command.extend(["--ak", f"reasoning_effort={effort}"]) + command.extend(["--ak", f"reasoning_effort={'null' if effort == 'default' else effort}"]) if args.openrouter: required.append("AGENT_OPENROUTER_API_KEY") command.extend([ @@ -261,13 +282,13 @@ def main(): agent_host = ANTHROPIC_HOST else: thinking = args.thinking or env.get("PI_THINKING") - if thinking and thinking not in PI_THINKING_LEVELS: + if thinking and thinking not in ("default", *PI_THINKING_LEVELS): parser.error( - "PI_THINKING must be one of: " + ", ".join(PI_THINKING_LEVELS) + "PI_THINKING must be one of: default, " + ", ".join(PI_THINKING_LEVELS) ) command.extend(["--ak", f"version={PI_VERSION}"]) if thinking: - command.extend(["--ak", f"thinking={thinking}"]) + command.extend(["--ak", f"thinking={'null' if thinking == 'default' else thinking}"]) key = PI_MODEL_KEYS[model] required.append(key) command.extend(["--ae", f"{key}=${{{key}}}"]) @@ -296,13 +317,12 @@ def main(): # Task-specific judge settings are resolved by task.toml from Harbor's # environment; require only the groups used by the selected task. for name in ("ANSWER_JUDGE_MODEL_NAME", "ANSWER_JUDGE_BASE_URL", "ANSWER_JUDGE_API_KEY"): - if name in verifier_env: - required.append(name) + required.extend(re.findall(r"\$\{([A-Z][A-Z0-9_]*)(?::[-?][^}]*)?\}", verifier_env.get(name, ""))) endpoint_variables = [] if "OPENAI_API_KEY" in verifier_env: endpoint_variables.append("VERIFIER_OPENAI_BASE_URL") - if "ANSWER_JUDGE_BASE_URL" in verifier_env: + if "${ANSWER_JUDGE_BASE_URL" in verifier_env.get("ANSWER_JUDGE_BASE_URL", ""): endpoint_variables.append("ANSWER_JUDGE_BASE_URL") for variable in endpoint_variables: if not env.get(variable): @@ -361,6 +381,11 @@ def main(): if dns_servers: command.extend(["--environment-kwarg", "egress_dns=" + ",".join(dns_servers)]) + if args.cpu_set is not None: + if not use_gateway: + command[command.index("--env") + 1] = "scripts.harbor_environments:GpuDocker" + command.extend(["--environment-kwarg", f"cpu_set={args.cpu_set}"]) + if args.gpu_device is not None: if not use_gateway: command[command.index("--env") + 1] = "scripts.harbor_environments:GpuDocker" @@ -377,6 +402,9 @@ def main(): if args.dry_run: print("Preview only; credentials, assets, Docker, GPU, and API access are not checked.") + if storage: + print(f"Bounded storage and {storage.cpus} logical CPUs are prepared automatically; " + f"optional overrides: {storage.variable}, {storage.cpu_variable}.") if use_gateway: mode = egress.settings.get("transport", "proxy") print(f"Phase-scoped {mode} gateway; task and phase allowlists are enforced by the trusted sidecar.") @@ -389,6 +417,9 @@ def main(): missing = [name for name in required if not env.get(name)] if missing: parser.error("Set the following variables in .env or the shell: " + ", ".join(missing)) + if args.check: + print("Model, credentials and configuration checks passed; no evaluation started.") + return 0 manifest = read_manifest(task) unavailable = [] for entry in manifest["files"]: @@ -419,6 +450,12 @@ def main(): env["PYTHONPATH"] += os.pathsep + existing_pythonpath print(f"Launching {task_key(REPO, task)}; job output: {output}", flush=True) + if storage: + try: + with prepared_storage(storage, env): + return run_command(command, env, REPO) + except (OSError, ValueError, RuntimeError, subprocess.SubprocessError) as error: + parser.error(str(error)) # All task and output paths are absolute, so launching works from any directory. os.chdir(REPO) os.execvpe(command[0], command, env) diff --git a/scripts/task_storage.py b/scripts/task_storage.py new file mode 100644 index 0000000..e65f852 --- /dev/null +++ b/scripts/task_storage.py @@ -0,0 +1,215 @@ +"""Host preparation for tasks using job-specific bounded filesystems.""" + +import argparse +from contextlib import contextmanager +from dataclasses import dataclass +import json +import os +from pathlib import Path +import platform +import re +import shlex +import shutil +import signal +import subprocess +import sys +import uuid + + +DOWNLOAD_ENV = ("http_proxy", "https_proxy", "HTTP_PROXY", "HTTPS_PROXY", "no_proxy", "NO_PROXY", + "SSL_CERT_FILE", "SSL_CERT_DIR") + + +@dataclass(frozen=True) +class StoragePlan: + task: Path + variable: str + cpu_variable: str + cpus: int + + @classmethod + def discover(cls, task, config): + if not (task / "tools/prepare_storage.py").is_file(): + return None + compose = (task / "environment/docker-compose.yaml").read_text() + variables = set(re.findall(r"\$\{([A-Z][A-Z0-9_]*_STORAGE_ROOT)(?=[:}])", compose)) + cpu_variables = set(re.findall(r"\$\{([A-Z][A-Z0-9_]*_CPUSET)(?=[:}])", compose)) + if len(variables) != 1 or len(cpu_variables) != 1: + raise ValueError("Bounded storage requires one STORAGE_ROOT and one CPUSET variable") + return cls(task, variables.pop(), cpu_variables.pop(), config["environment"]["cpus"]) + + def configure_cpus(self, env): + available = os.sched_getaffinity(0) + value = env.get(self.cpu_variable, "") + if value: + selected = set() + for item in value.split(","): + if not re.fullmatch(r"\d+(?:-\d+)?", item): + raise ValueError(f"{self.cpu_variable} must contain CPU IDs or ranges, such as 0-3,8-11") + bounds = list(map(int, item.split("-"))) + first, last = bounds[0], bounds[-1] + if last < first or last > max(available): + raise ValueError(f"{self.cpu_variable} contains an unavailable CPU range") + selected.update(range(first, last + 1)) + if len(selected) != self.cpus or not selected <= available: + raise ValueError(f"{self.cpu_variable} must select exactly {self.cpus} available logical CPUs") + else: + if len(available) < self.cpus: + raise ValueError(f"Task requires {self.cpus} logical CPUs; only {len(available)} are available") + selected = set(sorted(available)[:self.cpus]) + env[self.cpu_variable] = ",".join(map(str, sorted(selected))) + print(f"Task CPUs: {env[self.cpu_variable]} ({self.cpu_variable})", flush=True) + + +def docker_output(arguments, env=None): + return subprocess.run(["docker", *arguments], env=env, check=True, + capture_output=True, text=True, timeout=30).stdout + + +def check_host(env): + if platform.system() != "Linux" or platform.machine() not in {"x86_64", "amd64"}: + raise ValueError("Bounded task storage requires a local Linux x86_64 Docker host") + missing = [name for name in ("docker", "mkfs.ext4", "dpkg-deb") + if shutil.which(name) is None] + if missing: + raise ValueError("Missing host tools: " + ", ".join(missing) + + ". On Ubuntu/Debian, install e2fsprogs and dpkg, plus Docker Engine.") + if not Path("/dev/fuse").exists(): + raise ValueError("Missing /dev/fuse; enable FUSE on the Docker host (for example sudo modprobe fuse)") + context = json.loads(docker_output(["context", "inspect"], env))[0] + endpoint = (None if env.get("DOCKER_CONTEXT") else env.get("DOCKER_HOST")) or context["Endpoints"]["docker"]["Host"] + if not endpoint.startswith("unix://"): + raise ValueError("Bounded task storage requires a local Unix-socket Docker daemon") + info = json.loads(docker_output(["info", "--format", "{{json .}}"], env)) + security = " ".join(info.get("SecurityOptions", [])) + if (info.get("OSType") != "linux" or "rootless" in security or "userns" in security + or "desktop" in info.get("OperatingSystem", "").lower() + or info.get("KernelVersion") != platform.release()): + raise ValueError("Bounded task storage requires native Linux rootful Docker sharing the host kernel") + return endpoint + + +def require_unused(runtime, endpoint): + """Never unmount storage retained by an unfinished or stopped container.""" + prefix = ["--host", endpoint] + ids = docker_output([*prefix, "ps", "-aq"]).split() + if not ids: + return + mounts = docker_output([*prefix, "inspect", "--format", "{{json .Mounts}}", *ids]) + for line in mounts.splitlines(): + for mount in json.loads(line) or []: + source = Path(mount.get("Source", "/")) + if source == runtime or runtime in source.parents: + raise RuntimeError(f"Storage is still attached to a Docker container: {runtime}") + + +def worker(task, runtime_root, run_id, endpoint, owner_uid): + """Keep the one sudo session alive until Harbor has finished; receive no keys.""" + import fcntl + + signal.signal(signal.SIGINT, signal.SIG_IGN) + + runtime = runtime_root / run_id + prepare = [sys.executable, str(task / "tools/prepare_storage.py"), + "--runtime-root", str(runtime_root), "--run-id", run_id] + cleanup = [*prepare, "--unmount"] + try: + # The task's tool cache must not be unpacked by two simultaneous runs. + with (runtime_root / ".prepare.lock").open("a") as lock: + fcntl.flock(lock, fcntl.LOCK_EX) + subprocess.run(prepare, check=True, stdout=sys.stderr) + # Keep the run directory private to the caller. Images, evidence, + # filesystem roots and verifier markers retain their original ownership. + os.chown(runtime, owner_uid, -1) + runtime.chmod(0o700) + print("ready", flush=True) + if sys.stdin.readline().strip() != "cleanup": + raise RuntimeError("Launcher disconnected; run storage has been retained") + require_unused(runtime, endpoint) + subprocess.run(cleanup, check=True, stdout=sys.stderr) + return 0 + except (OSError, ValueError, RuntimeError, subprocess.SubprocessError) as error: + print(f"Storage preparation/cleanup failed: {error}", file=sys.stderr) + if runtime.exists(): + print("After its containers exit, recover with: sudo " + shlex.join(cleanup), file=sys.stderr) + return 1 + + +@contextmanager +def prepared_storage(plan, env): + endpoint = check_host(env) + plan.configure_cpus(env) + if env.get(plan.variable): + runtime = Path(env[plan.variable]) + if not runtime.is_absolute(): + raise ValueError(f"{plan.variable} must be an absolute path") + print(f"Using caller-managed storage: {runtime}", flush=True) + yield + return + prefix = [] + if os.getuid() != 0: + sudo = shutil.which("sudo") + if sudo is None: + raise ValueError("Storage preparation needs host root or sudo; install/configure sudo first") + print("Authorizing storage preparation and cleanup with sudo; Harbor retains your user identity.", flush=True) + subprocess.run([sudo, "-v"], check=True) + prefix = [sudo, "-n", "--preserve-env=" + ",".join(DOWNLOAD_ENV), "--"] + root = plan.task.parents[1] / "jobs/.task-storage" / plan.task.name + root.mkdir(parents=True, exist_ok=True, mode=0o700) + run_id = "run-" + uuid.uuid4().hex + runtime = root / run_id + command = [*prefix, sys.executable, str(Path(__file__).resolve()), "--worker", + "--task", str(plan.task), "--runtime-root", str(root), + "--run-id", run_id, "--docker-endpoint", endpoint, + "--owner-uid", str(os.geteuid())] + # Only the storage helper is privileged. Model credentials never enter its + # environment, command line or control pipe. sudo can use the terminal. + helper_env = {key: os.environ[key] for key in ("PATH", "HOME", "LANG", "TERM", *DOWNLOAD_ENV) + if key in os.environ} + helper = subprocess.Popen(command, env=helper_env, stdin=subprocess.PIPE, + stdout=subprocess.PIPE, text=True, start_new_session=not bool(prefix)) + ready = False + try: + ready = helper.stdout.readline().strip() == "ready" + if not ready: + raise RuntimeError("Bounded storage preparation failed; see the host setup error above") + env[plan.variable] = str(runtime) + print(f"Bounded task storage: {runtime}", flush=True) + yield + finally: + try: + helper.communicate("cleanup\n") + except BrokenPipeError: + helper.wait() + if ready and helper.returncode: + raise RuntimeError("Storage cleanup is incomplete; retained paths and recovery command are shown above") + + +def run_command(command, env, cwd): + """Forward cancellation to Harbor and wait for its container teardown.""" + child = subprocess.Popen(command, env=env, cwd=cwd, start_new_session=True) + def forward(signum, _frame): + try: + os.killpg(child.pid, signum) + except ProcessLookupError: + pass + previous = {sig: signal.signal(sig, forward) for sig in (signal.SIGINT, signal.SIGTERM)} + try: + result = child.wait() + return result if result >= 0 else 128 - result + finally: + for sig, handler in previous.items(): + signal.signal(sig, handler) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--worker", action="store_true", required=True) + parser.add_argument("--task", type=Path, required=True) + parser.add_argument("--runtime-root", type=Path, required=True) + parser.add_argument("--run-id", required=True) + parser.add_argument("--docker-endpoint", required=True) + parser.add_argument("--owner-uid", type=int, required=True) + arguments = parser.parse_args() + sys.exit(worker(arguments.task, arguments.runtime_root, arguments.run_id, + arguments.docker_endpoint, arguments.owner_uid)) diff --git a/scripts/tests/test_cli.py b/scripts/tests/test_cli.py new file mode 100644 index 0000000..fbc1e62 --- /dev/null +++ b/scripts/tests/test_cli.py @@ -0,0 +1,235 @@ +import argparse +from contextlib import redirect_stdout, redirect_stderr +import io +import json +import os +from pathlib import Path +import shutil +import stat +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "src")) + +from search_swe.cli import main, parser, configure_run +from search_swe.configuration import Setting, read_env, save_env, check_config_destination +from search_swe.repository import discover, find_repo +from search_swe.resources import Pool +from search_swe.runner import doctor, run_batch + + +class CliTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + (self.root / "scripts").mkdir() + (self.root / "scripts/run_task.py").touch() + (self.root / "scripts/download_assets.py").write_text('print("fixture assets verified")\n') + for name in ("first-search", "second-search", "third-search"): + task = self.root / "tasks" / name + (task / "environment").mkdir(parents=True) + (task / "environment/docker-compose.yaml").write_text('services:\n main: {}\n') + (task / "task.toml").write_text( + '[environment]\ncpus=2\nmemory_mb=1024\nstorage_mb=1024\ngpus=0\n' + 'network_mode="public"\n[agent]\nnetwork_mode="public"\n' + '[verifier]\nnetwork_mode="public"\n') + (task / "assets.json").write_text('{"schema_version":1,"files":[]}') + self.tasks = list(discover(self.root).values()) + + def test_dotenv_is_literal_atomic_private_and_preserves_unrelated_lines(self): + path = self.root / ".env" + path.write_text('# keep this\nUNCHANGED="original"\nKEY=old\nKEY=duplicate\n') + secret = "quote'backslash\\dollar$(never-run)${HOME}\nsecond line" + save_env(path, {"KEY": secret, "AGENT_MODEL": "example/model"}) + self.assertEqual(read_env(path)["KEY"], secret) + self.assertEqual(read_env(path)["UNCHANGED"], "original") + self.assertIn('# keep this\nUNCHANGED="original"\n', path.read_text()) + self.assertEqual(path.read_text().count("KEY="), 1) + self.assertEqual(stat.S_IMODE(path.stat().st_mode), 0o600) + + def test_invalid_dotenv_and_symlink_are_not_overwritten(self): + path = self.root / ".env" + original = 'KEY="unclosed\n' + path.write_text(original) + with self.assertRaises(ValueError): + save_env(path, {"KEY": "new"}) + self.assertEqual(path.read_text(), original) + link = self.root / "linked.env" + link.symlink_to(path) + with self.assertRaises(ValueError): + read_env(link) + + def test_repo_explicit_selection_is_not_silently_replaced(self): + self.assertEqual(find_repo(self.root), self.root) + with self.assertRaises(ValueError): + find_repo(self.root / "missing") + + def test_credentials_cannot_be_saved_to_a_public_template_or_tracked_file(self): + with self.assertRaises(ValueError): + save_env(self.root / ".env.example", {"KEY": "fixture"}) + subprocess.run(["git", "init", "-q", str(self.root)], check=True) + (self.root / ".gitignore").write_text('.env\n.env.*\n!.env.example\n') + check_config_destination(self.root, self.root / ".env") + check_config_destination(self.root, self.root / ".env.tmp-fixture") + for path in (self.root / ".env.example", self.root / "public.env"): + with self.assertRaises(ValueError): + check_config_destination(self.root, path) + + def test_fixed_judge_values_do_not_require_redundant_environment_keys(self): + task = self.tasks[0] + task.config["verifier"]["env"] = { + "ANSWER_JUDGE_MODEL_NAME": "fixed/model", "ANSWER_JUDGE_BASE_URL": "https://example.org", + "ANSWER_JUDGE_API_KEY": "${VERIFIER_OPENAI_API_KEY:-}", "JINA_API_KEY": "${JINA_API_KEY:-}"} + self.assertEqual(task.required_variables(), {"VERIFIER_OPENAI_API_KEY"}) + self.assertEqual(task.optional_keys(), {"JINA_API_KEY"}) + + def test_model_compatibility_and_provider_keys(self): + self.assertEqual(Setting().required(), {"AGENT_OPENROUTER_API_KEY"}) + for setting in (Setting("pi", "openrouter", "deepseek/deepseek-flash", "high"), + Setting("claude-code", "openrouter", "openai/gpt-6-luna", "max")): + with self.assertRaises(ValueError): + setting.validate() + self.assertEqual(Setting("pi", "direct", "zai/glm-5.3-flash", "high").validate().required(), {"ZAI_API_KEY"}) + + def test_host_preflight_uses_resolved_docker_configuration(self): + import platform + local = subprocess.CompletedProcess([], 0, json.dumps([ + {"Endpoints": {"docker": {"Host": "unix:///var/run/docker.sock"}}}])) + info = subprocess.CompletedProcess([], 0, json.dumps({ + "OSType": "linux", "KernelVersion": platform.release()})) + env = {"DOCKER_HOST": "tcp://remote:2375"} + with patch("search_swe.runner.shutil.which", return_value="fixture"), \ + patch("search_swe.runner.subprocess.run", return_value=local) as run: + with self.assertRaisesRegex(ValueError, "local Unix-socket"): + doctor(self.root, env=env) + self.assertEqual(run.call_args.kwargs["env"], env) + env["DOCKER_CONTEXT"] = "local-context" + with patch("search_swe.runner.shutil.which", return_value="fixture"), \ + patch("search_swe.runner.subprocess.run", side_effect=[local, info, local]) as run: + doctor(self.root, env=env) + self.assertTrue(all(call.kwargs["env"] == env for call in run.call_args_list)) + + def test_resource_allocations_are_disjoint_and_released(self): + pool = Pool({0, 1, 2, 3}, 4096, ["GPU-one"]) + pool.validate(self.tasks, {}) + first = pool.acquire(self.tasks[0], {}) + second = pool.acquire(self.tasks[1], {}) + self.assertFalse(first.cpus & second.cpus) + self.assertIsNone(pool.acquire(self.tasks[2], {})) + pool.release(first) + third = pool.acquire(self.tasks[2], {}) + self.assertEqual(third.cpus, first.cpus) + with self.assertRaises(ValueError): + Pool({0}, 4096, []).validate(self.tasks, {}) + with self.assertRaises(ValueError): + Pool({0, 1}, 1024, []).validate(self.tasks, {}) + + def test_gpu_tasks_cannot_share_one_device(self): + from dataclasses import replace + gpu_task = replace(self.tasks[0], gpus=1) + pool = Pool({0, 1, 2, 3}, 8192, ["GPU-one"]) + first = pool.acquire(gpu_task, {}) + self.assertEqual(first.gpu, "GPU-one") + self.assertIsNone(pool.acquire(gpu_task, {})) + pool.release(first) + self.assertIsNone(pool.acquire(gpu_task, {}, idle_gpus=set())) + self.assertEqual(pool.acquire(gpu_task, {}).gpu, "GPU-one") + + def test_explicit_task_cpu_binding_is_respected(self): + task = self.tasks[0] + (task.path / "environment/docker-compose.yaml").write_text('services:\n main:\n cpuset: ${FIXED_CPUSET:-0-1}\n') + pool = Pool({0, 1, 8, 9}, 8192, []) + chosen = pool.acquire(task, {"FIXED_CPUSET": "8-9"}) + self.assertEqual(chosen.cpus, {8, 9}) + with self.assertRaises(ValueError): + pool.validate([task], {"FIXED_CPUSET": "6-7"}) + + def fake_launcher(self, *, fail=False, cancel=False): + (self.root / "scripts/run_task.py").write_text( + 'import json, os, signal, sys, time\nfrom pathlib import Path\n' + 'if "--dry-run" in sys.argv or "--check" in sys.argv: raise SystemExit(0)\n' + 'task=sys.argv[sys.argv.index("--task")+1]\n' + 'cpus=sys.argv[sys.argv.index("--cpu-set")+1]\n' + 'output=Path(sys.argv[sys.argv.index("--output")+1]); output.mkdir()\n' + 'start=time.time(); time.sleep(0.4)\n' + + ('os.kill(os.getppid(), signal.SIGINT); time.sleep(5)\n' if cancel else '') + + '(output/"observed.json").write_text(json.dumps({"start":start,"end":time.time(),"cpus":cpus}))\n' + + ('raise SystemExit(7 if task=="first-search" else 0)\n' if fail else '')) + + def test_parallel_batch_overlaps_and_queues_without_cpu_overlap(self): + self.fake_launcher() + output = self.root / "batch" + pool = Pool({0, 1, 2, 3}, 8192, []) + with redirect_stdout(io.StringIO()) as console: + code = run_batch(self.root, self.tasks, Setting(), dict(os.environ), pool, 2, output) + self.assertEqual(code, 0) + self.assertNotIn("Waiting for an idle GPU", console.getvalue()) + records = [json.loads((output / task.name / "observed.json").read_text()) for task in self.tasks] + self.assertLess(max(records[0]["start"], records[1]["start"]), min(records[0]["end"], records[1]["end"])) + self.assertFalse(set(records[0]["cpus"].split(",")) & set(records[1]["cpus"].split(","))) + self.assertGreaterEqual(records[2]["start"], min(records[0]["end"], records[1]["end"])) + self.assertEqual(pool.used, []) + + def test_sequential_failure_is_recorded_and_other_tasks_continue(self): + self.fake_launcher(fail=True) + output = self.root / "batch" + pool = Pool({0, 1}, 8192, []) + env = dict(os.environ, AGENT_OPENROUTER_API_KEY="fixture-secret-not-for-logs") + with redirect_stdout(io.StringIO()): + code = run_batch(self.root, self.tasks[:2], Setting(), env, pool, 1, output) + self.assertEqual(code, 1) + raw = (output / "run.json").read_text() + report = json.loads(raw) + self.assertEqual(report["tasks"]["first-search"]["returncode"], 7) + self.assertEqual(report["tasks"]["second-search"]["status"], "completed") + self.assertNotIn("fixture-secret-not-for-logs", raw) + first = json.loads((output / "first-search/observed.json").read_text()) + second = json.loads((output / "second-search/observed.json").read_text()) + self.assertGreaterEqual(second["start"], first["end"]) + + def test_cancellation_releases_allocations_and_records_not_started_tasks(self): + self.fake_launcher(cancel=True) + output = self.root / "batch" + pool = Pool({0, 1}, 8192, []) + with redirect_stdout(io.StringIO()), self.assertRaises(KeyboardInterrupt): + run_batch(self.root, self.tasks[:2], Setting(), dict(os.environ), pool, 1, output) + self.assertEqual(pool.used, []) + report = json.loads((output / "run.json").read_text()) + self.assertEqual(report["status"], "cancelled") + self.assertEqual(report["tasks"]["second-search"]["status"], "not-started") + + def test_cli_dry_run_and_non_tty_never_write_env_or_start_work(self): + self.fake_launcher() + stdout, stderr = io.StringIO(), io.StringIO() + with patch.dict(os.environ, {}, clear=True), redirect_stdout(stdout), redirect_stderr(stderr): + self.assertEqual(main(["--repo", str(self.root), "run", "first-search", "--dry-run"]), 0) + with patch("sys.stdin.isatty", return_value=False): + self.assertEqual(main(["--repo", str(self.root), "run"]), 1) + self.assertFalse((self.root / ".env").exists()) + self.assertFalse((self.root / "jobs").exists()) + self.assertIn("--yes", stderr.getvalue()) + + def test_wizard_cancellation_does_not_save_entered_secret(self): + path = self.root / ".env" + path.write_text("# original\n") + args = parser().parse_args(["configure", "first-search"]) + def enter(setting, env, updates): + updates["AGENT_OPENROUTER_API_KEY"] = env["AGENT_OPENROUTER_API_KEY"] = "fixture-entered-secret" + with redirect_stdout(io.StringIO()) as output, \ + patch.dict(os.environ, {}, clear=True), patch("sys.stdin.isatty", return_value=True), \ + patch("sys.stdout.isatty", return_value=True), patch("search_swe.wizard.select_setting", return_value=Setting()), \ + patch("search_swe.wizard.configure_agent", side_effect=enter), \ + patch("search_swe.wizard.configure_task_services"), patch("search_swe.wizard.concurrency", return_value=1), \ + patch("search_swe.wizard.ask", side_effect=[True, False]), patch("search_swe.runner.preflight"): + self.assertEqual(configure_run(args, self.root), 0) + self.assertEqual(path.read_text(), "# original\n") + self.assertNotIn("fixture-entered-secret", output.getvalue()) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/test_cpu_affinity.py b/scripts/tests/test_cpu_affinity.py new file mode 100644 index 0000000..c5c38f3 --- /dev/null +++ b/scripts/tests/test_cpu_affinity.py @@ -0,0 +1,37 @@ +import copy +import os +from pathlib import Path +import unittest +from unittest.mock import patch + +import yaml + +from scripts.cpu_affinity import parse_cpu_set, select_compose_cpus, validate_cpu_set + + +class CpuAffinityTests(unittest.TestCase): + def test_count_and_host_affinity_are_enforced(self): + with patch("os.sched_getaffinity", return_value={2, 3, 8, 9}): + self.assertEqual(validate_cpu_set("8-9,2-3", 4), "2,3,8,9") + for value in ("0-3", "2-3", "9-2", "2,,3", "2-999999999"): + with self.subTest(value=value), self.assertRaises(ValueError): + validate_cpu_set(value, 4) + self.assertEqual(validate_cpu_set("1000-1001", 2, check_host=False), "1000,1001") + + def test_all_task_services_keep_every_other_resource_and_mount(self): + root = Path(__file__).resolve().parents[2] + for path in (root / "tasks").glob("*/*/docker-compose.yaml"): + with self.subTest(path=path): + document = yaml.safe_load(path.read_text()) + original = copy.deepcopy(document) + selected = select_compose_cpus(document, "2,3") + for name, service in selected["services"].items(): + self.assertEqual(service.pop("cpuset"), "2,3") + expected = copy.deepcopy(original["services"][name]) + expected.pop("cpuset", None) + self.assertEqual(service, expected) + self.assertEqual(document, original) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/test_model_catalog.py b/scripts/tests/test_model_catalog.py new file mode 100644 index 0000000..a645270 --- /dev/null +++ b/scripts/tests/test_model_catalog.py @@ -0,0 +1,145 @@ +from contextlib import redirect_stdout +import io +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch +from urllib.error import URLError + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "src")) + +from prompt_toolkit.document import Document +from search_swe.cli import parser +from search_swe.configuration import Setting +from search_swe.model_catalog import ModelCompleter, eligible, load_models, parse_models +from search_swe.wizard import select_setting + + +def fixture(): + return {"data": [ + {"id": "vendor/solver-v2", "name": "Vendor: Reasoning Solver", + "context_length": 100000, "supported_parameters": ["tools", "reasoning"], + "endpoints": ["/api/v1/responses"], "architecture": {"output_modalities": ["text"]}}, + {"id": "anthropic/claude-new", "name": "Claude New", "supported_parameters": ["tools", "reasoning_effort"], + "endpoints": ["/api/v1/messages", "/api/v1/responses"]}, + {"id": "vendor/plain", "name": "Plain", "supported_parameters": ["tools"]}, + {"id": "vendor/no-tools", "supported_parameters": ["temperature"]}, + {"id": "vendor/image", "architecture": {"output_modalities": ["image"]}}, + {"id": "~vendor/solver-latest", "supported_parameters": ["tools"]}, + ]} + + +class ModelCatalogTests(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + self.cache = self.root / "models.json" + + def test_live_catalog_then_cache_without_credentials(self): + class Response(io.BytesIO): + pass + with patch("search_swe.model_catalog.urlopen", return_value=Response(json.dumps(fixture()).encode())) as request: + models, source = load_models(cache=self.cache) + self.assertEqual(source, "live") + self.assertIn("vendor/solver-v2", models) + self.assertNotIn("Authorization", request.call_args.args[0].headers) + with patch("search_swe.model_catalog.urlopen") as request: + self.assertEqual(load_models(cache=self.cache), (models, "cache")) + request.assert_not_called() + + def test_refresh_failure_and_offline_use_stale_cache(self): + self.cache.write_text(json.dumps({**fixture(), "fetched_at": 1})) + with patch("search_swe.model_catalog.urlopen", side_effect=URLError("fixture offline")) as request: + self.assertEqual(load_models(cache=self.cache, refresh=True)[1], "stale cache") + request.assert_called_once() + with patch("search_swe.model_catalog.urlopen") as request: + self.assertEqual(load_models(cache=self.cache, offline=True, refresh=True)[1], "stale cache") + request.assert_not_called() + + def test_corrupt_or_missing_catalog_keeps_manual_input_available(self): + self.cache.write_text("not json") + with patch("search_swe.model_catalog.urlopen", side_effect=TimeoutError): + self.assertEqual(load_models(cache=self.cache), ({}, "unavailable")) + self.assertEqual(load_models(cache=self.root / "missing", offline=True), ({}, "unavailable")) + + def test_capability_filters_and_terminal_text(self): + data = fixture() + data["data"][0]["name"] = "Solver\x1b\n" + models = parse_models(data) + self.assertEqual(models["vendor/solver-v2"]["name"], "Solver") + self.assertTrue(eligible(models["vendor/solver-v2"], "codex")) + self.assertFalse(eligible(models["vendor/solver-v2"], "claude-code")) + self.assertTrue(eligible(models["anthropic/claude-new"], "claude-code")) + self.assertFalse(eligible(models["vendor/no-tools"], "codex")) + self.assertFalse(eligible(models["vendor/image"], "codex")) + self.assertTrue(eligible({"id": "vendor/unlisted"}, "codex")) + self.assertTrue(eligible(models["~vendor/solver-latest"], "codex")) + + def test_search_matches_name_and_id_and_returns_full_model_slug(self): + completer = ModelCompleter(parse_models(fixture())) + rows = list(completer.get_completions(Document("REASONING v2"), None)) + self.assertEqual([row.text for row in rows], ["vendor/solver-v2"]) + self.assertEqual(rows[0].start_position, -len("REASONING v2")) + self.assertIn("100,000", rows[0].display_meta_text) + self.assertEqual(list(completer.get_completions(Document("absent"), None)), []) + + def test_wizard_selects_harness_then_provider_then_search_then_effort(self): + with patch("search_swe.wizard.load_models", return_value=(parse_models(fixture()), "live")), \ + patch("search_swe.wizard.ask", side_effect=["claude-code", "openrouter", "anthropic/claude-new", "default"]), \ + patch("search_swe.wizard.questionary.select") as select, \ + patch("search_swe.wizard.questionary.autocomplete") as autocomplete, redirect_stdout(io.StringIO()): + setting = select_setting(Setting(), parser().parse_args(["configure"])) + self.assertEqual(setting, Setting("claude-code", "openrouter", "anthropic/claude-new", "default")) + self.assertEqual([call.args[0] for call in select.call_args_list], ["Harness", "Provider", "Reasoning / thinking level"]) + options = autocomplete.call_args.kwargs + models = options["completer"].models + self.assertEqual([model["id"] for model in models], ["anthropic/claude-new"]) + self.assertIsNot(options["validate"]("vendor/solver-v2"), True) + self.assertIs(options["validate"]("anthropic/custom-model"), True) + + def test_model_without_reasoning_uses_default_and_pi_stays_direct(self): + with patch("search_swe.wizard.load_models", return_value=(parse_models(fixture()), "cache")), \ + patch("search_swe.wizard.ask", side_effect=["codex", "openrouter", "vendor/plain"]), \ + patch("search_swe.wizard.questionary.select") as select, \ + patch("search_swe.wizard.questionary.autocomplete"), redirect_stdout(io.StringIO()): + self.assertEqual(select_setting(Setting(), parser().parse_args(["configure"])).effort, "default") + self.assertEqual(select.call_count, 2) + with patch("search_swe.wizard.load_models") as load: + with self.assertRaises(ValueError): + select_setting(Setting(), parser().parse_args(["configure", "--agent", "pi", "--provider", "openrouter"])) + load.assert_not_called() + + def test_model_default_reaches_harbor_as_null_and_omits_effort_flags(self): + from harbor.cli.utils import parse_kwargs + from scripts.harbor_agents import PreinstalledCodex, PreinstalledClaudeCode, PreinstalledPi + for harness, model, cls, flag in ( + ("codex", "vendor/solver-v2", PreinstalledCodex, "reasoning_effort"), + ("codex", "~vendor/solver-latest", PreinstalledCodex, "reasoning_effort"), + ("claude-code", "anthropic/claude-new", PreinstalledClaudeCode, "reasoning_effort"), + ("pi", "deepseek/deepseek-flash", PreinstalledPi, "thinking"), + ): + args = [sys.executable, str(ROOT / "scripts/run_task.py"), "--task", "reasoning-query-rewriting", + "--no-env-file", "--agent", harness, "--model", model, "--dry-run", + "--thinking" if harness == "pi" else "--reasoning-effort", "default"] + if harness != "pi": + args.append("--openrouter") + env = {"PATH": os.environ["PATH"], "HOME": str(self.root)} + result = subprocess.run(args, capture_output=True, text=True, env=env) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn(f"--ak {flag}=null", result.stdout) + with patch.dict(os.environ, env, clear=True): + agent = cls(logs_dir=self.root / harness, model_name=model, **parse_kwargs([f"{flag}=null"])) + self.assertNotIn(flag, agent._resolved_flags) + self.assertNotIn("--effort", agent.build_cli_flags()) + self.assertNotIn("model_reasoning_effort", agent.build_cli_flags()) + self.assertNotIn("--thinking", agent.build_cli_flags()) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/test_openrouter_agents.py b/scripts/tests/test_openrouter_agents.py index 6641dd0..5f7df24 100644 --- a/scripts/tests/test_openrouter_agents.py +++ b/scripts/tests/test_openrouter_agents.py @@ -43,6 +43,16 @@ def test_codex_fails_if_harbor_command_shape_changes(self): with self.assertRaisesRegex(RuntimeError, "command format changed"): asyncio.run(agent.exec_as_agent(object(), "codex exec --model changed --json")) + def test_codex_quotes_openrouter_alias_without_shell_expansion(self): + agent = PreinstalledOpenRouterCodex( + logs_dir=Path("/tmp"), model_name="~vendor/solver-latest", config=CONFIG + ) + with patch.object(Codex, "exec_as_agent", new_callable=AsyncMock) as execute: + asyncio.run(agent.exec_as_agent( + object(), "codex exec --model solver-latest --json -- task" + )) + self.assertIn("--model '~vendor/solver-latest' --json", execute.call_args.args[1]) + def test_claude_uses_bearer_token_and_complete_model(self): agent = PreinstalledOpenRouterClaudeCode( logs_dir=Path("/tmp"), model_name="anthropic/claude-opus-5.5", diff --git a/scripts/tests/test_task_storage.py b/scripts/tests/test_task_storage.py new file mode 100644 index 0000000..055e2a5 --- /dev/null +++ b/scripts/tests/test_task_storage.py @@ -0,0 +1,220 @@ +import json +import os +from pathlib import Path +import shutil +import signal +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch + +from scripts.task_storage import StoragePlan, prepared_storage, require_unused, run_command + + +class StorageTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + self.task = self.root / "tasks/example-search" + (self.task / "tools").mkdir(parents=True) + (self.task / "environment").mkdir() + (self.task / "environment/docker-compose.yaml").write_text( + 'services:\n main:\n cpuset: ${EXAMPLE_CPUSET:-80,81}\n' + ' volumes: ["${EXAMPLE_STORAGE_ROOT:?Prepare storage}/agent:/app"]\n') + # A real helper subprocess exercises the lifecycle without sudo, FUSE, + # model calls or Docker. The production task scripts are smoke-tested + # separately on a supported host. + (self.task / "tools/prepare_storage.py").write_text( + 'import argparse, os\nfrom pathlib import Path\n' + 'p=argparse.ArgumentParser()\n' + 'p.add_argument("--runtime-root", type=Path)\np.add_argument("--run-id")\n' + 'p.add_argument("--unmount", action="store_true")\na=p.parse_args()\n' + 'assert "AGENT_OPENROUTER_API_KEY" not in os.environ\n' + 'assert "VERIFIER_OPENAI_API_KEY" not in os.environ\n' + 'r=a.runtime_root/a.run_id\n' + 'if a.unmount: (r/"unmounted").touch()\n' + 'else: r.mkdir(); (r/"prepared").touch()\n') + self.bin = self.root / "bin" + self.bin.mkdir() + docker = self.bin / "docker" + docker.write_text(f'#!{sys.executable}\n# No retained containers.\n') + docker.chmod(0o755) + self.plan = StoragePlan.discover(self.task, {"environment": {"cpus": 2}}) + + def test_cpu_selection_uses_host_affinity_not_fixed_large_ids(self): + env = {} + with patch("os.sched_getaffinity", return_value={4, 6, 8}): + self.plan.configure_cpus(env) + self.assertEqual(env["EXAMPLE_CPUSET"], "4,6") + + def test_explicit_cpu_binding_is_preserved_and_validated(self): + with patch("os.sched_getaffinity", return_value={4, 5, 8}): + env = {"EXAMPLE_CPUSET": "4-5"} + self.plan.configure_cpus(env) + self.assertEqual(env["EXAMPLE_CPUSET"], "4-5") + for value in ("0-1", "4", "4,5,8", "4,,5", "8-4", "4-9999999999"): + with self.subTest(value=value), self.assertRaises(ValueError): + self.plan.configure_cpus({"EXAMPLE_CPUSET": value}) + + def test_insufficient_cpus_fails_before_start(self): + with patch("os.sched_getaffinity", return_value={3}), self.assertRaises(ValueError): + self.plan.configure_cpus({}) + + def test_other_tasks_need_no_storage_helper(self): + (self.task / "tools/prepare_storage.py").unlink() + self.assertIsNone(StoragePlan.discover(self.task, {})) + + def test_standard_entrypoint_prepares_passes_environment_and_cleans(self): + if os.getuid() != 0 or len(os.sched_getaffinity(0)) < 2: + self.skipTest("CLI fixture needs host root and two CPUs; sudo path is tested separately") + scripts = Path(__file__).resolve().parents[1] + shutil.copytree(scripts, self.root / "scripts", ignore=shutil.ignore_patterns("tests", "__pycache__")) + (self.task / "task.toml").write_text( + '[environment]\ncpus=2\ngpus=0\nnetwork_mode="public"\n' + '[agent]\nnetwork_mode="public"\n[verifier]\nnetwork_mode="public"\n') + (self.task / "assets.json").write_text('{"schema_version":1,"files":[]}') + (self.bin / "docker").write_text( + f'#!{sys.executable}\nimport json, platform, sys\n' + 'if "context" in sys.argv: print(json.dumps([{"Endpoints":{"docker":{"Host":"unix:///fixture"}}}]))\n' + 'elif "info" in sys.argv: print(json.dumps({"OSType":"linux","KernelVersion":platform.release()}))\n') + observed = self.root / "harbor-observed.json" + harbor = self.bin / "harbor" + harbor.write_text( + f'#!{sys.executable}\nimport json, os, sys\nfrom pathlib import Path\n' + 'assert "fixture-agent-secret" not in " ".join(sys.argv)\n' + 'assert os.environ["AGENT_OPENAI_API_KEY"] == "fixture-agent-secret"\n' + 'root=Path(os.environ["EXAMPLE_STORAGE_ROOT"])\n' + 'assert (root/"prepared").exists()\n' + f'Path({str(observed)!r}).write_text(json.dumps({{"root":str(root),"cpus":os.environ["EXAMPLE_CPUSET"]}}))\n' + 'raise SystemExit(7)\n') + harbor.chmod(0o755) + env = {"PATH": str(self.bin) + os.pathsep + os.environ["PATH"], + "HOME": str(self.root), "AGENT_OPENAI_BASE_URL": "https://agent.example/v1", + "AGENT_OPENAI_API_KEY": "fixture-agent-secret"} + command = [sys.executable, str(self.root / "scripts/run_task.py"), + "--task", "example-search", "--model", "fixture-model"] + preview = subprocess.run([*command, "--dry-run"], env=env, capture_output=True, text=True, timeout=30) + self.assertEqual(preview.returncode, 0, preview.stderr) + self.assertFalse((self.root / "jobs").exists()) + result = subprocess.run(command, env=env, capture_output=True, text=True, timeout=30) + self.assertEqual(result.returncode, 7, result.stderr) + record = json.loads(observed.read_text()) + self.assertEqual(len(record["cpus"].split(",")), 2) + self.assertTrue((Path(record["root"]) / "unmounted").exists()) + self.assertNotIn("fixture-agent-secret", result.stdout + result.stderr) + + def lifecycle(self, failure=False): + host_env = {"PATH": str(self.bin) + os.pathsep + os.environ["PATH"], + "AGENT_OPENROUTER_API_KEY": "fixture-agent-secret", + "VERIFIER_OPENAI_API_KEY": "fixture-verifier-secret"} + env = dict(os.environ) | host_env + with patch.dict(os.environ, host_env), patch("os.getuid", return_value=0), \ + patch("os.sched_getaffinity", return_value={2, 3}), \ + patch("scripts.task_storage.check_host", return_value="unix:///fixture"): + try: + with prepared_storage(self.plan, env): + runtime = Path(env["EXAMPLE_STORAGE_ROOT"]) + self.assertTrue((runtime / "prepared").exists()) + self.assertFalse((runtime / "unmounted").exists()) + self.assertEqual(env["EXAMPLE_CPUSET"], "2,3") + if failure: + raise ValueError("synthetic launch failure") + result = run_command([sys.executable, "-c", "raise SystemExit(7)"], env, self.root) + self.assertEqual(result, 7) + except ValueError: + if not failure: + raise + self.assertTrue((runtime / "unmounted").exists()) + self.assertTrue((runtime / "prepared").exists()) + return runtime + + def test_nonzero_harbor_exit_cleans_storage_and_retains_evidence(self): + first = self.lifecycle() + second = self.lifecycle() + self.assertNotEqual(first, second) + + def test_launch_exception_cleans_storage(self): + self.lifecycle(failure=True) + + def test_sudo_is_authorized_once_and_helper_handles_cleanup(self): + record = self.root / "sudo-calls.jsonl" + sudo = self.bin / "sudo" + sudo.write_text( + f'#!{sys.executable}\nimport os, sys, json\n' + f'with open({str(record)!r}, "a") as f: f.write(json.dumps(sys.argv[1:])+"\\n")\n' + 'if sys.argv[1:] != ["-v"]:\n' + ' args=sys.argv[sys.argv.index("--")+1:]; os.execv(args[0], args)\n') + sudo.chmod(0o755) + env = dict(os.environ) + with patch.dict(os.environ, {"PATH": str(self.bin) + os.pathsep + os.environ["PATH"]}), \ + patch("os.getuid", return_value=1234), \ + patch("os.sched_getaffinity", return_value={2, 3}), \ + patch("scripts.task_storage.check_host", return_value="unix:///fixture"): + with prepared_storage(self.plan, env): + runtime = Path(env["EXAMPLE_STORAGE_ROOT"]) + calls = [json.loads(line) for line in record.read_text().splitlines()] + self.assertEqual(len(calls), 2) + self.assertEqual(calls[0], ["-v"]) + self.assertEqual(calls[1][0], "-n") + self.assertTrue(calls[1][1].startswith("--preserve-env=http_proxy,")) + self.assertTrue((runtime / "unmounted").exists()) + + def test_busy_storage_is_retained_and_cleanup_failure_is_reported(self): + (self.bin / "docker").write_text( + f'#!{sys.executable}\nimport json, sys\nfrom pathlib import Path\n' + 'if "ps" in sys.argv: print("retained-container")\n' + f'else: print(json.dumps([{{"Source":str(next(Path({str(self.root)!r}).glob("jobs/.task-storage/example-search/run-*"))/"agent")}}]))\n') + env = dict(os.environ) + with patch.dict(os.environ, {"PATH": str(self.bin) + os.pathsep + os.environ["PATH"]}), \ + patch("os.getuid", return_value=0), \ + patch("os.sched_getaffinity", return_value={2, 3}), \ + patch("scripts.task_storage.check_host", return_value="unix:///fixture"): + with self.assertRaisesRegex(RuntimeError, "cleanup is incomplete"): + with prepared_storage(self.plan, env): + runtime = Path(env["EXAMPLE_STORAGE_ROOT"]) + self.assertTrue((runtime / "prepared").exists()) + self.assertFalse((runtime / "unmounted").exists()) + + def test_caller_managed_storage_is_not_unmounted(self): + runtime = self.root / "existing" + env = {"EXAMPLE_STORAGE_ROOT": str(runtime)} + with patch("os.sched_getaffinity", return_value={2, 3}), \ + patch("scripts.task_storage.check_host", return_value="unix:///fixture"), \ + patch("scripts.task_storage.subprocess.Popen") as start: + with prepared_storage(self.plan, env): + self.assertEqual(env["EXAMPLE_STORAGE_ROOT"], str(runtime)) + start.assert_not_called() + + def test_cleanup_refuses_containers_that_reference_run_storage(self): + runtime = self.root / "run" + for source, busy in ((str(runtime / "agent/app"), True), + (str(runtime) + "-another/agent/app", False)): + with self.subTest(source=source), patch("scripts.task_storage.docker_output", side_effect=[ + "container-id\n", json.dumps([{"Source": source}]) + "\n"]): + if busy: + with self.assertRaises(RuntimeError): + require_unused(runtime, "unix:///fixture") + else: + require_unused(runtime, "unix:///fixture") + + def test_cleanup_preserves_storage_when_docker_cannot_be_inspected(self): + with patch("scripts.task_storage.docker_output", side_effect=subprocess.TimeoutExpired("docker", 30)): + with self.assertRaises(subprocess.TimeoutExpired): + require_unused(self.root, "unix:///fixture") + + def test_termination_is_forwarded_and_waits_for_harbor_exit(self): + code = ( + 'import os, signal, time\n' + 'signal.signal(signal.SIGTERM, lambda *_: exit(23))\n' + 'os.kill(os.getppid(), signal.SIGTERM)\n' + 'time.sleep(5)\n' + ) + previous = signal.getsignal(signal.SIGTERM) + self.assertEqual(run_command([sys.executable, "-c", code], dict(os.environ), self.root), 23) + self.assertEqual(signal.getsignal(signal.SIGTERM), previous) + + +if __name__ == "__main__": + unittest.main() diff --git a/skills/run-searchswe-evaluation/SKILL.md b/skills/run-searchswe-evaluation/SKILL.md new file mode 100644 index 0000000..2406b19 --- /dev/null +++ b/skills/run-searchswe-evaluation/SKILL.md @@ -0,0 +1,132 @@ +--- +name: run-searchswe-evaluation +description: Configure, launch, monitor and summarize evaluations of existing Search-SWE tasks using the search-swe CLI. Use when the user specifies models, harnesses, providers, tasks or resource settings for benchmark runs, including preparation-only requests. +license: Apache-2.0 +--- + +# Run Search-SWE evaluations + +Translate the user's requested settings and tasks into reproducible CLI batches, +follow their progress, and report scores together with verifier health. This skill +coordinates the host-side runner; the selected harness runs the evaluated coding +agent inside the task environment. Preserve task instructions, resource budgets, +input specifications and verifier contracts while operating the benchmark. + +## Locate the runtime + +The installed skill is portable; its directory is independent of the benchmark +checkout. Locate the checkout from the user's path, `SEARCH_SWE_REPO`, or the +current workspace. Reuse its existing branch and working tree. If a checkout is +needed, obtain `https://github.com/VectorSpaceLab/Search-SWE` in the user's chosen +workspace. Task definitions and backend scripts come from that checkout. + +Use a Python 3.12+ host environment with the Search-SWE package installed. Check +`search-swe --version` and `search-swe run --help`. The current development +checkout installs with `python -m pip install -e /path/to/Search-SWE`; the published +distribution installs with `python -m pip install search-swe`. An installed skill +provides instructions; the package supplies the executable. + +Read `docs/cli.md` in the selected checkout for its supported flags. Read +`docs/evaluation.md` only for provider, network or bounded-storage setup details. +These paths are relative to the benchmark checkout, not this installed skill. + +## Resolve the requested experiment + +- Preserve the requested harness, provider, complete model ID, reasoning/thinking + level, task names, concurrency and CPU/GPU/RAM pool. Use existing configuration + for omitted values, and identify material ambiguities before launching. +- Discover names/resources with `search-swe --repo /path/to/Search-SWE list --json`. + Inspect the selected task READMEs and `task.toml` for runtime and service needs. +- Codex supports OpenRouter or a direct endpoint; Claude Code supports its direct + Anthropic endpoint and Anthropic models through OpenRouter; Pi uses the + checkout's supported direct adapters. For an ambiguous OpenRouter name, consult + `https://openrouter.ai/api/v1/models` or cached catalog metadata described in + the checkout's CLI guide, and resolve the full ID. + Directory metadata alone is not proof of harness compatibility. +- Use one model setting per batch and one attempt per selected task by default. + For a requested model matrix, create separate batches and coordinate resources + across them. The CLI coordinates allocations within one invocation. +- The repository default is Codex + OpenRouter + `openai/gpt-6-luna`, reasoning + `max`. For a new model with no specified or matching saved effort, use `--reasoning-effort default` + to keep its model/harness configuration. This is distinct from disabling reasoning. + +A request to run a concrete experiment authorizes that batch. Continue once its +configuration is ready; ask for missing choices, credentials or resource access +that actually block it. For a preparation-only request, finish configuration and +the command preview without launching an evaluation. + +## Prepare configuration and resources + +For execution, run `search-swe --repo /path/to/Search-SWE doctor --json` and inspect +available host resources. Configuration can be prepared before runtime host +requirements are satisfied; report what remains before an actual launch. +Choose a dedicated pool on shared machines with `--cpu-set`, +`--gpu-devices` and `--memory-gb` when appropriate. `--concurrency` is a maximum; +the CLI queues tasks until their allocations fit. Bounded-storage tasks need +host FUSE and root/sudo; arrange authorization before an unattended launch. + +Reuse the ignored checkout `.env`, an explicitly selected `--env-file`, or +exported variables. Explicit CLI options override saved settings; exported +variables override file values. Inspect credential presence through the CLI's +validation errors or boolean checks, keeping values out of chat and tool output. + +When a required key is missing, name the variable and its purpose, and ask the +user to configure it locally. Give them `search-swe --repo /path/to/Search-SWE +configure` with the requested task/setting flags so they can use hidden input, +or identify the private env file to edit. Resume after configuration is available. +Keep agent, trajectory-judge, answer-judge and submission-resource credentials in +their declared roles; reuse a key across roles only when the user has selected it. + +Use `configure ... --yes` to validate and save specified defaults when the needed +credentials are already available. It makes no model calls. Keep environment files +Git-ignored and mode `0600`; the CLI preserves unrelated settings. Optional task +APIs follow the task's resource documentation and the user's choices. + +## Preview and launch + +Use explicit flags and `--yes` for agent-driven execution, avoiding a terminal +wizard waiting on an agent tool's stdin. For example, from the selected checkout: + +```bash +search-swe run agentic-search agentic-search-vectorless \ + --agent codex --provider openrouter --model openai/gpt-6-luna \ + --reasoning-effort max --concurrency 2 --dry-run +``` + +After configuration and resources are ready, replace `--dry-run` with `--yes` for +the authorized run. The preview does not validate credentials, assets or API +connectivity. Runtime preflight checks the configuration before data restoration +and startup; it is not a paid model probe. Use the normal runner for evaluation +and retain its asset checks and phase isolation. + +The CLI restores verified assets, prepares bounded storage where needed, and +launches the task harness/verifier. `--offline` uses cached assets and model +metadata; evaluation still needs its configured model APIs. Use a fresh `--output` +path or the generated `jobs/evaluations//` directory. Output paths are +relative to the invoking directory when supplied; use absolute paths for handoffs. + +For long experiments, use an available persistent execution session or a detached +process with stdin closed and launcher output redirected to a private log. Record +the session/PID, command without secret values, checkout revision, batch directory +and log path. Verify the process started and the batch summary appeared. Keep any +launcher log outside a custom `--output` directory, which must not exist at startup. + +## Monitor and report + +Read the batch `run.json` and task log tails at intervals suited to the task's +runtime and the user's preference. Check the recorded task output directories for +Harbor results, rewards, verifier errors and judge health. Asset restoration may +take time before any task enters its agent phase. + +`completed` in `run.json` means the launcher exited successfully; it is not a +passing score. Distinguish an observed task score from setup/authentication, +infrastructure or verifier failures. A running process or command preview is not +a completed evaluation. Report task, model/harness/provider/effort, status, score +when available, judge/verifier health and paths to evidence. + +On interruption, signal the recorded CLI process with SIGINT or SIGTERM and allow +its teardown to finish. If storage remains attached, follow that run's printed +recovery instructions after its containers exit. Keep completed results and +unrelated workloads intact. Diagnose a failed attempt before proposing a targeted +rerun, respecting the user's retry and spending limits; a low score is a result. +For an ongoing background run, report its actual state and monitoring handles. diff --git a/skills/run-searchswe-evaluation/agents/openai.yaml b/skills/run-searchswe-evaluation/agents/openai.yaml new file mode 100644 index 0000000..ef2b23b --- /dev/null +++ b/skills/run-searchswe-evaluation/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Run Search-SWE Evaluations" + short_description: "Configure and run Search-SWE benchmark experiments" + default_prompt: "Use $run-searchswe-evaluation to configure and run my chosen model settings and tasks, then report scores, verifier health and result paths." diff --git a/src/search_swe/__init__.py b/src/search_swe/__init__.py new file mode 100644 index 0000000..f35b8fc --- /dev/null +++ b/src/search_swe/__init__.py @@ -0,0 +1,3 @@ +"""Search-SWE evaluation CLI.""" + +__version__ = "0.1.0.dev0" diff --git a/src/search_swe/__main__.py b/src/search_swe/__main__.py new file mode 100644 index 0000000..eb53e2f --- /dev/null +++ b/src/search_swe/__main__.py @@ -0,0 +1,3 @@ +from .cli import main + +raise SystemExit(main()) diff --git a/src/search_swe/cli.py b/src/search_swe/cli.py new file mode 100644 index 0000000..97c106b --- /dev/null +++ b/src/search_swe/cli.py @@ -0,0 +1,168 @@ +"""Command-line entrypoint for configuration and evaluation batches.""" + +import argparse +import json +import os +from pathlib import Path +import subprocess +import sys + +from . import __version__ +from .configuration import DEFAULTS, Setting, read_env, save_env, check_config_destination +from .repository import discover, find_repo, select_tasks +from .resources import Pool +from . import runner + + +def parser(): + root = argparse.ArgumentParser(prog="search-swe", description="Configure and run Search-SWE evaluations") + root.add_argument("--version", action="version", version=__version__) + root.add_argument("--repo", type=Path, help="Search-SWE checkout (or SEARCH_SWE_REPO)") + commands = root.add_subparsers(dest="command") + for name in ("run", "configure", "list", "doctor"): + command = commands.add_parser(name) + command.add_argument("--repo", type=Path, default=argparse.SUPPRESS) + if name in ("list", "doctor"): + command.add_argument("--json", action="store_true") + if name == "list": + command.add_argument("--hardware", choices=("cpu", "gpu")) + if name not in ("run", "configure"): + continue + command.add_argument("tasks", nargs="*", help="Task names; omit for the interactive selector") + command.add_argument("--task", action="append", default=[], help="Select a task; repeat for multiple tasks") + command.add_argument("--model") + command.add_argument("--agent", choices=("codex", "claude-code", "pi")) + command.add_argument("--provider", choices=("openrouter", "direct")) + command.add_argument("--reasoning-effort", help="Reasoning effort / Pi thinking level, or default to omit the override") + command.add_argument("--refresh-models", action="store_true", help="Refresh the OpenRouter catalog during interactive model selection") + command.add_argument("--env-file", type=Path, help="Defaults to the checkout .env") + command.add_argument("--concurrency", type=int, help="Maximum concurrent tasks; default 1") + command.add_argument("--cpu-set", help="CPU pool, e.g. 0-31") + command.add_argument("--gpu-devices", help="GPU pool: comma-separated indices or full UUIDs") + command.add_argument("--memory-gb", type=float, help="Cap the batch RAM pool in GiB") + command.add_argument("--output", type=Path, help="Fresh batch output directory") + command.add_argument("--offline", action="store_true", help="Use cached assets/model catalog; evaluation API calls still need network") + command.add_argument("--dry-run", action="store_true", help="Show the setting/tasks without prompts, writes, downloads or API calls") + command.add_argument("--yes", action="store_true", help="Use arguments/saved configuration without interactive prompts") + return root + + +def configure_run(args, root): + catalog = discover(root) + path = (args.env_file or root / ".env").expanduser().absolute() + file_values = read_env(path) + env = {**DEFAULTS, **file_values, **os.environ} + env = {key: value for key, value in env.items() if value is not None} + current = Setting(args.agent or env.get("SEARCH_SWE_AGENT") or "codex", + args.provider or env.get("SEARCH_SWE_PROVIDER") or "openrouter", + args.model or env.get("AGENT_MODEL") or "openai/gpt-6-luna", + args.reasoning_effort or env.get("PI_THINKING" if (args.agent or env.get("SEARCH_SWE_AGENT")) == "pi" else "AGENT_REASONING_EFFORT") or "max") + interactive = not args.yes and not args.dry_run + if interactive and not (sys.stdin.isatty() and sys.stdout.isatty()): + raise ValueError("Interactive setup needs a terminal; provide task/configuration arguments and --yes for scripts") + updates = {} + setting = current + if interactive: + from . import wizard + print("Search-SWE evaluation setup. API keys are entered privately and never displayed.") + setting = wizard.select_setting(current, args) + setting.validate() + updates.update(setting.values()) + env.update(updates) + if interactive: + wizard.configure_agent(setting, env, updates) + names = [*args.tasks, *args.task] + tasks = select_tasks(catalog, names) if names else [] + if not tasks and interactive: + tasks = wizard.select_tasks(catalog) + if not tasks: + raise ValueError("Select at least one task; use search-swe list") + if interactive: + wizard.configure_task_services(tasks, env, updates) + concurrency = (wizard.concurrency(args.concurrency) if interactive else + args.concurrency if args.concurrency is not None else int(env.get("SEARCH_SWE_CONCURRENCY") or "1")) + if concurrency < 1: + raise ValueError("--concurrency must be positive") + if interactive and args.command == "run" and any(task.gpus for task in tasks) and not args.gpu_devices: + args.gpu_devices = wizard.gpu_pool() + updates["SEARCH_SWE_CONCURRENCY"] = str(concurrency) + required = setting.required() | set().union(*(task.required_variables() for task in tasks)) + # Persist required default endpoints along with the explicitly entered keys. + for key in required: + if env.get(key) and key not in file_values and key not in os.environ: + updates.setdefault(key, env[key]) + summary = {"setting": setting.public(), "tasks": [task.name for task in tasks], + "concurrency": concurrency, "asset_gib": round(sum(task.asset_bytes for task in tasks) / 2**30, 2), + "resource_pool": {"cpus": args.cpu_set or "auto", "gpus": args.gpu_devices or "auto", + "memory_gib_cap": args.memory_gb}, + "output": str(args.output.absolute()) if args.output else "jobs/evaluations//"} + print(json.dumps(summary, indent=2), flush=True) + if args.dry_run: + for task in tasks: + subprocess.run([*runner.launch_command(root, task, setting), "--dry-run"], env=env, check=True) + return 0 + missing = sorted(key for key in required if not env.get(key)) + if missing: + raise ValueError("Configure these variables in .env or run search-swe configure: " + ", ".join(missing)) + runner.preflight(root, tasks, setting, env) + pool = None + if args.command == "run": + runner.doctor(root, tasks, env) + pool = Pool.host(tasks, args.cpu_set, args.gpu_devices, args.memory_gb) + pool.validate(tasks, env) + print(f"Resource pool: {len(pool.cpus)} logical CPUs, {pool.memory_mb / 1024:.1f} GiB RAM, {len(pool.gpus)} GPUs") + persist = args.command == "configure" + if interactive: + persist = wizard.ask(wizard.questionary.confirm(f"Save settings and entered keys to {path} (mode 0600)?", + default=True, auto_enter=False)) + if persist: + shadowed = sorted(key for key in updates if key in os.environ and os.environ[key] != updates[key]) + if shadowed: + print("Future runs will use exported shell values ahead of saved values for: " + ", ".join(shadowed)) + action = ("Save configuration" if persist else "Finish configuration") if args.command == "configure" else "Start these evaluations (model API usage may be billed)" + if not wizard.ask(wizard.questionary.confirm(action + "?", default=args.command == "configure", auto_enter=False)): + print("Cancelled; configuration was not changed.") + return 0 + if persist: + check_config_destination(root, path) + save_env(path, updates) + print(f"Saved configuration to {path}; API keys are hidden.") + if args.command == "configure": + print("Configuration checked. No evaluation started.") + return 0 + return runner.run_batch(root, tasks, setting, env, pool, concurrency, args.output, args.offline) + + +def main(argv=None): + arguments = list(sys.argv[1:] if argv is None else argv) + if not arguments: + arguments = ["run"] + command_parser = parser() + args = command_parser.parse_args(arguments) + if args.command is None: + command_parser.print_help() + return 0 + try: + root = find_repo(args.repo) + if args.command == "list": + rows = [{"name": task.name, "cpus": task.cpus, "ram_gib": task.memory_mb / 1024, + "gpus": task.gpus, "asset_gib": round(task.asset_bytes / 2**30, 2)} + for task in discover(root).values() + if args.hardware is None or bool(task.gpus) == (args.hardware == "gpu")] + if args.json: + print(json.dumps(rows, indent=2)) + else: + for row in rows: + print(f"{row['name']:<42} {row['cpus']:>3} CPU {row['ram_gib']:>5g} GiB RAM {row['gpus']} GPU {row['asset_gib']:>7.2f} GiB assets") + return 0 + if args.command == "doctor": + result = runner.doctor(root) + print(json.dumps(result, indent=2) if args.json else "\n".join(f"{key}: {value}" for key, value in result.items())) + return 0 + return configure_run(args, root) + except KeyboardInterrupt: + print("Cancelled; inspect the batch summary if execution had started.", file=sys.stderr) + return 130 + except (OSError, ValueError, RuntimeError, subprocess.SubprocessError) as error: + print(f"Error: {error}", file=sys.stderr) + return 1 diff --git a/src/search_swe/configuration.py b/src/search_swe/configuration.py new file mode 100644 index 0000000..bd47f39 --- /dev/null +++ b/src/search_swe/configuration.py @@ -0,0 +1,130 @@ +"""Literal dotenv configuration; keys never appear in command-line arguments.""" + +from dataclasses import asdict, dataclass +import io +import os +from pathlib import Path +import subprocess +import tempfile + +from dotenv.parser import parse_stream + + +DEFAULTS = {"AGENT_MODEL": "openai/gpt-6-luna", "SEARCH_SWE_AGENT": "codex", + "SEARCH_SWE_PROVIDER": "openrouter", "AGENT_REASONING_EFFORT": "max", + "VERIFIER_OPENAI_BASE_URL": "https://openrouter.ai/api/v1"} +PI_KEYS = {"deepseek/deepseek-flash": "DEEPSEEK_API_KEY", "zai/glm-5.3-flash": "ZAI_API_KEY"} +EFFORTS = ["low", "medium", "high", "xhigh", "max"] + + +def bindings(path): + if path.is_symlink(): + raise ValueError("The environment file must be a regular file, not a symlink") + text = path.read_text() if path.exists() else "" + result = list(parse_stream(io.StringIO(text))) + if any(item.error for item in result): + raise ValueError("Invalid dotenv syntax; fix the environment file before configuring a run") + return result + + +def read_env(path): + return {item.key: item.value for item in bindings(path) if item.key and item.value is not None} + + +def save_env(path, updates): + """Replace selected bindings atomically, preserving unrelated text.""" + if path.name == ".env.example": + raise ValueError("Save credentials to a private .env file; .env.example is the public template") + original = bindings(path) + pending = dict(updates) + output = [] + def assignment(key, value): + if "\0" in value: + raise ValueError("Configuration values cannot contain NUL bytes") + escaped = value.replace("\\", "\\\\").replace("'", "\\'") + return f"{key}='{escaped}'\n" + for item in original: + if item.key in updates: + if item.key in pending: + output.append(assignment(item.key, pending.pop(item.key))) + else: + output.append(item.original.string) + if output and not output[-1].endswith("\n"): + output.append("\n") + output.extend(assignment(key, value) for key, value in pending.items()) + path.parent.mkdir(parents=True, exist_ok=True) + temporary = None + try: + with tempfile.NamedTemporaryFile(mode="w", dir=path.parent, prefix=".env.tmp-", delete=False) as stream: + temporary = Path(stream.name) + os.fchmod(stream.fileno(), 0o600) + stream.write("".join(output)) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + finally: + if temporary is not None: + temporary.unlink(missing_ok=True) + + +def check_config_destination(root, path): + if path.is_relative_to(root) and (root / ".git").exists(): + relative = str(path.relative_to(root)) + tracked = subprocess.run(["git", "-C", str(root), "ls-files", "--error-unmatch", "--", relative], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode == 0 + ignored = subprocess.run(["git", "-C", str(root), "check-ignore", "--no-index", "-q", "--", relative], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode == 0 + if tracked or not ignored: + raise ValueError("Save credentials to a Git-ignored environment file, such as the checkout .env") + + +@dataclass(frozen=True) +class Setting: + agent: str = "codex" + provider: str = "openrouter" + model: str = "openai/gpt-6-luna" + effort: str = "max" + + def values(self): + values = {"SEARCH_SWE_AGENT": self.agent, "SEARCH_SWE_PROVIDER": self.provider, + "AGENT_MODEL": self.model} + values["PI_THINKING" if self.agent == "pi" else "AGENT_REASONING_EFFORT"] = self.effort + return values + + def flags(self): + args = ["--agent", self.agent, "--model", self.model] + if self.provider == "openrouter": + args.append("--openrouter") + if self.effort: + args.extend(["--thinking" if self.agent == "pi" else "--reasoning-effort", self.effort]) + return args + + def required(self): + if self.provider == "openrouter": + return {"AGENT_OPENROUTER_API_KEY"} + if self.agent == "codex": + return {"AGENT_OPENAI_API_KEY", "AGENT_OPENAI_BASE_URL"} + if self.agent == "claude-code": + return {"AGENT_ANTHROPIC_API_KEY"} + if self.model not in PI_KEYS: + raise ValueError("Pi direct models: " + ", ".join(PI_KEYS)) + return {PI_KEYS[self.model]} + + def validate(self): + if self.agent not in ("codex", "claude-code", "pi") or self.provider not in ("openrouter", "direct"): + raise ValueError("Unsupported harness/provider") + if self.agent == "pi" and self.provider != "direct": + raise ValueError("Pi currently uses the supported direct-provider adapters") + if self.provider == "openrouter": + if "/" not in self.model: + raise ValueError("OpenRouter requires a complete provider/model slug") + if self.agent == "claude-code" and not self.model.startswith("anthropic/"): + raise ValueError("Claude Code on OpenRouter requires an anthropic/ model") + levels = ["default", "off", "minimal", *EFFORTS] if self.agent == "pi" else ["default", *EFFORTS] + if self.effort and self.effort not in levels: + raise ValueError("Unsupported reasoning/thinking level: " + self.effort) + self.required() + return self + + def public(self): + return asdict(self) diff --git a/src/search_swe/model_catalog.py b/src/search_swe/model_catalog.py new file mode 100644 index 0000000..7214628 --- /dev/null +++ b/src/search_swe/model_catalog.py @@ -0,0 +1,114 @@ +"""Public OpenRouter metadata, cached locally and searched without API keys.""" + +import json +from http.client import HTTPException +import os +from pathlib import Path +import re +import tempfile +import time +from urllib.request import Request, urlopen + +from prompt_toolkit.completion import Completer, Completion + + +CATALOG_URL = "https://openrouter.ai/api/v1/models" +CACHE_SECONDS = 24 * 60 * 60 +MAX_BYTES = 10 * 1024 * 1024 +MODEL_ID = re.compile(r"~?[a-z0-9][a-z0-9._-]*/[a-zA-Z0-9][a-zA-Z0-9._:+-]*") + + +def clean_text(value): + return "".join(char for char in str(value) if char.isprintable())[:160] + + +def parse_models(document): + if not isinstance(document, dict) or not isinstance(document.get("data"), list): + raise ValueError("Invalid model catalog") + result = {} + for row in document["data"]: + if not isinstance(row, dict) or not isinstance(row.get("id"), str) or not MODEL_ID.fullmatch(row["id"]): + continue + model = {"id": row["id"], "name": clean_text(row.get("name") or row["id"])} + for key in ("supported_parameters", "endpoints"): + if isinstance(row.get(key), list) and all(isinstance(value, str) for value in row[key]): + model[key] = row[key] + architecture = row.get("architecture") + if isinstance(architecture, dict) and isinstance(architecture.get("output_modalities"), list): + model["output_modalities"] = architecture["output_modalities"] + if isinstance(row.get("context_length"), int) and row["context_length"] > 0: + model["context_length"] = row["context_length"] + result[model["id"]] = model + if not result: + raise ValueError("Empty model catalog") + return result + + +def load_models(*, offline=False, refresh=False, cache=None): + """Return (models, source). Catalog failure always leaves manual input usable.""" + cache = cache or Path(os.environ.get("XDG_CACHE_HOME") or Path.home() / ".cache") / "search-swe/openrouter-models.json" + cached, fetched = {}, 0 + try: + if cache.stat().st_size <= MAX_BYTES: + document = json.loads(cache.read_text()) + cached = parse_models(document) + fetched = float(document.get("fetched_at", 0)) + except (OSError, ValueError, TypeError): + pass + if cached and not refresh and 0 <= time.time() - fetched < CACHE_SECONDS: + return cached, "cache" + if not offline: + try: + request = Request(CATALOG_URL, headers={"Accept": "application/json", "User-Agent": "Search-SWE CLI"}) + with urlopen(request, timeout=8) as response: + payload = response.read(MAX_BYTES + 1) + if len(payload) > MAX_BYTES: + raise ValueError("Model catalog exceeds size limit") + document = json.loads(payload) + models = parse_models(document) + document["fetched_at"] = time.time() + except (OSError, ValueError, TypeError, HTTPException): + pass + else: + temporary = None + try: + cache.parent.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile(mode="w", dir=cache.parent, delete=False) as stream: + temporary = Path(stream.name) + json.dump(document, stream) + os.replace(temporary, cache) + except OSError: + pass # A read-only cache does not prevent model selection. + finally: + if temporary is not None: + temporary.unlink(missing_ok=True) + return models, "live" + return cached, "stale cache" if cached else "unavailable" + + +def eligible(model, agent): + if agent == "claude-code" and not model["id"].startswith("anthropic/"): + return False + if "output_modalities" in model and "text" not in model["output_modalities"]: + return False + if "supported_parameters" in model and "tools" not in model["supported_parameters"]: + return False + endpoint = "/api/v1/messages" if agent == "claude-code" else "/api/v1/responses" + return "endpoints" not in model or endpoint in model["endpoints"] + + +class ModelCompleter(Completer): + def __init__(self, models, preferred=""): + self.models = sorted(models.values(), key=lambda model: (model["id"] != preferred, model["id"])) + + def get_completions(self, document, complete_event): + query = document.text_before_cursor + tokens = re.findall(r"\w+", query.casefold()) + for model in self.models: + searchable = f"{model['id']} {model['name']}".casefold() + if not all(token in searchable for token in tokens): + continue + context = model.get("context_length") + yield Completion(model["id"], start_position=-len(query), + display=f"{model['id']} · {model['name']}", + display_meta=f"{context:,} tokens" if context else "") diff --git a/src/search_swe/repository.py b/src/search_swe/repository.py new file mode 100644 index 0000000..2207e92 --- /dev/null +++ b/src/search_swe/repository.py @@ -0,0 +1,84 @@ +"""Discover a checkout and read task metadata without executing task code.""" + +from dataclasses import dataclass +import json +import os +from pathlib import Path +import re +import tomllib + + +VARIABLE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)(?::[-?][^}]*)?\}") + + +def find_repo(explicit=None): + selected = explicit or os.environ.get("SEARCH_SWE_REPO") + if selected: + candidates = [Path(selected).expanduser().resolve()] + else: + here = Path.cwd().resolve() + candidates = [here, *here.parents, Path(__file__).resolve().parents[2]] + for root in candidates: + if (root / "tasks").is_dir() and (root / "scripts/run_task.py").is_file(): + return root + raise ValueError("Select a Search-SWE checkout with --repo PATH or SEARCH_SWE_REPO, " + "or run from the cloned repository. Clone: git clone https://github.com/VectorSpaceLab/Search-SWE.git") + + +@dataclass(frozen=True) +class Task: + name: str + path: Path + title: str + cpus: int + memory_mb: int + gpus: int + storage_mb: int + asset_bytes: int + config: dict + + @property + def bounded(self): + return (self.path / "tools/prepare_storage.py").is_file() + + @property + def cpu_variable(self): + source = (self.path / "environment/docker-compose.yaml").read_text() + match = re.search(r"\$\{([A-Z][A-Z0-9_]*_CPUSET)(?=[:}])", source) + return match[1] if match else None + + def required_variables(self): + result = set() + for key, value in self.config.get("verifier", {}).get("env", {}).items(): + if key in ("OPENAI_API_KEY", "OPENAI_BASE_URL") or key.startswith("ANSWER_JUDGE_"): + result.update(VARIABLE.findall(str(value))) + return result + + def optional_keys(self): + result = set() + for phase in ("environment", "agent", "verifier"): + for value in self.config.get(phase, {}).get("env", {}).values(): + result.update(name for name in VARIABLE.findall(str(value)) if name.endswith("API_KEY")) + return result - self.required_variables() + + +def discover(root): + tasks = {} + for config_path in sorted((root / "tasks").glob("*/task.toml")): + path = config_path.parent + config = tomllib.loads(config_path.read_text()) + resources = config["environment"] + manifest = json.loads((path / "assets.json").read_text()) + tasks[path.name] = Task( + path.name, path, config.get("task", {}).get("title", path.name), + int(resources["cpus"]), int(resources["memory_mb"]), int(resources.get("gpus", 0)), + int(resources.get("storage_mb", 0)), sum(entry["size_bytes"] for entry in manifest["files"]), config) + return tasks + + +def select_tasks(catalog, names): + names = list(dict.fromkeys(names)) + unknown = set(names) - set(catalog) + if unknown: + raise ValueError("Unknown task(s): " + ", ".join(sorted(unknown)) + "; use search-swe list") + return [catalog[name] for name in names] diff --git a/src/search_swe/resources.py b/src/search_swe/resources.py new file mode 100644 index 0000000..006968d --- /dev/null +++ b/src/search_swe/resources.py @@ -0,0 +1,123 @@ +"""Resource accounting for evaluations owned by one CLI invocation.""" + +from dataclasses import dataclass +import os +import re +import subprocess + +import psutil + + +def cpu_ids(value): + result = set() + for item in value.split(","): + if not re.fullmatch(r"\d+(?:-\d+)?", item): + raise ValueError("CPU pool must contain IDs/ranges such as 0-15,32-47") + bounds = list(map(int, item.split("-"))) + first, last = bounds[0], bounds[-1] + if last < first or last > 1_000_000: + raise ValueError("Invalid CPU range") + result.update(range(first, last + 1)) + return result + + +def gpu_inventory(): + try: + response = subprocess.run( + ["nvidia-smi", "--query-gpu=index,uuid,memory.used,utilization.gpu", "--format=csv,noheader,nounits"], + check=True, capture_output=True, text=True, timeout=15) + except (OSError, subprocess.SubprocessError) as error: + raise ValueError("GPU tasks require nvidia-smi and a working NVIDIA driver") from error + devices = [] + for line in response.stdout.splitlines(): + index, identity, used, utilization = [part.strip() for part in line.split(",")] + if not identity.startswith("GPU-"): + raise ValueError("Unsupported GPU inventory; select full physical GPUs") + devices.append({"index": index, "uuid": identity, "idle": float(used) <= 512 and float(utilization) <= 5}) + return devices + + +@dataclass(frozen=True) +class Allocation: + cpus: frozenset + memory_mb: int + gpu: str | None + + @property + def cpu_set(self): + return ",".join(map(str, sorted(self.cpus))) + + +class Pool: + def __init__(self, cpus, memory_mb, gpus): + self.cpus = set(cpus) + self.memory_mb = memory_mb + self.gpus = list(gpus) + self.used = [] + + @classmethod + def host(cls, tasks, cpu_set=None, gpu_devices=None, memory_gb=None): + if not hasattr(os, "sched_getaffinity"): + raise ValueError("Evaluation requires a Linux Docker host") + available = os.sched_getaffinity(0) + cpus = cpu_ids(cpu_set) if cpu_set else available + if not cpus <= available: + raise ValueError("CPU pool includes unavailable logical CPUs") + memory = max(0, psutil.virtual_memory().available // 2**20 - 2048) + if memory_gb is not None: + if memory_gb <= 0: + raise ValueError("--memory-gb must be positive") + memory = min(memory, int(memory_gb * 1024)) + gpus = [] + if any(task.gpus for task in tasks): + inventory = gpu_inventory() + if gpu_devices: + requested = gpu_devices.split(",") + for value in requested: + matches = [gpu for gpu in inventory if value in (gpu["index"], gpu["uuid"])] + if not matches or not matches[0]["idle"]: + raise ValueError(f"GPU {value} is unavailable or currently busy") + if matches[0]["uuid"] in gpus: + raise ValueError("GPU pool contains the same device more than once") + gpus.append(matches[0]["uuid"]) + else: + gpus = [gpu["uuid"] for gpu in inventory if gpu["idle"]] + return cls(cpus, memory, gpus) + + def fixed_cpus(self, task, env): + name = task.cpu_variable + selected = cpu_ids(env[name]) if name and env.get(name) else None + if selected is not None and (len(selected) != task.cpus or not selected <= self.cpus): + raise ValueError(f"{name} must select {task.cpus} logical CPUs inside the CPU pool") + return selected + + def validate(self, tasks, env): + for task in tasks: + self.fixed_cpus(task, env) + if task.cpus > len(self.cpus) or task.memory_mb + 512 > self.memory_mb: + raise ValueError(f"Insufficient CPU/RAM for {task.name}: needs {task.cpus} CPUs and " + f"{task.memory_mb / 1024:g} GiB RAM plus host/gateway headroom") + if task.gpus > 1 or (task.gpus and not self.gpus): + raise ValueError(f"No idle supported GPU available for {task.name}") + + def acquire(self, task, env, idle_gpus=None): + busy_cpus = set().union(*(allocation.cpus for allocation in self.used)) + available = self.cpus - busy_cpus + selected = self.fixed_cpus(task, env) + if selected is None: + selected = set(sorted(available)[:task.cpus]) + memory = task.memory_mb + 512 # Trusted gateway and orchestration headroom. + if len(selected) < task.cpus or not selected <= available: + return None + if sum(item.memory_mb for item in self.used) + memory > self.memory_mb: + return None + free_gpus = [gpu for gpu in self.gpus if gpu not in {item.gpu for item in self.used} + and (idle_gpus is None or gpu in idle_gpus)] + if task.gpus and not free_gpus: + return None + allocation = Allocation(frozenset(selected), memory, free_gpus[0] if task.gpus else None) + self.used.append(allocation) + return allocation + + def release(self, allocation): + self.used.remove(allocation) diff --git a/src/search_swe/runner.py b/src/search_swe/runner.py new file mode 100644 index 0000000..5fbce51 --- /dev/null +++ b/src/search_swe/runner.py @@ -0,0 +1,265 @@ +"""Restore fixed inputs and schedule the existing single-task launcher.""" + +from contextlib import nullcontext +from datetime import datetime, timezone +import importlib.util +import json +import os +from pathlib import Path +import shutil +import signal +import subprocess +import sys +import time +import uuid + +from . import __version__ +from .resources import gpu_inventory + + +def backend(root, name): + key = "_search_swe_backend_" + name + spec = importlib.util.spec_from_file_location(key, root / "scripts" / f"{name}.py") + module = importlib.util.module_from_spec(spec) + sys.modules[key] = module + spec.loader.exec_module(module) + return module + + +def launch_command(root, task, setting, output=None, allocation=None): + command = [sys.executable, str(root / "scripts/run_task.py"), "--task", task.name, + "--no-env-file", *setting.flags()] + if output is not None: + command += ["--output", str(output)] + if allocation is not None: + command += ["--cpu-set", allocation.cpu_set] + if allocation.gpu: + command += ["--gpu-device", allocation.gpu] + return command + + +def preflight(root, tasks, setting, env): + for task in tasks: + result = subprocess.run([*launch_command(root, task, setting), "--check"], + env=env, cwd=root, capture_output=True, text=True) + if result.returncode: + # The backend reports variable names, never credential values. + raise ValueError(f"Configuration for {task.name}:\n{result.stderr.strip()}") + + +def doctor(root, tasks=(), env=None): + import platform + from .resources import Pool + env = dict(os.environ) if env is None else env + if platform.system() != "Linux" or platform.machine() not in ("x86_64", "amd64"): + raise ValueError("Evaluation requires a native Linux x86_64 Docker host") + missing = [name for name in ("docker", "harbor") if shutil.which(name) is None] + if missing: + raise ValueError("Missing executables: " + ", ".join(missing) + "; install the package and Docker Engine") + context = subprocess.run(["docker", "context", "inspect"], env=env, + check=True, capture_output=True, text=True, timeout=30) + endpoint = ((None if env.get("DOCKER_CONTEXT") else env.get("DOCKER_HOST")) + or json.loads(context.stdout)[0]["Endpoints"]["docker"]["Host"]) + if not endpoint.startswith("unix://"): + raise ValueError("Evaluation requires a local Unix-socket Docker daemon") + response = subprocess.run(["docker", "info", "--format", "{{json .}}"], env=env, + check=True, capture_output=True, text=True, timeout=30) + info = json.loads(response.stdout) + security = " ".join(info.get("SecurityOptions", [])) + if (info.get("OSType") != "linux" or "rootless" in security or "userns" in security + or "desktop" in info.get("OperatingSystem", "").lower() + or info.get("KernelVersion") != platform.release()): + raise ValueError("Evaluation requires native Linux rootful Docker sharing the host kernel") + subprocess.run(["docker", "compose", "version", "--short"], env=env, check=True, + capture_output=True, text=True, timeout=15) + if any(task.bounded for task in tasks): + backend(root, "task_storage").check_host(env) + if os.getuid() and shutil.which("sudo") is None: + raise ValueError("Selected tasks need root or sudo for bounded storage") + return {"checkout": str(root), "docker": info.get("ServerVersion"), + "logical_cpus": len(os.sched_getaffinity(0)), + "available_memory_gib": round(Pool.host([]).memory_mb / 1024, 1)} + + +def private_json(path, value): + temporary = path.with_suffix(".tmp") + with temporary.open("w") as stream: + os.fchmod(stream.fileno(), 0o600) + json.dump(value, stream, indent=2) + stream.write("\n") + os.replace(temporary, path) + + +def terminate(process): + if process.poll() is None: + try: + os.killpg(process.pid, signal.SIGINT) + except ProcessLookupError: + pass + + +def wait_cancelled(processes): + for process in processes: + terminate(process) + deadline = time.monotonic() + 120 + for process in processes: + try: + process.wait(timeout=max(0.1, deadline - time.monotonic())) + except subprocess.TimeoutExpired: + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + process.wait() + + +def run_batch(root, tasks, setting, env, pool, concurrency, output=None, offline=False): + batch_id = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + "-" + uuid.uuid4().hex[:8] + output = Path(output).resolve() if output else root / "jobs/evaluations" / batch_id + output.mkdir(parents=True, mode=0o700, exist_ok=False) + logs = output / "logs" + logs.mkdir(mode=0o700) + try: + revision = subprocess.check_output(["git", "-C", str(root), "rev-parse", "HEAD"], text=True, stderr=subprocess.DEVNULL).strip() + dirty = bool(subprocess.check_output(["git", "-C", str(root), "status", "--porcelain"], text=True)) + except (OSError, subprocess.SubprocessError): + revision, dirty = None, None + report = {"cli_version": __version__, "setting": setting.public(), "git_revision": revision, "working_tree_dirty": dirty, + "concurrency": concurrency, "tasks": {}, "status": "preparing"} + for task in tasks: + report["tasks"][task.name] = {"status": "pending", "log": str(logs / f"{task.name}.log"), + "output": str(output / task.name)} + def record(): + private_json(output / "run.json", report) + record() + print(f"Evaluation batch: {output}", flush=True) + active = [] + storage = backend(root, "task_storage") if any(task.bounded for task in tasks) else None + pending = list(tasks) + authorized_sudo = False + refreshed = 0 + gpu_checked = 0 + idle_gpus = None + waiting = False + def cancel(*_): + raise KeyboardInterrupt + previous_term = signal.signal(signal.SIGTERM, cancel) + try: + # Restore sequentially before scheduling to avoid concurrent asset/cache writes. + for task in tasks: + path = logs / f"{task.name}.assets.log" + print(f"Checking/restoring assets: {task.name} ({task.asset_bytes / 2**30:.2f} GiB manifest); {path}", flush=True) + command = [sys.executable, str(root / "scripts/download_assets.py"), "--task", task.name, "--force"] + if offline: + command.append("--local-files-only") + with path.open("w") as stream: + os.fchmod(stream.fileno(), 0o600) + process = subprocess.Popen(command, env=env, cwd=root, stdout=stream, stderr=subprocess.STDOUT, + stdin=subprocess.DEVNULL, start_new_session=True) + try: + code = process.wait() + except BaseException: + wait_cancelled([process]) + raise + if code: + report["tasks"][task.name]["status"] = "asset-error" + raise RuntimeError(f"Asset restoration failed for {task.name}; see {path}") + report["status"] = "running" + while pending or active: + if any(task.gpus for task in pending) and time.monotonic() - gpu_checked >= 2: + idle_gpus = {gpu["uuid"] for gpu in gpu_inventory() if gpu["idle"]} + gpu_checked = time.monotonic() + if authorized_sudo and time.monotonic() - refreshed > 60: + subprocess.run(["sudo", "-n", "-v"], check=True) + refreshed = time.monotonic() + for task in list(pending): + if len(active) >= concurrency: + break + allocation = pool.acquire(task, env, idle_gpus) + if allocation is None: + continue + child_env = dict(env) + if task.cpu_variable: + child_env[task.cpu_variable] = allocation.cpu_set + context = nullcontext() + stream = None + entered = False + try: + if task.bounded: + plan = storage.StoragePlan.discover(task.path, task.config) + context = storage.prepared_storage(plan, child_env) + context.__enter__() + entered = True + if task.bounded and os.getuid() != 0: + authorized_sudo = True + refreshed = time.monotonic() + row = report["tasks"][task.name] + if task.bounded: + row["storage_root"] = child_env[plan.variable] + stream = Path(row["log"]).open("w") + os.fchmod(stream.fileno(), 0o600) + command = launch_command(root, task, setting, row["output"], allocation) + process = subprocess.Popen(command, cwd=root, env=child_env, stdout=stream, + stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL, + start_new_session=True, umask=0o077) + except BaseException: + if stream: + stream.close() + try: + if entered: + context.__exit__(*sys.exc_info()) + finally: + pool.release(allocation) + raise + row.update(status="running", cpu_set=allocation.cpu_set, gpu=allocation.gpu, + started=datetime.now(timezone.utc).isoformat()) + active.append((task, allocation, process, stream, context)) + waiting = False + pending.remove(task) + print(f"Started {task.name}; CPUs {allocation.cpu_set}; GPU {allocation.gpu or 'none'}; log: {row['log']}", flush=True) + record() + for item in list(active): + task, allocation, process, stream, context = item + if process.poll() is None: + continue + active.remove(item) + stream.close() + row = report["tasks"][task.name] + row.update(returncode=process.returncode, status="completed" if process.returncode == 0 else "failed") + try: + context.__exit__(None, None, None) + except Exception as error: + row.update(status="cleanup-error", error=str(error)) + pool.release(allocation) + print(f"{task.name}: {row['status']}; output: {row['output']}", flush=True) + record() + if (pending and not active and all(task.gpus for task in pending) + and not set(pool.gpus).intersection(idle_gpus or ()) and not waiting): + print("Waiting for an idle GPU in the selected resource pool; Ctrl-C cancels the batch.", flush=True) + waiting = True + if active or pending: + time.sleep(0.25) + report["status"] = "completed" if all(row["status"] == "completed" for row in report["tasks"].values()) else "failed" + except BaseException as error: + report["status"] = "cancelled" if isinstance(error, KeyboardInterrupt) else "failed" + if not isinstance(error, KeyboardInterrupt): + report["error"] = str(error) + wait_cancelled([item[2] for item in active]) + for task, allocation, process, stream, context in active: + stream.close() + row = report["tasks"][task.name] + row.update(status="cancelled", returncode=process.returncode) + try: + context.__exit__(None, None, None) + except Exception as cleanup_error: + row.update(status="cleanup-error", error=str(cleanup_error)) + pool.release(allocation) + for task in pending: + if report["tasks"][task.name]["status"] == "pending": + report["tasks"][task.name]["status"] = "not-started" + raise + finally: + record() + signal.signal(signal.SIGTERM, previous_term) + print(f"Batch summary: {output / 'run.json'}", flush=True) + return 0 if report["status"] == "completed" else 1 diff --git a/src/search_swe/wizard.py b/src/search_swe/wizard.py new file mode 100644 index 0000000..0a2373e --- /dev/null +++ b/src/search_swe/wizard.py @@ -0,0 +1,152 @@ +"""Interactive choices are collected before configuration is written or work starts.""" + +import questionary + +from .configuration import EFFORTS, PI_KEYS, Setting +from .model_catalog import MODEL_ID, ModelCompleter, eligible, load_models + + +def ask(question): + value = question.ask() + if value is None: + raise KeyboardInterrupt + return value + + +def select_setting(current, args): + harnesses = ["codex", "claude-code", "pi"] + agent = args.agent or ask(questionary.select("Harness", choices=harnesses, + default=current.agent if current.agent in harnesses else "codex")) + providers = ["direct"] if agent == "pi" else ["openrouter", "direct"] + provider = args.provider or (providers[0] if len(providers) == 1 else + ask(questionary.select("Provider", choices=providers, + default=current.provider if current.provider in providers else providers[0]))) + if provider not in providers: + raise ValueError("Pi uses the supported direct-provider adapters") + same_route = (agent, provider) == (current.agent, current.provider) + model, metadata = args.model, None + if model is None and provider == "openrouter": + default = current.model if same_route else ("anthropic/claude-opus-5.5" if agent == "claude-code" else Setting().model) + model, metadata = openrouter_model(agent, default, args) + elif model is None and agent == "pi": + model = ask(questionary.select("Model", choices=list(PI_KEYS), + default=current.model if current.model in PI_KEYS else next(iter(PI_KEYS)))) + elif model is None: + model = ask(questionary.text("Model ID for the direct endpoint", + default=current.model if same_route else "", validate=lambda value: bool(value.strip()))).strip() + levels = ["default", "off", "minimal", *EFFORTS] if agent == "pi" else ["default", *EFFORTS] + effort = args.reasoning_effort + if effort is None: + parameters = metadata.get("supported_parameters") if metadata else None + if parameters is not None and not {"reasoning", "reasoning_effort"}.intersection(parameters): + print("This model lists no reasoning parameter; using its default configuration.") + effort = "default" + else: + default = current.effort if same_route and model == current.model and not args.model else "default" + choices = [questionary.Choice("Model/config default (omit effort override)", value="default"), + *levels[1:]] + effort = ask(questionary.select("Reasoning / thinking level", choices=choices, + default=default if default in levels else "default")) + return Setting(agent, provider, model, effort).validate() + + +def openrouter_model(agent, default, args): + print("Loading OpenRouter model catalog...", flush=True) + models, source = load_models(offline=args.offline, refresh=args.refresh_models) + choices = {key: model for key, model in models.items() if eligible(model, agent)} + if source == "unavailable": + print("Model catalog unavailable; enter a full model ID manually.") + else: + print(f"OpenRouter: {len(choices)} matching models ({source}). Type a name or ID to search; use ↓/↑ and Enter to select.") + if default in models and default not in choices: + default = "" + if agent == "claude-code" and not default.startswith("anthropic/"): + default = "" + def validate(value): + value = value.strip() or default + if not MODEL_ID.fullmatch(value): + return "Select a model or enter its complete provider/model ID" + if agent == "claude-code" and not value.startswith("anthropic/"): + return "Claude Code on OpenRouter uses an anthropic/ model" + if value in models and value not in choices: + return "Choose a text model with tools and the API required by this harness" + return True + hint = f"Enter keeps {default}. " if default else "" + hint += "A complete model ID can also be entered manually." + print(hint) + model = ask(questionary.autocomplete("OpenRouter model", choices=[], + completer=ModelCompleter(choices, default), validate=validate, + complete_while_typing=True, reserve_space_for_menu=10, + placeholder=default or "Search model name or ID")).strip() or default + return model, models.get(model) + + +def credential(name, env, updates, *, optional=False): + if env.get(name): + action = ask(questionary.select(f"{name}: configured", choices=["Keep existing", "Replace"])) + if action == "Keep existing": + return + if name == "VERIFIER_OPENAI_API_KEY" and env.get("AGENT_OPENROUTER_API_KEY"): + reuse = ask(questionary.select("Trajectory judge key", choices=["Enter a separate key", "Reuse my OpenRouter agent key"])) + if reuse.startswith("Reuse"): + updates[name] = env[name] = env["AGENT_OPENROUTER_API_KEY"] + return + prompt = name + (" (optional; Enter to skip)" if optional else " (required)") + if name.endswith("API_KEY"): + value = ask(questionary.password(prompt, validate=lambda v: True if optional or v.strip() else "A key is required")) + else: + defaults = {"AGENT_OPENAI_BASE_URL": "https://api.openai.com/v1", + "ANSWER_JUDGE_BASE_URL": "https://openrouter.ai/api/v1"} + value = ask(questionary.text(prompt, default=env.get(name) or defaults.get(name, ""), + validate=lambda v: True if optional or v.strip() else "A value is required")) + if value: + updates[name] = env[name] = value + + +def configure_agent(setting, env, updates): + for name in sorted(setting.required()): + credential(name, env, updates) + + +def select_tasks(catalog): + query = ask(questionary.text("Filter tasks by name (Enter for all)")) + candidates = [task for task in catalog.values() if query.lower() in task.name.lower()] + if not candidates: + raise ValueError("No tasks match the filter") + choices = [questionary.Choice( + f"{task.name} · {task.cpus} CPU / {task.memory_mb / 1024:g} GiB / {task.gpus} GPU · assets {task.asset_bytes / 2**30:.2f} GiB", + value=task.name) for task in candidates] + names = ask(questionary.checkbox("Tasks (Space to select; Enter to confirm)", choices=choices, + validate=lambda values: True if values else "Select at least one task")) + return [catalog[name] for name in names] + + +def configure_task_services(tasks, env, updates): + required = set().union(*(task.required_variables() for task in tasks)) + optional = set().union(*(task.optional_keys() for task in tasks)) - required + print("Configure evaluation services required by the selected tasks.") + for name in sorted(required): + credential(name, env, updates) + if optional and ask(questionary.confirm("Configure optional APIs available to submitted programs?", default=False, auto_enter=False)): + for name in sorted(optional): + credential(name, env, updates, optional=True) + + +def concurrency(value): + if value is not None: + return value + mode = ask(questionary.select("Execution", choices=["Sequential", "Parallel"])) + if mode == "Sequential": + return 1 + return int(ask(questionary.text("Maximum simultaneous tasks (CPU/RAM/GPU also limit admission)", default="2", + validate=lambda v: True if v.isdigit() and int(v) > 0 else "Enter a positive integer"))) + + +def gpu_pool(): + from .resources import gpu_inventory + idle = [gpu for gpu in gpu_inventory() if gpu["idle"]] + if not idle: + raise ValueError("No idle GPU available for the selected tasks") + choices = [questionary.Choice(f"GPU {gpu['index']} · {gpu['uuid']}", value=gpu["uuid"], checked=True) for gpu in idle] + return ",".join(ask(questionary.checkbox("GPU pool for this batch", choices=choices, + validate=lambda values: True if values else "Select at least one GPU"))) diff --git a/tasks/budgeted-bimetric-retrieval/README.md b/tasks/budgeted-bimetric-retrieval/README.md index 35921f4..ed92a97 100644 --- a/tasks/budgeted-bimetric-retrieval/README.md +++ b/tasks/budgeted-bimetric-retrieval/README.md @@ -57,14 +57,15 @@ trusted setup and checks. This task uses no GPU. -On the Linux Docker host, `/dev/fuse` and `mkfs.ext4` must be available. -Prepare fresh bounded storage for each run, then set `BIMETRIC_CPUSET` to -16 available logical CPU IDs on a supported CPU. Use a unique `RUN_ID`: - -```bash -python tasks/budgeted-bimetric-retrieval/tools/prepare_storage.py --run-id RUN_ID -export BIMETRIC_STORAGE_ROOT="$PWD/jobs/.task-storage/budgeted-bimetric-retrieval/RUN_ID" -``` +Use a native Linux x86_64 Docker host with `/dev/fuse`, `mkfs.ext4`, +`dpkg-deb`, and root or sudo access. The standard launcher prepares fresh +bounded agent/verifier storage and the oracle socket directory, chooses +16 logical CPUs from its available affinity, and unmounts storage after the +run's containers exit. Backing images remain under +`jobs/.task-storage/budgeted-bimetric-retrieval/`. +Set `BIMETRIC_CPUSET` to preserve a particular 16-CPU allocation when reproducing +a run. See [bounded task storage](../../docs/evaluation.md#bounded-task-storage) +for host setup, permissions, recovery and caller-managed storage. Configure `AGENT_OPENROUTER_API_KEY` through the existing ignored `.env` or authorized process environment, following the repository README. @@ -82,19 +83,13 @@ Invoke the standard entrypoint directly: ```bash bash scripts/run_task.sh --task budgeted-bimetric-retrieval --agent codex --openrouter \ --model openai/gpt-6-luna --reasoning-effort max \ - --output "jobs/budgeted-bimetric-retrieval-codex-openrouter-luna-max-RUN_ID" + --output "jobs/budgeted-bimetric-retrieval-codex-openrouter-luna-max" ``` Inspect the resulting agent trajectory, submission and verifier report under the job directory. The score alone does not distinguish an invalid submission from an infrastructure error. -After every container/process for this run has exited, unmount its storage: - -```bash -python tasks/budgeted-bimetric-retrieval/tools/prepare_storage.py --run-id RUN_ID --unmount -``` - ### Validate the package ```bash diff --git a/tasks/deletion-resilient-vector-search/.gitignore b/tasks/deletion-resilient-vector-search/.gitignore deleted file mode 100644 index d51f6be..0000000 --- a/tasks/deletion-resilient-vector-search/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -/data/ -/models/ -__pycache__/ -*.py[cod] diff --git a/tasks/deletion-resilient-vector-search/README.md b/tasks/deletion-resilient-vector-search/README.md deleted file mode 100644 index de37d1d..0000000 --- a/tasks/deletion-resilient-vector-search/README.md +++ /dev/null @@ -1,110 +0,0 @@ -# Deletion Resilient Vector Search - -## Task Metadata - -| Field | Value | -| --- | --- | -| Task | `deletion-resilient-vector-search` | -| Version | 1.0.0 | -| Authors | jianlyu | -| Primary metric | normalized log-error reduction * (0.8 + 0.2 memory efficiency) | -| Compute | 16 CPUs; 32 GiB RAM; 100 GiB storage; 0 GPUs | -| Agent / verifier time limits | 7200 s / 86400 s | - -Maintain graph-vector search quality and reclaim memory through 100 deletion stages on SIFT1M and GIST1M. - -The complete task contract is in [raw-instruction.md](raw-instruction.md). -The coding agent receives [instruction.md](instruction.md), -[available resources](environment/docs/available_resources.md), and -[environment instructions](environment/docs/environment.md). -Those documents specify the submission interface, allowed inputs, scoring, -failure behavior and public self-check commands. - -## Change Log - -| Date | Version | Description | -| --- | --- | --- | -| 2026-10-25 | 1.0.0 | Added initial task | - -## Difficulty Explanation - -Repeated deletions affect graph connectivity, recall and memory use. The implementation must retain search quality while reclaiming memory across 100 deletion stages on two vector collections. - -## Verification Explanation - -The verifier replays 100 deletion stages on each dataset, measuring Recall@10 and process memory. Reward combines normalized logarithmic error reduction over the frozen tombstone reference with memory efficiency. Build, mutation, query and resource limits apply throughout the sequence. See the [verification contract](raw-instruction.md#verification) for exact formulas, thresholds and failure handling. - -### Restore inputs - -Run from the repository root using the repository's existing Python environment: - -```bash -python scripts/download_assets.py --task deletion-resilient-vector-search -python scripts/download_assets.py --task deletion-resilient-vector-search --verify-only -python tasks/deletion-resilient-vector-search/tools/prepare_inputs.py --split learn -python tasks/deletion-resilient-vector-search/tools/prepare_truth.py --dataset sift -python tasks/deletion-resilient-vector-search/tools/prepare_truth.py --dataset gist -``` - -[assets.json](assets.json) fixes file sizes, hashes and source revisions or bundled -restoration paths. Keep verifier inputs outside agent mounts. Model cards, -licenses and data-use restrictions are described in the resource document. - -### Run - -The task allocates 16 CPU cores, 32 GiB RAM and -100 GiB storage. Agent development has -7200 seconds. Final-stage limits and accounting are -specified in the task contract; the verifier service timeout also includes its -trusted setup and checks. - -This task uses no GPU. - -On the Linux Docker host, `/dev/fuse` and `mkfs.ext4` must be available. -Prepare fresh bounded storage for each run, then set `DELETION_CPUSET` to -16 available logical CPU IDs on a supported CPU. Use a unique `RUN_ID`: - -```bash -python tasks/deletion-resilient-vector-search/tools/prepare_storage.py --run-id RUN_ID -export DELETION_STORAGE_ROOT="$PWD/jobs/.task-storage/deletion-resilient-vector-search/RUN_ID" -``` - -Configure `AGENT_OPENROUTER_API_KEY` through the existing ignored `.env` or -authorized process environment, following the repository README. -The independent verifier audit also needs `VERIFIER_OPENAI_API_KEY` and -`VERIFIER_OPENAI_BASE_URL` as documented by the repository runner. Credentials -are not submission inputs. - -Invoke the standard entrypoint directly: - -```bash -bash scripts/run_task.sh --task deletion-resilient-vector-search --agent codex --openrouter \ - --model openai/gpt-6-luna --reasoning-effort max \ - --output "jobs/deletion-resilient-vector-search-codex-openrouter-luna-max-RUN_ID" -``` - -Inspect the resulting agent trajectory, submission and verifier report under the -job directory. The score alone does not distinguish an invalid submission from -an infrastructure error. - -After every container/process for this run has exited, unmount its storage: - -```bash -python tasks/deletion-resilient-vector-search/tools/prepare_storage.py --run-id RUN_ID --unmount -``` - -### Validate the package - -```bash -python scripts/check_tasks.py tasks/deletion-resilient-vector-search -python scripts/download_assets.py --task deletion-resilient-vector-search --verify-only -``` - -Build both `environment/Dockerfile` and `tests/Dockerfile` with their respective -directories as build contexts. Run the public helper and verifier with the task's -resource controls and mounts. Tests that exercise process isolation or GPU -enforcement belong in disposable task containers, not the shared host. - -## References - -None. diff --git a/tasks/deletion-resilient-vector-search/assets.json b/tasks/deletion-resilient-vector-search/assets.json deleted file mode 100644 index 7a98635..0000000 --- a/tasks/deletion-resilient-vector-search/assets.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "schema_version": 1, - "files": [ - { - "path": "data/source/sift.tar.gz", - "size_bytes": 168280445, - "sha256": "92f1270c5e3a0cb46b89983e72b0511e4df065c31a9fa0276d8c9b1fca5bc81a", - "source": { - "repo_id": "fzliu/sift1m", - "repo_type": "dataset", - "revision": "339f78ced567d358c0971413595bf85e5b9db036", - "filename": "sift.tar.gz" - } - }, - { - "path": "data/source/gist.tar.gz", - "size_bytes": 2740172684, - "sha256": "01469a7f1c3768853525e543d537e2dfa1adece927616405e360952e3f67df73", - "source": { - "repo_id": "fzliu/gist1m", - "repo_type": "dataset", - "revision": "a98d7415dba638216300552059013cc627293409", - "filename": "gist.tar.gz" - } - } - ] -} diff --git a/tasks/deletion-resilient-vector-search/environment/.dockerignore b/tasks/deletion-resilient-vector-search/environment/.dockerignore deleted file mode 100644 index 4e094e3..0000000 --- a/tasks/deletion-resilient-vector-search/environment/.dockerignore +++ /dev/null @@ -1,3 +0,0 @@ -__pycache__/ -**/__pycache__/ -*.py[cod] diff --git a/tasks/deletion-resilient-vector-search/environment/Dockerfile b/tasks/deletion-resilient-vector-search/environment/Dockerfile deleted file mode 100644 index 25e712c..0000000 --- a/tasks/deletion-resilient-vector-search/environment/Dockerfile +++ /dev/null @@ -1,24 +0,0 @@ -FROM docker.io/hanhainebula/search-swe-base:cpu-py3.12-1.0.0 - -ARG SEARCH_SWE_CODEX_VERSION=0.164.0 -ARG SEARCH_SWE_CLAUDE_CODE_VERSION=2.1.309 -ARG SEARCH_SWE_PI_VERSION=1.4.0 -RUN npm install --global --ignore-scripts \ - --registry=https://registry.npmmirror.com \ - "@openai/codex@${SEARCH_SWE_CODEX_VERSION}" \ - "@earendil-works/pi-coding-agent@${SEARCH_SWE_PI_VERSION}" \ - && npm install --global \ - --registry=https://registry.npmmirror.com \ - "@anthropic-ai/claude-code@${SEARCH_SWE_CLAUDE_CODE_VERSION}" \ - && codex --version | grep -Fx "codex-cli ${SEARCH_SWE_CODEX_VERSION}" \ - && test "$(pi --version)" = "${SEARCH_SWE_PI_VERSION}" \ - && test "$(claude --version)" = "${SEARCH_SWE_CLAUDE_CODE_VERSION} (Claude Code)" -RUN claude --help | grep -F "(low, medium, high, xhigh, max)" >/dev/null - -COPY tools /task/tools -RUN chmod -R a+rX /task/tools && chmod 755 /task/tools/*.py && mkdir -p /app -RUN /opt/conda/bin/python -c 'import faiss,numpy,psutil; assert (faiss.__version__,numpy.__version__,psutil.__version__) == ("1.15.0","2.5.2","7.2.2")' - -RUN useradd --create-home --uid 10001 --user-group submission -ENV HOME=/home/submission -CMD ["sh", "-c", "sleep infinity"] diff --git a/tasks/deletion-resilient-vector-search/environment/docker-compose.yaml b/tasks/deletion-resilient-vector-search/environment/docker-compose.yaml deleted file mode 100644 index 003c826..0000000 --- a/tasks/deletion-resilient-vector-search/environment/docker-compose.yaml +++ /dev/null @@ -1,49 +0,0 @@ -services: - main: - mem_limit: 32g - cpuset: ${DELETION_CPUSET:-0-15} - memswap_limit: 32g - pids_limit: 512 - volumes: - - type: bind - source: ../data/public - target: /task/data/public - read_only: true - bind: - create_host_path: false - - type: bind - source: ./docs - target: /task/docs - read_only: true - bind: - create_host_path: false - - type: bind - source: ${DELETION_STORAGE_ROOT:?Prepare job storage first}/agent/app - target: /app - bind: - create_host_path: false - - type: bind - source: ${DELETION_STORAGE_ROOT:?Prepare job storage first}/agent/tmp - target: /tmp - bind: - create_host_path: false - - type: bind - source: ${DELETION_STORAGE_ROOT:?Prepare job storage first}/agent/var-tmp - target: /var/tmp - bind: - create_host_path: false - - type: bind - source: ${DELETION_STORAGE_ROOT:?Prepare job storage first}/agent/home - target: /home/submission - bind: - create_host_path: false - - type: bind - source: ${DELETION_STORAGE_ROOT:?Prepare job storage first}/agent/shm - target: /dev/shm - bind: - create_host_path: false - - type: bind - source: ${DELETION_STORAGE_ROOT:?Prepare job storage first}/agent/lock - target: /run/lock - bind: - create_host_path: false diff --git a/tasks/deletion-resilient-vector-search/environment/docs/available_resources.md b/tasks/deletion-resilient-vector-search/environment/docs/available_resources.md deleted file mode 100644 index 3dcd776..0000000 --- a/tasks/deletion-resilient-vector-search/environment/docs/available_resources.md +++ /dev/null @@ -1,12 +0,0 @@ -# Available resources - -Use only the supplied local vectors, calibration data, public development deletion -order, frozen reference measurements, installed libraries and compiler/runtime. -No network, additional datasets, GPU, external embeddings, search services, -rerankers or model APIs are available to the submission. No submission API keys -are injected. The coding agent's provider connection is a harness exception only. - -The public exact/HNSW reference programs are optional tools. No algorithm or -training procedure is mandatory beyond a genuine graph index and the task's -interface/resource requirements. Hidden inputs, future deletion IDs, answers, -grader state and previous evaluation outputs are not permitted resources. diff --git a/tasks/deletion-resilient-vector-search/environment/docs/environment.md b/tasks/deletion-resilient-vector-search/environment/docs/environment.md deleted file mode 100644 index e3fd44e..0000000 --- a/tasks/deletion-resilient-vector-search/environment/docs/environment.md +++ /dev/null @@ -1,41 +0,0 @@ -# Runtime and input contract - -The CPU base is `docker.io/hanhainebula/search-swe-base:cpu-py3.12-1.0.0`. -Use `/opt/conda/bin/python` (Python 3.12). The image verifies FAISS 1.15.0, -NumPy 2.5.2 and psutil 7.2.2; hnswlib 0.8.0 and C/C++ build tools are installed. -No GPU is provided. - -Inputs are read-only under `/task/data/public/{sift,gist}`. Each contains -`base.f32`, `calibration.f32`, `deletions.i32` and `config.json`. -Current phase limits and scoring references are in `REFERENCES` in -`/task/tools/runtime_limits.py`. Each `calibration.f32` contains only 10 official-learn examples -to illustrate the protocol; the smoke checker defaults to all 10 and rejects -larger query counts. These examples do not estimate hidden-test performance. -Raw source archives, -hidden query vectors, hidden deletion lists and exact answers are never mounted -in the agent container. `/task/tools` has the public reference and smoke checker. - -Create `/app/run.sh` and code under `/app`. During final verification `/app` is -read-only, UID 10001 runs the submission, and only the build message's `work_dir` -is writable. No symlinks in submitted artifacts. Compilation must therefore -write to work_dir and counts toward the build allowance. Use only supplied files -and installed tools. Process/thread cap: 512; maximum protocol line: 1 MiB. - -The declared envelope is 16 logical CPUs / 32 GiB memory / 100 GiB task storage. -Queries are serial and restricted to one logical CPU, inherited by descendants; -the supervisor temporarily adopts the stopped worker's effective UID to set -affinity, then restores root before grading. No added container capabilities are -needed. The worker has no capabilities and cannot call sched_setaffinity, create -Internet sockets, enter namespaces, ptrace, or gain privileges. Local Unix IPC is allowed. OMP, MKL and -OpenBLAS start with one thread, with Intel OpenMP automatic affinity disabled. -Build/delete may explicitly use 16 threads. Aggregate RSS is sampled every 10 ms -while waiting and at boundaries; storage is sampled every 0.5 s and at boundaries. -The public runtime-limits module defines exact phase deadlines. - -Development uses UID 10001 (`submission`) with no administrative privileges. -`/app`, `/tmp`, `/var/tmp`, `/home/submission`, `/dev/shm` and `/run/lock` share -a bounded ext4 filesystem. Keep all submitted files under `/app`; harness logs -are reserved for the harness and cannot store indexes or other task data. The -100 GiB allowance includes public input bytes, filesystem metadata and a 512 MiB -diagnostic reserve. The verifier uses the same quota mechanism for `/work`, -and subtracts submitted files too. Swap is disabled in both phases. diff --git a/tasks/deletion-resilient-vector-search/environment/tools/check_submission.py b/tasks/deletion-resilient-vector-search/environment/tools/check_submission.py deleted file mode 100755 index 500214d..0000000 --- a/tasks/deletion-resilient-vector-search/environment/tools/check_submission.py +++ /dev/null @@ -1,93 +0,0 @@ -#!/usr/bin/env python3 -"""Small public quality/protocol check; does not replace full verifier resource gates.""" -import argparse -import json -import math -import os -from pathlib import Path -import selectors -import subprocess -import tempfile -import time - -import faiss -import numpy as np - - -def main(): - ap = argparse.ArgumentParser(description=__doc__) - ap.add_argument('--run', default='/app/run.sh') - ap.add_argument('--dataset', choices=['sift', 'gist'], default='sift') - ap.add_argument('--queries', type=int, default=10) - ap.add_argument('--stages', type=int, default=3) - args = ap.parse_args() - if not 1 <= args.stages <= 100: - raise ValueError('stages must be 1..100') - p = Path('/task/data/public') / args.dataset - c = json.loads((p / 'config.json').read_text()) - d, n = c['dimension'], c['rows'] - if not 1 <= args.queries <= c['calibration_count']: - raise ValueError(f"queries must be 1..{c['calibration_count']}; only the public examples are available") - calibration = np.fromfile(p / 'calibration.f32', dtype='= 0 for v in values) - if not valid: - scores.append(0.) - else: - scores.append(len(set(v['id'] for v in values) & set(truth[i])) / 10) - print(json.dumps({'stage': stage + 1, 'recall': float(np.mean(scores)), 'query_seconds': elapsed}), flush=True) - finally: - process.kill() - process.wait() - selector.close() - - -if __name__ == '__main__': - main() diff --git a/tasks/deletion-resilient-vector-search/environment/tools/reference_server.py b/tasks/deletion-resilient-vector-search/environment/tools/reference_server.py deleted file mode 100755 index 0635e5f..0000000 --- a/tasks/deletion-resilient-vector-search/environment/tools/reference_server.py +++ /dev/null @@ -1,89 +0,0 @@ -#!/usr/bin/env python3 -"""Public fixed FAISS 1.15.0 references: exact full scan and HNSW tombstones. - ---keep is only for author-side measurement of independently rebuilt live sets. -It does not authorize contestants to rebuild after each deletion. -""" -import argparse -import gc -import json -import sys - -import faiss -import numpy as np - - -def main(): - ap = argparse.ArgumentParser() - ap.add_argument('--kind', choices=['full', 'hnsw'], required=True) - ap.add_argument('--keep', help='author-side original row IDs for one rebuilt reference') - args = ap.parse_args() - index = None - original = None - alive = None - edge_count = 0 - for line in sys.stdin: - request = json.loads(line) - op = request['op'] - if op == 'build': - faiss.omp_set_num_threads(16) - d, n = request['dimension'], request['rows'] - data = np.memmap(request['vectors'], dtype='= 0)) - alive = np.ones(n, dtype=np.uint8) - bitmap_dirty = True - response = {'ok': True, 'edge_count': edge_count} - elif op == 'delete': - faiss.omp_set_num_threads(16) - ids = np.asarray(request['ids'], dtype=np.int64) - alive[ids] = 0 - bitmap_dirty = True - if args.kind == 'full': - index.remove_ids(ids) - response = {'ok': True, 'edge_count': edge_count} - elif op == 'query': - faiss.omp_set_num_threads(1) - q = np.asarray([request['vector']], dtype=np.float32) - if args.kind == 'hnsw': - # Cache the bitmap by deletion count; refreshing it is query work. - if bitmap_dirty: - bitmap = np.packbits(alive[original], bitorder='little') - selector = faiss.IDSelectorBitmap(len(bitmap), faiss.swig_ptr(bitmap)) - bitmap_dirty = False - params = faiss.SearchParametersHNSW() - params.efSearch = 128 - params.sel = selector - faiss.cvar.hnsw_stats.reset() - distances, labels = index.search(q, 10, params=params) - calls = int(faiss.cvar.hnsw_stats.ndis) - labels = np.asarray([[int(original[i]) if i >= 0 else -1 for i in labels[0]]]) - else: - distances, labels = index.search(q, 10) - calls = int(index.ntotal) - response = {'query_id': request['query_id'], 'results': [ - {'id': int(i), 'distance': float(v)} for i, v in zip(labels[0], distances[0]) if i >= 0 - ], 'distance_computations': calls} - else: - raise ValueError(f'Unknown operation {op}') - print(json.dumps(response, separators=(',', ':'), allow_nan=False), flush=True) - - -if __name__ == '__main__': - main() diff --git a/tasks/deletion-resilient-vector-search/environment/tools/runtime_limits.py b/tasks/deletion-resilient-vector-search/environment/tools/runtime_limits.py deleted file mode 100644 index 0e25bcc..0000000 --- a/tasks/deletion-resilient-vector-search/environment/tools/runtime_limits.py +++ /dev/null @@ -1,442 +0,0 @@ -"""Fixed parameters for the current task contract.""" - -REFERENCES = {'sift': {'status': 'frozen', - 'stages': 100, - 'config': {'dataset': 'sift', - 'rows': 1000000, - 'dimension': 128, - 'dtype': '