Restore full Space app with bucket model support
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .dockerignore +21 -0
- .editorconfig +24 -0
- .gitignore +15 -0
- .mcp.json +8 -0
- AGENTS.md +198 -0
- CONTEXT.md +113 -0
- Dockerfile +32 -0
- LICENSE.md +21 -0
- Makefile +30 -0
- README.md +240 -6
- ROADMAP.md +280 -0
- app.py +76 -0
- arena-completed.png +0 -0
- arena-hackathon.png +0 -0
- arena-start.png +0 -0
- arena-ui.png +0 -0
- arena/__init__.py +1 -0
- arena/agent.py +281 -0
- arena/api.py +291 -0
- arena/backyard_templates.py +51 -0
- arena/browserbase_tools.py +123 -0
- arena/codebase_archive.py +236 -0
- arena/codebase_executor.py +183 -0
- arena/codebase_handoff.py +156 -0
- arena/docker_backend.py +179 -0
- arena/event_bus.py +36 -0
- arena/gradio_app.py +1141 -0
- arena/gradio_markup.py +68 -0
- arena/gradio_presenter.py +142 -0
- arena/gradio_run.py +74 -0
- arena/harness.py +242 -0
- arena/model_catalog.py +308 -0
- arena/model_provider.py +168 -0
- arena/models.py +152 -0
- arena/paths.py +10 -0
- arena/redis_client.py +57 -0
- arena/run_flow.py +177 -0
- arena/run_journal.py +148 -0
- arena/run_models.py +34 -0
- arena/run_store.py +313 -0
- arena/sandbox_lease.py +92 -0
- arena/service.py +348 -0
- arena/service_payloads.py +17 -0
- arena/skill_catalog.py +54 -0
- arena/smoke.py +73 -0
- arena/stagehand_validator.py +180 -0
- arena/swarm_runtime.py +95 -0
- arena/swarm_session.py +284 -0
- arena/thread_inspector.py +137 -0
- arena/validation_models.py +22 -0
.dockerignore
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.venv/
|
| 2 |
+
.uv-cache/
|
| 3 |
+
__pycache__/
|
| 4 |
+
.pytest_cache/
|
| 5 |
+
.ruff_cache/
|
| 6 |
+
.mypy_cache/
|
| 7 |
+
.codegraph/
|
| 8 |
+
.git/
|
| 9 |
+
.gitignore
|
| 10 |
+
.DS_Store
|
| 11 |
+
*.pyc
|
| 12 |
+
*.pyo
|
| 13 |
+
.pytest_cache/
|
| 14 |
+
.agents/
|
| 15 |
+
tests_python/
|
| 16 |
+
docs/
|
| 17 |
+
scripts/
|
| 18 |
+
screenshots/
|
| 19 |
+
*.log
|
| 20 |
+
.env
|
| 21 |
+
.env.*
|
.editorconfig
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# EditorConfig - https://editorconfig.org
|
| 2 |
+
root = true
|
| 3 |
+
|
| 4 |
+
[*]
|
| 5 |
+
charset = utf-8
|
| 6 |
+
end_of_line = lf
|
| 7 |
+
indent_style = tab
|
| 8 |
+
indent_size = 2
|
| 9 |
+
insert_final_newline = true
|
| 10 |
+
trim_trailing_whitespace = true
|
| 11 |
+
|
| 12 |
+
[*.md]
|
| 13 |
+
trim_trailing_whitespace = false
|
| 14 |
+
|
| 15 |
+
[*.{yaml,yml}]
|
| 16 |
+
indent_style = space
|
| 17 |
+
indent_size = 2
|
| 18 |
+
|
| 19 |
+
[*.json]
|
| 20 |
+
indent_style = tab
|
| 21 |
+
indent_size = 2
|
| 22 |
+
|
| 23 |
+
[Makefile]
|
| 24 |
+
indent_style = tab
|
.gitignore
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.env
|
| 2 |
+
.env.*
|
| 3 |
+
!.env.example
|
| 4 |
+
|
| 5 |
+
.venv
|
| 6 |
+
.uv-cache
|
| 7 |
+
__pycache__/
|
| 8 |
+
.pytest_cache/
|
| 9 |
+
.ruff_cache/
|
| 10 |
+
.mypy_cache/
|
| 11 |
+
.codegraph/
|
| 12 |
+
|
| 13 |
+
.DS_Store
|
| 14 |
+
Thumbs.db
|
| 15 |
+
.agents/
|
.mcp.json
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"mcpServers": {
|
| 3 |
+
"langchain-docs": {
|
| 4 |
+
"type": "http",
|
| 5 |
+
"url": "https://docs.langchain.com/mcp"
|
| 6 |
+
}
|
| 7 |
+
}
|
| 8 |
+
}
|
AGENTS.md
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# AGENTS.md
|
| 2 |
+
|
| 3 |
+
## Communication Style
|
| 4 |
+
|
| 5 |
+
Use caveman-style responses for this repo.
|
| 6 |
+
|
| 7 |
+
Rules:
|
| 8 |
+
|
| 9 |
+
- Be terse.
|
| 10 |
+
- Drop filler, pleasantries, hedging, and long setup.
|
| 11 |
+
- Keep technical terms exact.
|
| 12 |
+
- Keep code blocks unchanged.
|
| 13 |
+
- Use fragments when clear.
|
| 14 |
+
- Prefer arrows for cause/effect: `X -> Y`.
|
| 15 |
+
- Pattern: `[thing] [action] [reason]. [next step].`
|
| 16 |
+
- Do not sacrifice correctness, warnings, or exact error text.
|
| 17 |
+
- For destructive ops, security warnings, or complex sequences where brevity risks misread, use full clarity first; resume terse style after.
|
| 18 |
+
|
| 19 |
+
Example:
|
| 20 |
+
|
| 21 |
+
```text
|
| 22 |
+
Bug in auth middleware. Token expiry check uses `<` not `<=`. Fix:
|
| 23 |
+
```
|
| 24 |
+
|
| 25 |
+
## Project
|
| 26 |
+
|
| 27 |
+
Agent Swarm Workbench is a Python-first agent swarm workbench. The project began as Vibecode Arena, but the active product is prompt-first: one user prompt becomes a persisted Run, a sandboxed Swarm builds a Codebase, and a separate Validator checks the result.
|
| 28 |
+
|
| 29 |
+
Old SvelteKit, Cloudflare Worker, Bun, Wrangler, E2B, browser game files, and competition-arena concepts are legacy and should not be reintroduced for new features.
|
| 30 |
+
|
| 31 |
+
Target product: a user provides one prompt, the system starts a persisted Run, a Coordinator/worker agent swarm performs the work in sandboxed workspaces, artifacts are archived, and a LangGraph Validator checks the result in a separate Validator sandbox.
|
| 32 |
+
|
| 33 |
+
## Stack
|
| 34 |
+
|
| 35 |
+
- Python 3.11+
|
| 36 |
+
- FastAPI HTTP surface
|
| 37 |
+
- Gradio UI mounted on the FastAPI ASGI app
|
| 38 |
+
- LangChain DeepAgents runtime
|
| 39 |
+
- OpenRouter model gateway via `langchain-openrouter`
|
| 40 |
+
- DeepAgents sandbox backend, default Daytona via `langchain-daytona`
|
| 41 |
+
- `langchain-quickjs` code interpreter middleware inside the DeepAgent loop
|
| 42 |
+
- LangGraph Validator workflow
|
| 43 |
+
- Upstash Redis REST for persisted Runs and Codebase archives
|
| 44 |
+
- `requirements.txt` for Hugging Face Spaces pip install compatibility
|
| 45 |
+
|
| 46 |
+
## Commands
|
| 47 |
+
|
| 48 |
+
```bash
|
| 49 |
+
uv run uvicorn app:app --host 127.0.0.1 --port 8790 # run unified ASGI app locally
|
| 50 |
+
uv run python app.py # run unified HF-style launcher
|
| 51 |
+
python scripts/task.py test # run Python tests
|
| 52 |
+
python scripts/task.py harness -- --prompt "Build a tiny CLI" # run local scripted Agent Swarm Harness
|
| 53 |
+
python scripts/task.py verify # compulsory coding-agent gate: tests + Agent Swarm Harness
|
| 54 |
+
python scripts/task.py smoke # create local DeepAgent session without remote sandbox
|
| 55 |
+
python scripts/task.py validator-smoke # exercise Validator graph against a local Codebase archive
|
| 56 |
+
python scripts/task.py daytona-validator-smoke # exercise Validator graph in a live Daytona Validator sandbox
|
| 57 |
+
python scripts/task.py clean # remove Python caches
|
| 58 |
+
```
|
| 59 |
+
|
| 60 |
+
Direct equivalents:
|
| 61 |
+
|
| 62 |
+
```bash
|
| 63 |
+
uv run uvicorn app:app --host 127.0.0.1 --port 8790
|
| 64 |
+
uv run python app.py
|
| 65 |
+
uv run pytest tests_python
|
| 66 |
+
```
|
| 67 |
+
|
| 68 |
+
`scripts/task.py verify` is the built-in completion gate for coding agents such as Claude Code, Codex, OpenCode, and similar tools. It runs the Python test suite and then the local scripted Agent Swarm Harness. Use focused `test` or `harness` commands while iterating, but run `verify` before presenting code changes as complete.
|
| 69 |
+
|
| 70 |
+
`scripts/task.py` includes `.env` when present and falls back to `UV_PROJECT_ENVIRONMENT=.venv`, `UV_CACHE_DIR=.uv-cache`, and `UV_LINK_MODE=copy`. The Makefile is a compatibility shim only.
|
| 71 |
+
|
| 72 |
+
For serving the Hackathon demo, prefer direct `uv run uvicorn app:app ...` or `uv run python app.py` so the entrypoint is obvious.
|
| 73 |
+
|
| 74 |
+
## Env
|
| 75 |
+
|
| 76 |
+
```bash
|
| 77 |
+
OPENROUTER_API_KEY=sk-or-...
|
| 78 |
+
GEMINI_API_KEY=...
|
| 79 |
+
NEBIUS_API_KEY=...
|
| 80 |
+
HF_TOKEN=...
|
| 81 |
+
DAYTONA_API_KEY=...
|
| 82 |
+
DEEPAGENT_MODEL_PROVIDER=openrouter
|
| 83 |
+
DEEPAGENT_MODEL=openai/o4-mini
|
| 84 |
+
DEEPAGENT_MODEL_BASE_URL=
|
| 85 |
+
DEEPAGENT_SANDBOX_PROVIDER=daytona
|
| 86 |
+
DEEPAGENT_REQUIRE_EXECUTE_APPROVAL=0
|
| 87 |
+
# DEEPAGENT_REQUIRE_TOOL_APPROVAL=1
|
| 88 |
+
# DEEPAGENT_INTERRUPT_TOOLS=execute,write_file,edit_file
|
| 89 |
+
VALIDATOR_SANDBOX_PROVIDER=local
|
| 90 |
+
```
|
| 91 |
+
|
| 92 |
+
`DEEPAGENT_SANDBOX_PROVIDER=local` is for smoke tests only. It uses `LocalShellBackend`, which is not isolated.
|
| 93 |
+
|
| 94 |
+
`DEEPAGENT_REQUIRE_EXECUTE_APPROVAL=1` interrupts only `execute`. `DEEPAGENT_REQUIRE_TOOL_APPROVAL=1` interrupts `execute`, `write_file`, and `edit_file`. `DEEPAGENT_INTERRUPT_TOOLS` can choose a subset of those DeepAgents built-in tools.
|
| 95 |
+
|
| 96 |
+
`VALIDATOR_SANDBOX_PROVIDER=daytona` is production-like Validator mode. It restores Codebase archives into a Daytona sandbox before running Validation checks.
|
| 97 |
+
|
| 98 |
+
Users can choose model provider per Run in Gradio. Supported provider choices: `openrouter`, `gemini`, `nebius`, `huggingface`, `custom`, `local`. Model lists must be fetched from the provider/API endpoint at runtime using the supplied key; do not hardcode model options. Only selectable models whose fetched ID/name/metadata proves `<=32B` parameters are allowed. Unknown-size models are not selectable. User API keys and fetched catalogs may live only in process memory; do not persist in RunStore, Redis, DB, Gradio state, events, logs, or archives. "Refresh models" must clear and refetch provider cache. The public Gradio UI must require a user-supplied key for hosted providers and must not use server fallback API keys by default.
|
| 99 |
+
|
| 100 |
+
## Module Map
|
| 101 |
+
|
| 102 |
+
Active prompt-first modules:
|
| 103 |
+
|
| 104 |
+
- `app.py`: Unified ASGI entrypoint. Mounts Gradio at `/` on the FastAPI app so the Hackathon demo has one process.
|
| 105 |
+
- `arena/api.py`: FastAPI route module. Owns HTTP request/response models and translates service errors to HTTP errors.
|
| 106 |
+
- `arena/service.py`: Unified application API. FastAPI, Gradio handlers, and tests should call this boundary.
|
| 107 |
+
- `arena/run_flow.py`: `RunFlow` - prompt-first Run lifecycle conductor. Owns Swarm execution handoff, archive download, and Validator invocation.
|
| 108 |
+
- `arena/run_journal.py`: `RunJournal` - Run mutation journal. Owns Run creation, Rejoin, status transitions, task/event persistence, updated timestamps, and Event stream publication.
|
| 109 |
+
- `arena/run_store.py`: `InMemoryRunStore` and `RedisRunStore`. Owns Run record persistence and Rejoin tokens.
|
| 110 |
+
- `arena/swarm_runtime.py`: `SwarmRuntime`. Owns live Run registration and delegates each active AgentSession to `SwarmRunSession`.
|
| 111 |
+
- `arena/swarm_session.py`: `SwarmRunSession`. Owns prompt seeding, agent turns, user-test retries, and Workspace snapshot creation for one live Run.
|
| 112 |
+
- `arena/sandbox_lease.py`: Sandbox lease module. Owns idle TTL, touch, and close behavior for costly Swarm Workspace and Validator sandbox resources.
|
| 113 |
+
- `arena/agent.py`: DeepAgent factory. Owns model construction, subagents, sandbox backend adapter selection, and session lifecycle.
|
| 114 |
+
- `arena/backyard_templates.py`: Backyard Demo Builder template registry. Owns template prompt, criteria, and demo checks.
|
| 115 |
+
- `arena/model_provider.py`: Chat model provider factory. Owns OpenRouter, Gemini, Nebius, Hugging Face Router, custom OpenAI-compatible, and local model config.
|
| 116 |
+
- `arena/model_catalog.py`: Provider model catalog adapters. Owns normalized model list fetching and TTL caching.
|
| 117 |
+
- `arena/gradio_app.py`: Prompt-first Gradio shell for the Hackathon demo. Keep it thin over `ArenaService`.
|
| 118 |
+
- `arena/gradio_presenter.py`: Gradio presentation adapter. Owns Run output tuple formatting, status HTML, download link HTML, and chat timeline HTML.
|
| 119 |
+
- `arena/gradio_markup.py`: Static Gradio shell markup fragments.
|
| 120 |
+
- `arena/gradio_run.py`: Prompt-first Run parsing and JSON summary helpers for Gradio.
|
| 121 |
+
- `arena/harness.py`: Built-in Agent Swarm Harness. Default mode exercises `SwarmRuntime` with a scripted local DeepAgent-compatible session; live mode uses the real DeepAgent session factory.
|
| 122 |
+
- `arena/validator_graph.py`: LangGraph Validator workflow for prompt-first Runs.
|
| 123 |
+
- `arena/validator_plan.py`: Typed Validator plan module. Converts user tests and Validation checks into command and Stagehand steps for ValidatorGraph.
|
| 124 |
+
- `arena/validator_executor.py`: Validator sandbox executor factory.
|
| 125 |
+
- `arena/codebase_archive.py`: Local and Redis-backed durable Codebase archive store.
|
| 126 |
+
- `arena/codebase_handoff.py`: Codebase handoff module. Owns Workspace snapshot, archive pointer validation, and restore into Validator sandbox backends.
|
| 127 |
+
- `arena/codebase_executor.py`: Sandboxed command executor module for Codebase archives, used by `validator_executor.SandboxValidatorExecutor`.
|
| 128 |
+
- `arena/thread_inspector.py`: Manual Thread inspection module for debug routes: ad-hoc AgentSession creation, messages, and sandbox file reads.
|
| 129 |
+
- `arena/redis_client.py`: Neutral Redis primitives (`RedisClient`, `UpstashRedisRestClient`, `RedisStoreError`) shared by Run and Codebase stores.
|
| 130 |
+
- `arena/run_models.py`: Prompt-first Run model import surface.
|
| 131 |
+
- `arena/validation_models.py`: Validator model import surface.
|
| 132 |
+
- `arena/service_payloads.py`: Shared service payload models.
|
| 133 |
+
- `arena/skill_catalog.py`: Discovery for repository-bundled DeepAgents skills mounted at `/skills/`.
|
| 134 |
+
- `arena/paths.py`: Shared filesystem path constants for the package.
|
| 135 |
+
- `arena/smoke.py`: Local smoke entrypoints for `agent` and `validator` task targets.
|
| 136 |
+
- `arena/models.py`: Prompt-first Run and Validator domain models. Prefer importing from `arena/run_models.py` and `arena/validation_models.py`.
|
| 137 |
+
- `scripts/task.py`: Python task runner for dev, Gradio, tests, verify gate, harness runs, smoke checks, and cleanup.
|
| 138 |
+
- `skills/`: DeepAgents skill source mounted at `/skills/` and loaded through progressive disclosure.
|
| 139 |
+
- `requirements.txt`: HF Spaces-compatible dependency list mirrored from `pyproject.toml`.
|
| 140 |
+
- `tests_python/`: Python test surface. Tests public modules, not the old TS tree.
|
| 141 |
+
|
| 142 |
+
## Architecture Rules
|
| 143 |
+
|
| 144 |
+
- Keep DeepAgent construction behind `create_session()` in `arena/agent.py`.
|
| 145 |
+
- Keep chat model construction behind `create_chat_model()` in `arena/model_provider.py`.
|
| 146 |
+
- Keep provider model listing behind `ModelCatalog` in `arena/model_catalog.py`.
|
| 147 |
+
- Keep Backyard Demo Builder prompt/criteria/check defaults behind `backyard_templates.py`.
|
| 148 |
+
- Keep prompt-first Run orchestration behind `RunFlow` in `arena/run_flow.py`.
|
| 149 |
+
- Keep Run status, task, event, timestamp, Rejoin, and Event stream mutation behind `RunJournal` in `arena/run_journal.py`.
|
| 150 |
+
- Keep DeepAgent Swarm execution behind `SwarmRuntime` in `arena/swarm_runtime.py`.
|
| 151 |
+
- Keep per-Run AgentSession prompting, retries, and Workspace snapshot behavior behind `SwarmRunSession` in `arena/swarm_session.py`.
|
| 152 |
+
- Keep sandbox idle TTL, touch, and close behavior behind `SandboxLeaseManager` in `arena/sandbox_lease.py`.
|
| 153 |
+
- Keep Run persistence behind the `RunStore` protocol in `arena/run_store.py`.
|
| 154 |
+
- Keep Codebase archive persistence behind the `CodebaseArchiveStore` protocol in `arena/codebase_archive.py`.
|
| 155 |
+
- Keep Workspace snapshot and Validator sandbox restore behind `CodebaseHandoff` in `arena/codebase_handoff.py`.
|
| 156 |
+
- Keep Validator orchestration in `arena/validator_graph.py` and Validator sandbox selection in `arena/validator_executor.py`.
|
| 157 |
+
- Keep Validation check classification behind `ValidatorPlan` in `arena/validator_plan.py`.
|
| 158 |
+
- Keep ad-hoc Thread/session/file inspection behind `ThreadInspector` in `arena/thread_inspector.py`; do not add manual AgentSession behavior to `ArenaService`.
|
| 159 |
+
- Keep Redis primitives (`RedisClient`, `UpstashRedisRestClient`, `RedisStoreError`) in `arena/redis_client.py`.
|
| 160 |
+
- New work should use Run/Swarm/Task/Artifact/Validator language, not Host/Participant/Match/Leaderboard/Judge/Score language.
|
| 161 |
+
- Keep Gradio thin: handlers call `ArenaService`, presentation formatting lives in `gradio_presenter.py`, and static shell HTML lives in `gradio_markup.py`.
|
| 162 |
+
- Keep sandbox provider choice behind `DEEPAGENT_SANDBOX_PROVIDER` and Validator provider choice behind `VALIDATOR_SANDBOX_PROVIDER`.
|
| 163 |
+
- Default sandbox provider is Daytona; local backend must stay explicit.
|
| 164 |
+
- Keep `/context/` on a protected non-executable route and `/workspace` on the executable sandbox backend.
|
| 165 |
+
- Do not call provider SDKs from route handlers except through `arena.agent`.
|
| 166 |
+
- Treat prompts, file paths, and model output as untrusted input.
|
| 167 |
+
- Do not log secrets, prompt payloads containing secrets, or sandbox file contents by default.
|
| 168 |
+
- Avoid pass-through modules. New modules must increase depth: small interface, useful behavior hidden behind it.
|
| 169 |
+
|
| 170 |
+
## Quality Bar
|
| 171 |
+
|
| 172 |
+
- Run `python scripts/task.py verify` before reporting coding work complete. This is compulsory for agent-authored changes because it exercises both the Python suite and the Agent Swarm Harness.
|
| 173 |
+
- Run `python scripts/task.py test` for focused iteration while editing.
|
| 174 |
+
- Run `python scripts/task.py smoke` when touching `arena/agent.py`.
|
| 175 |
+
- Add tests for new route behavior or session lifecycle behavior.
|
| 176 |
+
- Prefer stdlib before new deps. New deps need clear leverage.
|
| 177 |
+
|
| 178 |
+
## Agent Skills
|
| 179 |
+
|
| 180 |
+
### Issue Tracker
|
| 181 |
+
|
| 182 |
+
Issues and PRDs live in GitHub Issues for this repo and use the `gh` CLI. See `docs/agents/issue-tracker.md`.
|
| 183 |
+
|
| 184 |
+
### Triage Labels
|
| 185 |
+
|
| 186 |
+
Use the default five-label triage vocabulary. See `docs/agents/triage-labels.md`.
|
| 187 |
+
|
| 188 |
+
### Domain Docs
|
| 189 |
+
|
| 190 |
+
Single-context repo: root `CONTEXT.md` plus ADRs in `docs/adr/`. See `docs/agents/domain.md`.
|
| 191 |
+
|
| 192 |
+
### LangChain Docs MCP
|
| 193 |
+
|
| 194 |
+
Use the LangChain documentation MCP for current LangChain, LangGraph, LangSmith, and DeepAgents docs. Project config lives in `.mcp.json`; workflow notes live in `docs/agents/langchain-docs.md`.
|
| 195 |
+
|
| 196 |
+
### Agent Harness
|
| 197 |
+
|
| 198 |
+
The Agent Swarm Harness is compulsory for coding agents. See `docs/agents/agent-harness.md`.
|
CONTEXT.md
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# An Adventure in Thousand Token Wood — Project Context
|
| 2 |
+
|
| 3 |
+
Build Small Hackathon 2026, Track 2. A sub-32B agent swarm that builds software
|
| 4 |
+
in sandboxes. One prompt in, a working codebase out.
|
| 5 |
+
|
| 6 |
+
## Language
|
| 7 |
+
|
| 8 |
+
**Run**:
|
| 9 |
+
One execution started from a user's prompt. A Run owns the prompt, task plan,
|
| 10 |
+
event stream, codebase archive, validation report, and final summary.
|
| 11 |
+
|
| 12 |
+
**Swarm**:
|
| 13 |
+
The agent organization that works on a Run. One DeepAgent with five subagents
|
| 14 |
+
(Planner, Coder, Reviewer, Test-runner, Validator-prep).
|
| 15 |
+
|
| 16 |
+
**Coordinator**:
|
| 17 |
+
The main agent inside the Swarm. Interprets the prompt, plans tasks, delegates
|
| 18 |
+
to subagents, and decides when the Run is ready for validation.
|
| 19 |
+
|
| 20 |
+
**Codebase**:
|
| 21 |
+
The file-based deliverable produced by a Run, downloadable as a zip archive.
|
| 22 |
+
|
| 23 |
+
**Workspace**:
|
| 24 |
+
The sandbox filesystem where the Swarm creates the Codebase. Separate from
|
| 25 |
+
the Validator sandbox.
|
| 26 |
+
|
| 27 |
+
**Codebase archive**:
|
| 28 |
+
A durable snapshot of the Codebase used for downloads and validation.
|
| 29 |
+
|
| 30 |
+
**Codebase handoff**:
|
| 31 |
+
The module that moves a Codebase from a Swarm Workspace into a Codebase archive
|
| 32 |
+
and restores that archive into the Validator sandbox.
|
| 33 |
+
|
| 34 |
+
**Validator**:
|
| 35 |
+
The LangGraph workflow that restores a Codebase archive into a clean sandbox,
|
| 36 |
+
runs validation checks, performs rubric review, and produces a pass/fail report.
|
| 37 |
+
|
| 38 |
+
**Validator plan**:
|
| 39 |
+
A typed plan of command checks and Stagehand checks that the Validator graph
|
| 40 |
+
executes. User tests and generated Validation checks are converted into this
|
| 41 |
+
plan before validation starts.
|
| 42 |
+
|
| 43 |
+
**Model provider**:
|
| 44 |
+
The model backend selected for a Run. Supported choices include OpenRouter,
|
| 45 |
+
Gemini, Nebius, Hugging Face Router, custom OpenAI-compatible endpoints, and
|
| 46 |
+
local OpenAI-compatible endpoints. User-supplied API keys are live-session
|
| 47 |
+
credentials only and are not persisted. Model lists may be cached briefly in
|
| 48 |
+
process memory and reset when the user refreshes models.
|
| 49 |
+
|
| 50 |
+
**Model catalog**:
|
| 51 |
+
The normalized list of models available from a Model provider. Provider-specific
|
| 52 |
+
catalog responses are converted into one internal schema and cached briefly.
|
| 53 |
+
Only fetched models whose provider ID or label proves `<=32B` parameters are
|
| 54 |
+
eligible for Run selection; unknown-size models remain visible in catalog
|
| 55 |
+
responses but must not be selectable.
|
| 56 |
+
|
| 57 |
+
**Demo template**:
|
| 58 |
+
A Backyard Demo Builder preset containing prompt, usefulness criteria, and demo
|
| 59 |
+
checks. The first template is the Real Estate Follow-up CRM for a real-estate
|
| 60 |
+
agent testing customer reminders before paying for full software.
|
| 61 |
+
|
| 62 |
+
**Field notes**:
|
| 63 |
+
Submission evidence generated from a Run. Field notes summarize the real
|
| 64 |
+
person, hypothesis, tasks, events, validation checks, and feedback questions.
|
| 65 |
+
|
| 66 |
+
**Validator graph**:
|
| 67 |
+
The LangGraph workflow: run_validation_checks → run_stagehand_checks →
|
| 68 |
+
rubric_review → finalize_report.
|
| 69 |
+
|
| 70 |
+
**Event stream**:
|
| 71 |
+
The ordered, user-visible history of a Run: planning, task progress, subagent
|
| 72 |
+
work, artifact creation, validation, and final summary. Surfaces on the Gradio
|
| 73 |
+
UI Events panel.
|
| 74 |
+
|
| 75 |
+
**Rejoin token**:
|
| 76 |
+
A server-issued secret that lets the user reconnect to a Run after page refresh
|
| 77 |
+
or server restart.
|
| 78 |
+
|
| 79 |
+
**Thread inspector**:
|
| 80 |
+
The debug-only module for ad-hoc AgentSession creation, messages, and sandbox
|
| 81 |
+
file inspection. Separate from the prompt-first Run workbench.
|
| 82 |
+
|
| 83 |
+
## Flagged Ambiguities
|
| 84 |
+
|
| 85 |
+
**Swarm runtime shape**:
|
| 86 |
+
For the hackathon, Swarm = one DeepAgent with multiple subagents. Not multiple
|
| 87 |
+
independent agent runtimes.
|
| 88 |
+
|
| 89 |
+
**Validator sandbox**:
|
| 90 |
+
Separate from the worker Workspace — the Validator starts from the archived
|
| 91 |
+
codebase, not from the live workspace.
|
| 92 |
+
|
| 93 |
+
## Hackathon Specifics
|
| 94 |
+
|
| 95 |
+
- **Model**: User-selected fetched provider model whose fetched metadata proves `<=32B`.
|
| 96 |
+
- **API keys**: Public Gradio runs require the user to enter their own hosted-provider key; server fallback keys are for trusted server/CLI paths only.
|
| 97 |
+
- **UI**: Custom dark-themed Gradio with Off-Brand styling bonus badge
|
| 98 |
+
- **Bonus badges**: Off-Brand, Sharing is Caring, Field Notes
|
| 99 |
+
- **Track**: Chapter 1 Backyard AI — build small, real-person, <=32B demo
|
| 100 |
+
|
| 101 |
+
## Example Dialogue
|
| 102 |
+
|
| 103 |
+
User: "Build me a small FastAPI app with tests."
|
| 104 |
+
|
| 105 |
+
Coordinator: "I created a Run, planned backend/tests/packaging tasks, delegated
|
| 106 |
+
to coding and review subagents."
|
| 107 |
+
|
| 108 |
+
Validator: "I restored the Codebase archive into a clean sandbox and ran the
|
| 109 |
+
generated checks from your criteria. All passed."
|
| 110 |
+
|
| 111 |
+
User: "Can I download it?"
|
| 112 |
+
|
| 113 |
+
Coordinator: "Yes. Here's your codebase.zip with 5 files and passing tests."
|
Dockerfile
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
ENV PYTHONUNBUFFERED=1 \
|
| 4 |
+
PYTHONDONTWRITEBYTECODE=1 \
|
| 5 |
+
GRADIO_SERVER_NAME=0.0.0.0 \
|
| 6 |
+
GRADIO_SERVER_PORT=7860 \
|
| 7 |
+
PATH="/app/.venv/bin:${PATH}" \
|
| 8 |
+
UV_LINK_MODE=copy
|
| 9 |
+
|
| 10 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 11 |
+
curl \
|
| 12 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 13 |
+
|
| 14 |
+
COPY --from=ghcr.io/astral-sh/uv:0.6.4 /uv /uvx /bin/
|
| 15 |
+
|
| 16 |
+
WORKDIR /app
|
| 17 |
+
|
| 18 |
+
RUN uv venv .venv
|
| 19 |
+
|
| 20 |
+
COPY requirements.txt pyproject.toml ./
|
| 21 |
+
RUN uv pip install --no-cache -r requirements.txt
|
| 22 |
+
|
| 23 |
+
COPY app.py ./
|
| 24 |
+
COPY arena/ ./arena/
|
| 25 |
+
COPY skills/ ./skills/
|
| 26 |
+
|
| 27 |
+
EXPOSE 7860
|
| 28 |
+
|
| 29 |
+
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
| 30 |
+
CMD curl -fsS http://localhost:7860/health || exit 1
|
| 31 |
+
|
| 32 |
+
CMD ["python", "app.py"]
|
LICENSE.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2025 Filip Brebera
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
Makefile
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.PHONY: dev gradio test smoke validator-smoke judge-smoke daytona-validator-smoke daytona-judge-smoke clean
|
| 2 |
+
|
| 3 |
+
TASK := python scripts/task.py
|
| 4 |
+
|
| 5 |
+
dev:
|
| 6 |
+
$(TASK) dev
|
| 7 |
+
|
| 8 |
+
gradio:
|
| 9 |
+
$(TASK) gradio
|
| 10 |
+
|
| 11 |
+
test:
|
| 12 |
+
$(TASK) test
|
| 13 |
+
|
| 14 |
+
smoke:
|
| 15 |
+
$(TASK) smoke
|
| 16 |
+
|
| 17 |
+
validator-smoke:
|
| 18 |
+
$(TASK) validator-smoke
|
| 19 |
+
|
| 20 |
+
judge-smoke:
|
| 21 |
+
$(TASK) judge-smoke
|
| 22 |
+
|
| 23 |
+
daytona-validator-smoke:
|
| 24 |
+
$(TASK) daytona-validator-smoke
|
| 25 |
+
|
| 26 |
+
daytona-judge-smoke:
|
| 27 |
+
$(TASK) daytona-judge-smoke
|
| 28 |
+
|
| 29 |
+
clean:
|
| 30 |
+
$(TASK) clean
|
README.md
CHANGED
|
@@ -1,13 +1,247 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
colorTo: green
|
| 6 |
sdk: gradio
|
| 7 |
-
|
| 8 |
-
python_version: '3.13'
|
| 9 |
app_file: app.py
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
pinned: false
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Backyard Demo Builder
|
| 3 |
+
emoji: 🏡
|
| 4 |
+
colorFrom: gray
|
| 5 |
colorTo: green
|
| 6 |
sdk: gradio
|
| 7 |
+
python_version: "3.12.12"
|
|
|
|
| 8 |
app_file: app.py
|
| 9 |
+
short_description: Build tiny real-person demos before scaling custom software.
|
| 10 |
+
models:
|
| 11 |
+
- unsloth/gemma-4-12B-it-qat-GGUF
|
| 12 |
+
- Qwen/Qwen2.5-7B-Instruct
|
| 13 |
+
- nvidia/Nemotron-3.5-Content-Safety
|
| 14 |
+
datasets: []
|
| 15 |
+
tags:
|
| 16 |
+
- build-small-hackathon
|
| 17 |
+
- backyard-ai
|
| 18 |
+
- gradio
|
| 19 |
+
- agents
|
| 20 |
+
- small-language-model
|
| 21 |
+
- demo-builder
|
| 22 |
+
- real-estate
|
| 23 |
+
- ai-agents
|
| 24 |
pinned: false
|
| 25 |
---
|
| 26 |
|
| 27 |
+
# Backyard Demo Builder
|
| 28 |
+
|
| 29 |
+
## Chapter 1: Backyard AI
|
| 30 |
+
|
| 31 |
+
*Build Small Hackathon 2026 — Chapter 1 Submission*
|
| 32 |
+
|
| 33 |
+
`agent-swarm-workbench` now presents as **Backyard Demo Builder**: a Gradio app
|
| 34 |
+
that turns one real person's workflow into a small runnable demo package before
|
| 35 |
+
anyone pays to build full software.
|
| 36 |
+
|
| 37 |
+
First backyard case: my mom, a real-estate agent. She needs a cheap way to test
|
| 38 |
+
a customer follow-up reminder workflow before committing time and money to a
|
| 39 |
+
full app.
|
| 40 |
+
|
| 41 |
+
---
|
| 42 |
+
|
| 43 |
+
## Watch the Demo Builder Work
|
| 44 |
+
|
| 45 |
+
```
|
| 46 |
+
You: "Build a real-estate follow-up CRM demo for my mom."
|
| 47 |
+
Builder: Generates a Gradio mini-app, handoff spec, field notes, and checks
|
| 48 |
+
Result: app.py, README.md, handoff_spec.md, field_notes.md
|
| 49 |
+
Mom: Tests the workflow, then we scrap or scale.
|
| 50 |
+
```
|
| 51 |
+
|
| 52 |
+
Every Run produces a **downloadable demo package** and Validation report: files
|
| 53 |
+
you can inspect, unzip, run, and test with the real person.
|
| 54 |
+
|
| 55 |
+
---
|
| 56 |
+
|
| 57 |
+
## Build Small Hackathon — Submission Notes
|
| 58 |
+
|
| 59 |
+
| Requirement | How We Meet It |
|
| 60 |
+
|---|---|
|
| 61 |
+
| **Small model (≤ 32B)** | Provider catalog fetches models at runtime and only allows models whose ID/name proves ≤32B |
|
| 62 |
+
| **Gradio app** | Custom dark-themed Gradio UI mounted on FastAPI |
|
| 63 |
+
| **HF Space** | `app.py` + `requirements.txt` — one-command deploy |
|
| 64 |
+
| **Demo video** | *(placeholder — [link to demo])* |
|
| 65 |
+
| **Social post** | *(placeholder — [link to post])* |
|
| 66 |
+
|
| 67 |
+
### Bonus Badges Claimed
|
| 68 |
+
|
| 69 |
+
| Badge | Why |
|
| 70 |
+
|---|---|
|
| 71 |
+
| **🎨 Off-Brand** | Fully custom CSS dark theme — Archivo + IBM Plex Mono, acid green CTAs, paper/ink palette, CSS grid layout, status chips. Not a default Gradio component in sight. |
|
| 72 |
+
| **📡 Sharing is Caring** | Agent traces and swarm reasoning are surfaced in the Events panel. We'll publish a trace on the Hub. |
|
| 73 |
+
| **📓 Field Notes** | Generated demo packages include `field_notes.md`; this repo also documents the architecture and decisions. |
|
| 74 |
+
|
| 75 |
+
---
|
| 76 |
+
|
| 77 |
+
## Why This Belongs in Backyard AI
|
| 78 |
+
|
| 79 |
+
This solves a real problem for someone I know.
|
| 80 |
+
|
| 81 |
+
- **Specific person** — my mom, a real-estate agent.
|
| 82 |
+
- **Specific pain** — follow-up reminders and customer-care demos are useful, but custom app dev is slow and risky.
|
| 83 |
+
- **Honest small-model fit** — a ≤32B model drafts the demo and handoff spec; rules handle the reminder logic.
|
| 84 |
+
- **Actually testable** — the generated package includes field notes and feedback questions for the real user.
|
| 85 |
+
|
| 86 |
+
---
|
| 87 |
+
|
| 88 |
+
## How It Works Under the Hood
|
| 89 |
+
|
| 90 |
+
```
|
| 91 |
+
┌─────────────────────────────────────────────────────┐
|
| 92 |
+
│ Gradio UI / HTTP API │
|
| 93 |
+
├─────────────────────────────────────────────────────┤
|
| 94 |
+
│ RunFlow — lifecycle conductor │
|
| 95 |
+
│ ┌──────────┐ ┌────────────┐ ┌────────────────┐ │
|
| 96 |
+
│ │ Swarm │ │ Codebase │ │ Validator │ │
|
| 97 |
+
│ │ Runtime │→│ Archive │→│ Graph │ │
|
| 98 |
+
│ │ │ │ Store │ │ │ │
|
| 99 |
+
│ │ Planner │ │ (local/ │ │ Sandbox checks │ │
|
| 100 |
+
│ │ Coder │ │ Redis) │ │ Rubric review │ │
|
| 101 |
+
│ │ Reviewer │ │ │ │ Stagehand │ │
|
| 102 |
+
│ │ Tester │ │ │ │ (Browserbase) │ │
|
| 103 |
+
│ └──────────┘ └────────────┘ └────────────────┘ │
|
| 104 |
+
│ EventBus → SSE stream to UI │
|
| 105 |
+
└─────────────────────────────────────────────────────┘
|
| 106 |
+
```
|
| 107 |
+
|
| 108 |
+
### The Swarm
|
| 109 |
+
|
| 110 |
+
- **Coordinator** reads the prompt, plans tasks, delegates to subagents
|
| 111 |
+
- **Planner** breaks down the prompt into implementable units
|
| 112 |
+
- **Coder** writes the actual code files
|
| 113 |
+
- **Reviewer** checks code quality and correctness
|
| 114 |
+
- **Test-runner** runs the user's tests and retries up to 3x on failure
|
| 115 |
+
- **Validator-prep** generates validation checks from user criteria
|
| 116 |
+
|
| 117 |
+
### The Validator
|
| 118 |
+
|
| 119 |
+
After the swarm finishes, a LangGraph Validator workflow:
|
| 120 |
+
1. Restores the codebase into a clean sandbox
|
| 121 |
+
2. Runs user-provided tests
|
| 122 |
+
3. Executes LLM-based rubric review
|
| 123 |
+
4. (Optional) Runs Browserbase/Stagehand visual checks
|
| 124 |
+
5. Produces a pass/fail Validation Report
|
| 125 |
+
|
| 126 |
+
### The Sandbox
|
| 127 |
+
|
| 128 |
+
All agent work happens inside isolated sandbox workspaces:
|
| 129 |
+
- **Local** (for dev/smoke tests)
|
| 130 |
+
- **Docker** (container-based)
|
| 131 |
+
- **Daytona** (cloud sandboxes)
|
| 132 |
+
|
| 133 |
+
---
|
| 134 |
+
|
| 135 |
+
## Run It
|
| 136 |
+
|
| 137 |
+
```bash
|
| 138 |
+
git clone https://github.com/Kiy-K/agent-swarm-workbench.git
|
| 139 |
+
cd agent-swarm-workbench
|
| 140 |
+
cp .env.example .env
|
| 141 |
+
# Optional: add server fallback keys. Users can also paste their own key in the UI.
|
| 142 |
+
uv run uvicorn app:app --host 0.0.0.0 --port 8790
|
| 143 |
+
```
|
| 144 |
+
|
| 145 |
+
Open http://localhost:8790, type a prompt, choose a provider, fetch models with your API key, then click Start Run.
|
| 146 |
+
|
| 147 |
+
Model selection:
|
| 148 |
+
- Model lists are fetched from the selected provider/API endpoint at runtime.
|
| 149 |
+
- UI only offers fetched models whose ID/name proves `<=32B` parameters.
|
| 150 |
+
- Unknown-size models are shown in the catalog response as `unknown_parameters` but are not selectable.
|
| 151 |
+
- User API keys and fetched catalogs live only in process memory. They are not persisted, not stored in Redis/DB, and not kept in Gradio state. Click "Refresh models" to clear and refetch that provider cache.
|
| 152 |
+
|
| 153 |
+
For Hugging Face Spaces:
|
| 154 |
+
```bash
|
| 155 |
+
uv run python app.py
|
| 156 |
+
```
|
| 157 |
+
|
| 158 |
+
## Test
|
| 159 |
+
|
| 160 |
+
```bash
|
| 161 |
+
python scripts/task.py verify # required completion gate: tests + harness
|
| 162 |
+
python scripts/task.py test # 90 tests, all passing
|
| 163 |
+
python scripts/task.py harness -- --prompt "Build a tiny CLI" --test "test -f README.md"
|
| 164 |
+
python scripts/task.py smoke # Local agent session smoke check
|
| 165 |
+
python scripts/task.py validator-smoke # Validator end-to-end
|
| 166 |
+
```
|
| 167 |
+
|
| 168 |
+
### Agent Harness
|
| 169 |
+
|
| 170 |
+
The harness is the fast way to exercise the Run lifecycle without waiting on a
|
| 171 |
+
full demo session:
|
| 172 |
+
|
| 173 |
+
```bash
|
| 174 |
+
python scripts/task.py verify
|
| 175 |
+
python scripts/task.py harness -- --prompt "Build a tiny CLI" --output-dir /tmp/harness
|
| 176 |
+
python scripts/task.py harness -- --mode live --prompt "Build a tiny CLI"
|
| 177 |
+
```
|
| 178 |
+
|
| 179 |
+
`verify` is the required completion gate for coding agents. It runs the Python
|
| 180 |
+
suite, then runs the default scripted Agent Swarm Harness so changes are checked
|
| 181 |
+
against the same Run -> SwarmRuntime -> Archive -> Validator path that the app
|
| 182 |
+
uses.
|
| 183 |
+
|
| 184 |
+
Modes:
|
| 185 |
+
|
| 186 |
+
| Mode | Purpose |
|
| 187 |
+
|---|---|
|
| 188 |
+
| `swarm` | Default. Runs `RunFlow -> SwarmRuntime -> Archive -> Validator` with a scripted local DeepAgent-compatible session. |
|
| 189 |
+
| `live` | Uses the real `create_session()` DeepAgents path and the configured sandbox provider. |
|
| 190 |
+
|
| 191 |
+
## Environment
|
| 192 |
+
|
| 193 |
+
| Var | Purpose |
|
| 194 |
+
|---|---|
|
| 195 |
+
| `DEEPAGENT_MODEL_PROVIDER` | Server fallback model provider: `openrouter`, `gemini`, `nebius`, `huggingface`, `custom`, or `local` |
|
| 196 |
+
| `DEEPAGENT_MODEL` | Server fallback model ID. Must prove `<=32B` when selected per Run. |
|
| 197 |
+
| `DEEPAGENT_MODEL_BASE_URL` | Optional OpenAI-compatible `/v1` endpoint |
|
| 198 |
+
| `OPENROUTER_API_KEY` / `GEMINI_API_KEY` / `NEBIUS_API_KEY` / `HF_TOKEN` | Optional server fallback keys for trusted server/CLI runs only. The public Gradio UI requires the user to enter their own hosted-provider key and does not use these by default. |
|
| 199 |
+
| `DEEPAGENT_SANDBOX_PROVIDER` | `local`, `docker`, or `daytona` |
|
| 200 |
+
| `BROWSERBASE_API_KEY` | Optional — visual validation via Stagehand |
|
| 201 |
+
| `UPSTASH_REDIS_REST_URL` / `TOKEN` | Optional — persistent runs & archives |
|
| 202 |
+
|
| 203 |
+
---
|
| 204 |
+
|
| 205 |
+
## Stack
|
| 206 |
+
|
| 207 |
+
- **Python 3.11+** / **FastAPI** / **Gradio 6**
|
| 208 |
+
- **LangChain DeepAgents** — multi-subagent swarm runtime
|
| 209 |
+
- **Provider adapters** — OpenRouter, Gemini, Nebius, Hugging Face Router, custom OpenAI-compatible, local OpenAI-compatible
|
| 210 |
+
- **LangGraph** — Validator workflow
|
| 211 |
+
- **QuickJS code interpreter** — in-sandbox code execution middleware
|
| 212 |
+
- **Browserbase + Stagehand** — visual web validation (optional)
|
| 213 |
+
|
| 214 |
+
## Architecture
|
| 215 |
+
|
| 216 |
+
```
|
| 217 |
+
arena/
|
| 218 |
+
agent.py — Swarm factory, model, subagents, sandbox backend
|
| 219 |
+
backyard_templates.py — Backyard demo template registry
|
| 220 |
+
model_provider.py — Chat model factory for provider selection
|
| 221 |
+
model_catalog.py — Provider model list adapters and TTL cache
|
| 222 |
+
swarm_runtime.py — Active Run registration and Swarm session leasing
|
| 223 |
+
swarm_session.py — Prompt seeding, agent turns, test retries, snapshots
|
| 224 |
+
sandbox_lease.py — Idle TTL, touch, and close behavior for sandboxes
|
| 225 |
+
run_flow.py — Run lifecycle: create → execute → archive → validate
|
| 226 |
+
run_journal.py — Run mutation journal: status, tasks, events, timestamps
|
| 227 |
+
run_store.py — Run persistence (InMemory / Redis via Upstash)
|
| 228 |
+
codebase_handoff.py — Workspace snapshot and Validator sandbox restore
|
| 229 |
+
codebase_archive.py — Archive persistence (local / Redis)
|
| 230 |
+
validator_plan.py — Typed Validator plan from user tests/checks
|
| 231 |
+
validator_graph.py — LangGraph Validator workflow
|
| 232 |
+
thread_inspector.py — Manual Thread/session debug surface
|
| 233 |
+
gradio_app.py — Thin Gradio component wiring
|
| 234 |
+
gradio_presenter.py — Run output formatting for Gradio
|
| 235 |
+
gradio_markup.py — Static Gradio shell markup
|
| 236 |
+
api.py — FastAPI REST + SSE endpoints
|
| 237 |
+
event_bus.py — In-process event streaming
|
| 238 |
+
browserbase_tools.py — Web fetch/search tools for the swarm
|
| 239 |
+
stagehand_validator.py — Browserbase visual validation
|
| 240 |
+
docker_backend.py — Docker sandbox provider
|
| 241 |
+
skill_catalog.py — Bundled DeepAgents skills discovery
|
| 242 |
+
tests_python/ — Python test suite (integration + unit)
|
| 243 |
+
```
|
| 244 |
+
|
| 245 |
+
---
|
| 246 |
+
|
| 247 |
+
*Built with a sub-32B model for the Build Small Hackathon, June 2026.*
|
ROADMAP.md
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ROADMAP
|
| 2 |
+
|
| 3 |
+
## Goal
|
| 4 |
+
|
| 5 |
+
Build Agent Swarm Workbench: a prompt-first agent swarm workbench for Hugging Face Spaces.
|
| 6 |
+
|
| 7 |
+
The user provides one prompt for a codebase. The system creates a persisted Run, uses one DeepAgent with multiple subagents to plan and build the codebase, archives the result as a downloadable zip, restores that archive into a clean Validator sandbox, runs generated validation checks, and returns a summary with changed files and a Validation report.
|
| 8 |
+
|
| 9 |
+
## Locked Product Decisions
|
| 10 |
+
|
| 11 |
+
- The deliverable is a **Codebase**.
|
| 12 |
+
- The user can download the Codebase as a `.zip`.
|
| 13 |
+
- The MVP Swarm is **one DeepAgent with multiple subagents**.
|
| 14 |
+
- The Coordinator generates validation checks from the prompt and user Criteria.
|
| 15 |
+
- User-defined tests or Criteria are first-class inputs.
|
| 16 |
+
- Validation is mandatory before a Run can be considered complete.
|
| 17 |
+
- Validation runs in a separate **Validator sandbox**, never in the live worker Workspace.
|
| 18 |
+
- The final output is Summary + changed files + Validation report + Codebase zip.
|
| 19 |
+
- Keep the Python package name `arena` for now; the repo and public product name are `agent-swarm-workbench` / Agent Swarm Workbench.
|
| 20 |
+
|
| 21 |
+
## Architecture Ground Rules
|
| 22 |
+
|
| 23 |
+
- Keep FastAPI and Gradio thin over `ArenaService`.
|
| 24 |
+
- Keep DeepAgent construction behind `create_session()`.
|
| 25 |
+
- Keep the Swarm runtime behind a small module interface.
|
| 26 |
+
- Keep persistence behind small store interfaces.
|
| 27 |
+
- Keep archive persistence behind the Codebase archive seam.
|
| 28 |
+
- Keep validation orchestration in LangGraph.
|
| 29 |
+
- Keep the worker Workspace and Validator sandbox separate.
|
| 30 |
+
- Preserve a runnable app after every phase.
|
| 31 |
+
- New product work must use Run, Swarm, Task, Codebase, Criteria, Validator, and Validation report language.
|
| 32 |
+
- Legacy Match, Host, Participant, Challenge, Leaderboard, Judge, and Score language is quarantined outside the active app path.
|
| 33 |
+
|
| 34 |
+
## Current Assets To Reuse
|
| 35 |
+
|
| 36 |
+
- Python/FastAPI/Gradio app shell.
|
| 37 |
+
- `ArenaService` application seam.
|
| 38 |
+
- DeepAgents session factory and subagent support.
|
| 39 |
+
- Local and Daytona sandbox adapters.
|
| 40 |
+
- Upstash Redis REST client and store patterns.
|
| 41 |
+
- Local and Redis-backed archive storage.
|
| 42 |
+
- Sandboxed command executor used by the Validator.
|
| 43 |
+
- Repo skill catalog mounted at `/skills/`.
|
| 44 |
+
|
| 45 |
+
## Current Refactor Status
|
| 46 |
+
|
| 47 |
+
All nine phases are complete. The project is now Agent Swarm Workbench.
|
| 48 |
+
|
| 49 |
+
- `arena/run_flow.py` owns the prompt-first Run lifecycle.
|
| 50 |
+
- `arena/codebase_archive.py` is the only Codebase archive seam.
|
| 51 |
+
- `arena/validator_executor.py` exposes a Validator-named sandbox adapter and factory.
|
| 52 |
+
- `arena/gradio_app.py` is prompt-first only: prompt, Criteria, user tests, Run status, task table, event table, Validation report, and Codebase zip link.
|
| 53 |
+
- `arena/agent.py` keeps DeepAgents as the Swarm SDK with subagents and tool interrupts.
|
| 54 |
+
- `arena/api.py` and `arena/service.py` expose Run, skills, archive download, event streaming, and Validator operations.
|
| 55 |
+
- `arena/run_models.py` and `arena/validation_models.py` provide prompt-first import surfaces; `arena/models.py` contains only active Run/Validator models.
|
| 56 |
+
- `arena/redis_client.py` is the only source of `RedisClient`, `UpstashRedisRestClient`, and `RedisStoreError`.
|
| 57 |
+
- Legacy `match_store.py`, `match_flow.py`, `vibecoder.py`, and `judge_graph.py` are deleted.
|
| 58 |
+
- Package name `arena` kept.
|
| 59 |
+
|
| 60 |
+
## MVP Success Criteria
|
| 61 |
+
|
| 62 |
+
1. User starts a Run from one prompt and optional Criteria.
|
| 63 |
+
2. Run receives a hidden rejoin token and survives refresh.
|
| 64 |
+
3. One DeepAgent Swarm creates a task plan and works through subagents.
|
| 65 |
+
4. Event stream shows plan, task progress, artifact creation, validation, and final summary.
|
| 66 |
+
5. The Swarm produces a Codebase archive.
|
| 67 |
+
6. The Codebase archive is downloadable as a zip.
|
| 68 |
+
7. Validator graph restores the Codebase archive into a clean Validator sandbox.
|
| 69 |
+
8. Validator runs generated checks and user-defined tests/Criteria-derived checks.
|
| 70 |
+
9. Final response includes summary, changed files, Validation report, and risks.
|
| 71 |
+
10. Redis-backed Run records and Codebase archives survive process restart.
|
| 72 |
+
|
| 73 |
+
## Phase 1: Run Domain Models
|
| 74 |
+
|
| 75 |
+
Purpose: introduce the new domain language in code without broad renames.
|
| 76 |
+
|
| 77 |
+
Add models:
|
| 78 |
+
|
| 79 |
+
- `Run`
|
| 80 |
+
- `RunRequest`
|
| 81 |
+
- `RunView`
|
| 82 |
+
- `RunStatus`
|
| 83 |
+
- `RunTask`
|
| 84 |
+
- `TaskStatus`
|
| 85 |
+
- `RunArtifact`
|
| 86 |
+
- `RunEvent`
|
| 87 |
+
- `Criteria`
|
| 88 |
+
- `ValidationCheck`
|
| 89 |
+
- `ValidationCheckResult`
|
| 90 |
+
- `ValidationReport`
|
| 91 |
+
- `CodebaseArchive`
|
| 92 |
+
|
| 93 |
+
Done when:
|
| 94 |
+
|
| 95 |
+
- Tests validate Run creation, task state, event ordering, artifact pointers, Criteria, and Validation report shape.
|
| 96 |
+
- Legacy match models still pass while migration continues.
|
| 97 |
+
|
| 98 |
+
## Phase 2: Run Store
|
| 99 |
+
|
| 100 |
+
Purpose: persist prompt-first Runs behind a real seam.
|
| 101 |
+
|
| 102 |
+
Adapters:
|
| 103 |
+
|
| 104 |
+
- `InMemoryRunStore`
|
| 105 |
+
- `RedisRunStore`
|
| 106 |
+
|
| 107 |
+
Behavior:
|
| 108 |
+
|
| 109 |
+
- Create Run from prompt and Criteria.
|
| 110 |
+
- Issue Rejoin token.
|
| 111 |
+
- Load public Run view without exposing secrets.
|
| 112 |
+
- Rejoin by token.
|
| 113 |
+
- Append Run events.
|
| 114 |
+
- Save task state.
|
| 115 |
+
- Save Codebase archive pointer.
|
| 116 |
+
- Save Validation checks and Validation report.
|
| 117 |
+
- Restore after process restart.
|
| 118 |
+
- Mark active tasks interrupted if their runtime is gone.
|
| 119 |
+
|
| 120 |
+
Done when:
|
| 121 |
+
|
| 122 |
+
- Fake Redis tests cover create, rejoin, event append, task update, artifact update, validation update, and restart recovery.
|
| 123 |
+
|
| 124 |
+
## Phase 3: DeepAgent Swarm Runtime
|
| 125 |
+
|
| 126 |
+
Purpose: express the MVP Swarm as one DeepAgent with multiple subagents.
|
| 127 |
+
|
| 128 |
+
Subagents:
|
| 129 |
+
|
| 130 |
+
- planner
|
| 131 |
+
- coder
|
| 132 |
+
- reviewer
|
| 133 |
+
- test-runner
|
| 134 |
+
- validator-prep
|
| 135 |
+
|
| 136 |
+
Behavior:
|
| 137 |
+
|
| 138 |
+
- Seed User prompt and Criteria as read-only context.
|
| 139 |
+
- Work inside `/workspace`.
|
| 140 |
+
- Stream normalized events.
|
| 141 |
+
- Record changed files.
|
| 142 |
+
- Produce a Codebase archive through the archive seam.
|
| 143 |
+
- Return a summary of work and known risks.
|
| 144 |
+
|
| 145 |
+
Done when:
|
| 146 |
+
|
| 147 |
+
- One local fake Run can execute through the Swarm runtime and produce a Codebase archive.
|
| 148 |
+
- Legacy runner concepts are replaced by `SwarmRuntime`.
|
| 149 |
+
|
| 150 |
+
## Phase 4: Codebase Archive And Zip Download
|
| 151 |
+
|
| 152 |
+
Purpose: make the Codebase a durable, downloadable artifact.
|
| 153 |
+
|
| 154 |
+
Behavior:
|
| 155 |
+
|
| 156 |
+
- Archive all files from `/workspace`.
|
| 157 |
+
- Persist archive locally or in Redis depending on provider.
|
| 158 |
+
- Generate a `.zip` from the archived Codebase.
|
| 159 |
+
- Expose zip download through Gradio and FastAPI.
|
| 160 |
+
- Keep file size caps and path validation.
|
| 161 |
+
|
| 162 |
+
Done when:
|
| 163 |
+
|
| 164 |
+
- A Run produces a zip after completion.
|
| 165 |
+
- Zip generation works after process restart from Redis archive.
|
| 166 |
+
|
| 167 |
+
## Phase 5: Validator Graph
|
| 168 |
+
|
| 169 |
+
Purpose: retarget judge sandbox machinery into mandatory validation under Validator-named seams.
|
| 170 |
+
|
| 171 |
+
Files:
|
| 172 |
+
|
| 173 |
+
- `arena/validator_graph.py`
|
| 174 |
+
- `arena/validator_executor.py`
|
| 175 |
+
- `arena/codebase_handoff.py`
|
| 176 |
+
- `arena/codebase_executor.py`
|
| 177 |
+
|
| 178 |
+
Behavior:
|
| 179 |
+
|
| 180 |
+
1. Restore Codebase archive into a clean Validator sandbox.
|
| 181 |
+
2. Run all generated Validation checks.
|
| 182 |
+
3. Run user-defined tests when provided.
|
| 183 |
+
4. Optionally run LLM review over prompt, Criteria, changed files, and command outputs.
|
| 184 |
+
5. Produce a Validation report.
|
| 185 |
+
6. Attach the Validation report to the Run.
|
| 186 |
+
|
| 187 |
+
Status: done for the active app path.
|
| 188 |
+
|
| 189 |
+
Note: the old Judge executor alias has been removed. Validator restore now goes through the Codebase handoff module.
|
| 190 |
+
|
| 191 |
+
Done when:
|
| 192 |
+
|
| 193 |
+
- Primary service path is `validate_run()`.
|
| 194 |
+
- Judge language is not exposed by FastAPI or Gradio.
|
| 195 |
+
|
| 196 |
+
## Phase 6: Swarm Flow
|
| 197 |
+
|
| 198 |
+
Purpose: replace Match lifecycle with prompt-to-codebase Run orchestration.
|
| 199 |
+
|
| 200 |
+
Behavior:
|
| 201 |
+
|
| 202 |
+
- Start Run.
|
| 203 |
+
- Ask Coordinator to plan tasks.
|
| 204 |
+
- Execute one DeepAgent Swarm runtime.
|
| 205 |
+
- Persist events and task updates.
|
| 206 |
+
- Archive Codebase.
|
| 207 |
+
- Run Validator.
|
| 208 |
+
- Mark Run completed only after mandatory validation.
|
| 209 |
+
|
| 210 |
+
Done when:
|
| 211 |
+
|
| 212 |
+
- A Run can complete end-to-end in local fake mode.
|
| 213 |
+
- Restart keeps archived Codebase and Validation report.
|
| 214 |
+
- Active work lost after restart becomes interrupted.
|
| 215 |
+
|
| 216 |
+
## Phase 7: Prompt-First Gradio UI
|
| 217 |
+
|
| 218 |
+
Purpose: remove competition controls from the user-facing app.
|
| 219 |
+
|
| 220 |
+
Status: done for the Hackathon demo shell. Legacy Match/Judge controls are no longer exposed in Gradio.
|
| 221 |
+
|
| 222 |
+
UI:
|
| 223 |
+
|
| 224 |
+
- Prompt input.
|
| 225 |
+
- Optional Criteria/tests input.
|
| 226 |
+
- Start Run.
|
| 227 |
+
- Rejoin token.
|
| 228 |
+
- Event stream.
|
| 229 |
+
- Task table.
|
| 230 |
+
- Changed files.
|
| 231 |
+
- Validation report.
|
| 232 |
+
- Zip download.
|
| 233 |
+
- Final summary.
|
| 234 |
+
|
| 235 |
+
Remove from primary UI:
|
| 236 |
+
|
| 237 |
+
- Host tab.
|
| 238 |
+
- Join tab.
|
| 239 |
+
- Match code.
|
| 240 |
+
- Participant controls.
|
| 241 |
+
- Leaderboard.
|
| 242 |
+
|
| 243 |
+
Done when:
|
| 244 |
+
|
| 245 |
+
- User can run the full workflow without touching internal IDs.
|
| 246 |
+
|
| 247 |
+
## Phase 8: Run API
|
| 248 |
+
|
| 249 |
+
Purpose: expose prompt-first endpoints.
|
| 250 |
+
|
| 251 |
+
Primary endpoints (current):
|
| 252 |
+
|
| 253 |
+
- `POST /runs`
|
| 254 |
+
- `GET /runs/{run_id}`
|
| 255 |
+
- `POST /runs/rejoin`
|
| 256 |
+
- `POST /runs/{run_id}/validate`
|
| 257 |
+
- `GET /runs/{run_id}/codebase.zip`
|
| 258 |
+
|
| 259 |
+
Streaming endpoint:
|
| 260 |
+
|
| 261 |
+
- `GET /runs/{run_id}/events` for streamed Run events.
|
| 262 |
+
|
| 263 |
+
Done when:
|
| 264 |
+
|
| 265 |
+
- FastAPI tests cover prompt-first Run lifecycle.
|
| 266 |
+
- Legacy match endpoints are not exposed by the active FastAPI app.
|
| 267 |
+
|
| 268 |
+
## Phase 9: Product Rename Cleanup
|
| 269 |
+
|
| 270 |
+
Purpose: delete or quarantine old arena language after behavior is migrated.
|
| 271 |
+
|
| 272 |
+
Status: done for the public product and repository name.
|
| 273 |
+
|
| 274 |
+
Done when:
|
| 275 |
+
|
| 276 |
+
- UI title, README, ROADMAP, CONTEXT, AGENTS, and tests use swarm/run language.
|
| 277 |
+
- Legacy match modules are deleted.
|
| 278 |
+
- No active-path module under `arena/` imports from `arena/match_store.py`, `arena/match_flow.py`, `arena/vibecoder.py`, or `arena/judge_graph.py`.
|
| 279 |
+
- `python scripts/task.py test` passes after the cleanup.
|
| 280 |
+
- Python package name `arena` kept.
|
app.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Unified ASGI entrypoint for API and Gradio UI."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import os
|
| 6 |
+
|
| 7 |
+
import gradio as gr
|
| 8 |
+
import uvicorn
|
| 9 |
+
|
| 10 |
+
from arena.api import app as fastapi_app
|
| 11 |
+
from arena.api import service
|
| 12 |
+
from arena.gradio_app import build_app
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
demo = build_app(service)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def create_app():
|
| 19 |
+
"""Create one FastAPI ASGI app with Gradio mounted at the root."""
|
| 20 |
+
|
| 21 |
+
return gr.mount_gradio_app(fastapi_app, demo, path="/")
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
app = create_app()
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
try:
|
| 28 |
+
import spaces
|
| 29 |
+
except Exception:
|
| 30 |
+
class _SpacesShim:
|
| 31 |
+
def GPU(self, fn=None, **kwargs):
|
| 32 |
+
del kwargs
|
| 33 |
+
|
| 34 |
+
def decorator(inner):
|
| 35 |
+
return inner
|
| 36 |
+
|
| 37 |
+
return decorator(fn) if fn else decorator
|
| 38 |
+
|
| 39 |
+
spaces = _SpacesShim()
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
@spaces.GPU
|
| 43 |
+
def zerogpu_ready_marker() -> str:
|
| 44 |
+
return "ready"
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def server_config() -> dict[str, int | str]:
|
| 48 |
+
host = os.getenv("GRADIO_SERVER_NAME", os.getenv("HOST", "0.0.0.0"))
|
| 49 |
+
port = int(os.getenv("GRADIO_SERVER_PORT") or os.getenv("PORT") or "7860")
|
| 50 |
+
return {"host": host, "port": port}
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def gradio_launch_config() -> dict[str, int | str]:
|
| 54 |
+
config = server_config()
|
| 55 |
+
return {"server_name": str(config["host"]), "server_port": int(config["port"])}
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def should_launch_gradio_space() -> bool:
|
| 59 |
+
return bool(os.getenv("SPACE_ID")) and os.getenv("FORCE_SELF_LAUNCH") != "1"
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def should_self_launch() -> bool:
|
| 63 |
+
if os.getenv("FORCE_SELF_LAUNCH") == "1":
|
| 64 |
+
return True
|
| 65 |
+
return not should_launch_gradio_space()
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def _space_sdk() -> str:
|
| 69 |
+
return os.getenv("SPACE_SDK", os.getenv("HF_SPACE_SDK", "")).strip().lower()
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
if __name__ == "__main__":
|
| 73 |
+
if should_launch_gradio_space():
|
| 74 |
+
demo.launch(**gradio_launch_config())
|
| 75 |
+
elif should_self_launch():
|
| 76 |
+
uvicorn.run(app, **server_config())
|
arena-completed.png
ADDED
|
arena-hackathon.png
ADDED
|
arena-start.png
ADDED
|
arena-ui.png
ADDED
|
arena/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Backyard Demo Builder package."""
|
arena/agent.py
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""DeepAgent factory plus sandbox lifecycle."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import os
|
| 6 |
+
import shutil
|
| 7 |
+
import tempfile
|
| 8 |
+
from dataclasses import dataclass, field
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
from typing import Any
|
| 11 |
+
from uuid import uuid4
|
| 12 |
+
|
| 13 |
+
from deepagents import create_deep_agent
|
| 14 |
+
from deepagents.backends import CompositeBackend, FilesystemBackend, LocalShellBackend
|
| 15 |
+
from deepagents.middleware.filesystem import FilesystemPermission
|
| 16 |
+
from deepagents.middleware.summarization import create_summarization_tool_middleware
|
| 17 |
+
from langchain_quickjs import CodeInterpreterMiddleware
|
| 18 |
+
from langgraph.checkpoint.memory import MemorySaver
|
| 19 |
+
|
| 20 |
+
from .model_catalog import ModelProviderConfig
|
| 21 |
+
from .model_provider import create_chat_model, normalize_openrouter_model
|
| 22 |
+
from .paths import SKILLS_ROOT
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
DEFAULT_PROVIDER = "daytona"
|
| 26 |
+
DOTENV_PATH = Path(".env")
|
| 27 |
+
APPROVAL_TOOL_NAMES = ("execute", "write_file", "edit_file")
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
SYSTEM_PROMPT = """You are an agent swarm workbench Coordinator.
|
| 31 |
+
|
| 32 |
+
Goal:
|
| 33 |
+
- Build complete codebases from a user's prompt inside the sandbox filesystem.
|
| 34 |
+
- Start every non-trivial run by using the planning/todo tool.
|
| 35 |
+
- Keep Run context and Criteria in files, then read only what is needed.
|
| 36 |
+
- Use sandbox tools to write files, run checks, inspect failures, and improve output.
|
| 37 |
+
- Use the interpreter for structured transforms, batching, or tool composition inside the agent loop.
|
| 38 |
+
- Delegate isolated tasks to subagents when it keeps the main context focused.
|
| 39 |
+
- Return concise progress plus final file paths changed.
|
| 40 |
+
- Use browserbase_fetch to retrieve documentation, API refs, or reference content.
|
| 41 |
+
- Use browserbase_search to find examples, docs, or verify approaches.
|
| 42 |
+
|
| 43 |
+
Rules:
|
| 44 |
+
- Work in sandbox only.
|
| 45 |
+
- Treat /context as read-only source material and /workspace as the codebase workspace.
|
| 46 |
+
- Prefer runnable, testable artifacts.
|
| 47 |
+
- Run commands through sandbox execute tool.
|
| 48 |
+
- Never claim code works unless you ran relevant command or explain why not.
|
| 49 |
+
"""
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
SUBAGENTS = [
|
| 53 |
+
{
|
| 54 |
+
"name": "planner",
|
| 55 |
+
"description": "Turns the User prompt and Criteria into ordered implementation tasks.",
|
| 56 |
+
"system_prompt": "Create concise, dependency-aware task plans for the codebase.",
|
| 57 |
+
},
|
| 58 |
+
{
|
| 59 |
+
"name": "coder",
|
| 60 |
+
"description": "Implements codebase files in /workspace.",
|
| 61 |
+
"system_prompt": "Create clean, runnable code. Keep files focused and cohesive.",
|
| 62 |
+
},
|
| 63 |
+
{
|
| 64 |
+
"name": "reviewer",
|
| 65 |
+
"description": "Reviews codebase changes against the prompt and Criteria.",
|
| 66 |
+
"system_prompt": "Find correctness, maintainability, and completeness gaps.",
|
| 67 |
+
},
|
| 68 |
+
{
|
| 69 |
+
"name": "test-runner",
|
| 70 |
+
"description": "Runs workspace commands and diagnoses failures.",
|
| 71 |
+
"system_prompt": "Run checks, summarize failing command, propose minimal fix.",
|
| 72 |
+
},
|
| 73 |
+
{
|
| 74 |
+
"name": "validator-prep",
|
| 75 |
+
"description": "Prepares validation checks from User prompt and Criteria.",
|
| 76 |
+
"system_prompt": "Convert expected behavior into concrete validation commands and risks.",
|
| 77 |
+
},
|
| 78 |
+
]
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def _load_dotenv(path: Path = DOTENV_PATH) -> None:
|
| 82 |
+
if not path.exists():
|
| 83 |
+
return
|
| 84 |
+
for raw_line in path.read_text().splitlines():
|
| 85 |
+
line = raw_line.strip()
|
| 86 |
+
if not line or line.startswith("#") or "=" not in line:
|
| 87 |
+
continue
|
| 88 |
+
key, value = line.split("=", 1)
|
| 89 |
+
key = key.strip()
|
| 90 |
+
value = value.strip().strip("\"'")
|
| 91 |
+
if key and key not in os.environ:
|
| 92 |
+
os.environ[key] = value
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
@dataclass
|
| 96 |
+
class AgentSession:
|
| 97 |
+
thread_id: str
|
| 98 |
+
provider: str
|
| 99 |
+
backend: Any
|
| 100 |
+
agent: Any
|
| 101 |
+
workspace_dir: str = "/workspace"
|
| 102 |
+
sandbox: Any | None = None
|
| 103 |
+
context_root: Path | None = None
|
| 104 |
+
checkpointer: Any | None = None
|
| 105 |
+
messages: list[dict[str, str]] = field(default_factory=list)
|
| 106 |
+
|
| 107 |
+
def close(self) -> None:
|
| 108 |
+
if self.provider == "daytona" and self.sandbox is not None:
|
| 109 |
+
cleanup = getattr(self.sandbox, "delete", None) or getattr(self.sandbox, "stop", None)
|
| 110 |
+
if callable(cleanup):
|
| 111 |
+
cleanup()
|
| 112 |
+
if self.context_root is not None:
|
| 113 |
+
shutil.rmtree(self.context_root, ignore_errors=True)
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def _normalize_openrouter_model(model: str) -> str:
|
| 117 |
+
return normalize_openrouter_model(model)
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def _openrouter_model():
|
| 121 |
+
return create_chat_model()
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def _middleware(model: Any, backend: Any) -> list[Any]:
|
| 125 |
+
return [
|
| 126 |
+
CodeInterpreterMiddleware(skills_backend=backend),
|
| 127 |
+
create_summarization_tool_middleware(model, backend),
|
| 128 |
+
]
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def _permissions() -> list[FilesystemPermission]:
|
| 132 |
+
return [
|
| 133 |
+
FilesystemPermission(operations=["write"], paths=["/context/**"], mode="deny"),
|
| 134 |
+
FilesystemPermission(operations=["write"], paths=["/skills/**"], mode="deny"),
|
| 135 |
+
]
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def _context_backend(thread_id: str) -> tuple[FilesystemBackend, Path]:
|
| 139 |
+
root = Path(tempfile.gettempdir()) / "agent-swarm-workbench-context" / thread_id
|
| 140 |
+
root.mkdir(parents=True, exist_ok=True)
|
| 141 |
+
return FilesystemBackend(root_dir=root, virtual_mode=True), root
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def _skills_backend() -> FilesystemBackend:
|
| 145 |
+
SKILLS_ROOT.mkdir(parents=True, exist_ok=True)
|
| 146 |
+
return FilesystemBackend(root_dir=SKILLS_ROOT, virtual_mode=True)
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def _composite_backend(executable_backend: Any, thread_id: str) -> tuple[CompositeBackend, Path]:
|
| 150 |
+
context_backend, context_root = _context_backend(thread_id)
|
| 151 |
+
return (
|
| 152 |
+
CompositeBackend(
|
| 153 |
+
default=executable_backend,
|
| 154 |
+
routes={
|
| 155 |
+
"/context/": context_backend,
|
| 156 |
+
"/skills/": _skills_backend(),
|
| 157 |
+
},
|
| 158 |
+
),
|
| 159 |
+
context_root,
|
| 160 |
+
)
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def _daytona_backend() -> tuple[Any, Any]:
|
| 164 |
+
from daytona import Daytona
|
| 165 |
+
from langchain_daytona import DaytonaSandbox
|
| 166 |
+
|
| 167 |
+
client = Daytona()
|
| 168 |
+
sandbox = client.create()
|
| 169 |
+
backend = DaytonaSandbox(sandbox=sandbox)
|
| 170 |
+
backend._daytona_client = client
|
| 171 |
+
backend._workspace_dir = "/home/daytona/workspace"
|
| 172 |
+
return backend, sandbox
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def _docker_backend() -> tuple[Any, Any]:
|
| 176 |
+
from .docker_backend import DockerSandboxBackend
|
| 177 |
+
|
| 178 |
+
image = os.getenv("DOCKER_SANDBOX_IMAGE", "python:3.11-slim")
|
| 179 |
+
backend = DockerSandboxBackend(image=image)
|
| 180 |
+
backend._workspace_dir = "/workspace"
|
| 181 |
+
return backend, backend
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def _local_backend(thread_id: str) -> tuple[Any, None]:
|
| 185 |
+
root = Path(tempfile.gettempdir()) / "agent-swarm-workbench-sandboxes" / thread_id
|
| 186 |
+
root.mkdir(parents=True, exist_ok=True)
|
| 187 |
+
backend = LocalShellBackend(root_dir=root, virtual_mode=True, inherit_env=True)
|
| 188 |
+
backend._workspace_dir = "/workspace"
|
| 189 |
+
return backend, None
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def _truthy_env(name: str) -> bool:
|
| 193 |
+
return os.getenv(name, "").lower() in {"1", "true", "yes", "on"}
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
def _approval_tools() -> list[str]:
|
| 197 |
+
configured = os.getenv("DEEPAGENT_INTERRUPT_TOOLS", "").strip()
|
| 198 |
+
if configured:
|
| 199 |
+
requested = [item.strip() for item in configured.replace(";", ",").split(",") if item.strip()]
|
| 200 |
+
unknown = sorted(set(requested) - set(APPROVAL_TOOL_NAMES))
|
| 201 |
+
if unknown:
|
| 202 |
+
raise ValueError(f"Unsupported DeepAgent interrupt tools: {', '.join(unknown)}")
|
| 203 |
+
return requested
|
| 204 |
+
if _truthy_env("DEEPAGENT_REQUIRE_TOOL_APPROVAL"):
|
| 205 |
+
return list(APPROVAL_TOOL_NAMES)
|
| 206 |
+
if _truthy_env("DEEPAGENT_REQUIRE_EXECUTE_APPROVAL"):
|
| 207 |
+
return ["execute"]
|
| 208 |
+
return []
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
def _interrupt_on() -> dict[str, Any]:
|
| 212 |
+
return {
|
| 213 |
+
tool_name: {"allowed_decisions": ["approve", "reject"]}
|
| 214 |
+
for tool_name in _approval_tools()
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
def _swarm_tools() -> list:
|
| 219 |
+
if not os.environ.get("BROWSERBASE_API_KEY"):
|
| 220 |
+
return []
|
| 221 |
+
try:
|
| 222 |
+
from langchain_core.tools import tool as lc_tool
|
| 223 |
+
from .browserbase_tools import browserbase_fetch, browserbase_search
|
| 224 |
+
except ImportError:
|
| 225 |
+
return []
|
| 226 |
+
|
| 227 |
+
@lc_tool
|
| 228 |
+
def fetch_tool(url: str, allow_redirects: bool = False) -> str:
|
| 229 |
+
"""Fetch and return the content of a web page. Use for documentation, APIs, or static pages."""
|
| 230 |
+
return browserbase_fetch(url=url, allow_redirects=allow_redirects)
|
| 231 |
+
|
| 232 |
+
@lc_tool
|
| 233 |
+
def search_tool(query: str, num_results: int = 5) -> str:
|
| 234 |
+
"""Search the web and return structured results with title, URL, and description."""
|
| 235 |
+
return browserbase_search(query=query, num_results=num_results)
|
| 236 |
+
|
| 237 |
+
return [fetch_tool, search_tool]
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
def create_session(thread_id: str | None = None, model_provider: ModelProviderConfig | None = None) -> AgentSession:
|
| 241 |
+
_load_dotenv()
|
| 242 |
+
thread_id = thread_id or uuid4().hex
|
| 243 |
+
provider = os.getenv("DEEPAGENT_SANDBOX_PROVIDER", DEFAULT_PROVIDER).lower()
|
| 244 |
+
|
| 245 |
+
if provider == "daytona":
|
| 246 |
+
executable_backend, sandbox = _daytona_backend()
|
| 247 |
+
elif provider == "docker":
|
| 248 |
+
executable_backend, sandbox = _docker_backend()
|
| 249 |
+
elif provider == "local":
|
| 250 |
+
executable_backend, sandbox = _local_backend(thread_id)
|
| 251 |
+
else:
|
| 252 |
+
raise ValueError(f"Unsupported sandbox provider: {provider}")
|
| 253 |
+
|
| 254 |
+
backend, context_root = _composite_backend(executable_backend, thread_id)
|
| 255 |
+
workspace_dir = getattr(executable_backend, "_workspace_dir", "/workspace")
|
| 256 |
+
backend._workspace_dir = workspace_dir
|
| 257 |
+
model = create_chat_model(model_provider)
|
| 258 |
+
checkpointer = MemorySaver()
|
| 259 |
+
agent = create_deep_agent(
|
| 260 |
+
model=model,
|
| 261 |
+
system_prompt=SYSTEM_PROMPT,
|
| 262 |
+
backend=backend,
|
| 263 |
+
middleware=_middleware(model, backend),
|
| 264 |
+
subagents=SUBAGENTS,
|
| 265 |
+
skills=["/skills/"],
|
| 266 |
+
tools=_swarm_tools(),
|
| 267 |
+
permissions=_permissions(),
|
| 268 |
+
interrupt_on=_interrupt_on(),
|
| 269 |
+
checkpointer=checkpointer,
|
| 270 |
+
name="agent-swarm-workbench",
|
| 271 |
+
)
|
| 272 |
+
return AgentSession(
|
| 273 |
+
thread_id=thread_id,
|
| 274 |
+
provider=provider,
|
| 275 |
+
backend=backend,
|
| 276 |
+
sandbox=sandbox,
|
| 277 |
+
agent=agent,
|
| 278 |
+
workspace_dir=workspace_dir,
|
| 279 |
+
context_root=context_root,
|
| 280 |
+
checkpointer=checkpointer,
|
| 281 |
+
)
|
arena/api.py
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FastAPI transport for the unified Arena service."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
import os
|
| 7 |
+
from typing import AsyncIterator
|
| 8 |
+
|
| 9 |
+
from fastapi import FastAPI, HTTPException, Query, Request, Response
|
| 10 |
+
from pydantic import BaseModel, Field
|
| 11 |
+
from starlette.middleware.base import BaseHTTPMiddleware
|
| 12 |
+
from starlette.responses import JSONResponse, StreamingResponse
|
| 13 |
+
|
| 14 |
+
from .model_catalog import ModelProviderConfig, ProviderModel
|
| 15 |
+
from .run_models import RunRequest, RunView
|
| 16 |
+
from .run_store import InvalidRunToken, RunNotFound
|
| 17 |
+
from .validation_models import ValidationReport
|
| 18 |
+
from .service import (
|
| 19 |
+
ArenaService,
|
| 20 |
+
FileAccessFailed,
|
| 21 |
+
)
|
| 22 |
+
from .skill_catalog import SkillInfo
|
| 23 |
+
from .thread_inspector import (
|
| 24 |
+
AgentRunFailed,
|
| 25 |
+
FileEntry,
|
| 26 |
+
MessageResponse,
|
| 27 |
+
ThreadInspector,
|
| 28 |
+
ThreadNotFound,
|
| 29 |
+
ThreadResponse,
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
app = FastAPI(title="Backyard Demo Builder", version="0.1.0")
|
| 34 |
+
service = ArenaService()
|
| 35 |
+
thread_inspector = ThreadInspector()
|
| 36 |
+
sessions = thread_inspector.sessions
|
| 37 |
+
|
| 38 |
+
API_KEY = os.getenv("ARENA_API_KEY", "")
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
class AuthMiddleware(BaseHTTPMiddleware):
|
| 42 |
+
async def dispatch(self, request: Request, call_next):
|
| 43 |
+
if request.url.path in ("/health",) or not API_KEY:
|
| 44 |
+
return await call_next(request)
|
| 45 |
+
auth = request.headers.get("Authorization", "")
|
| 46 |
+
expected = f"Bearer {API_KEY}"
|
| 47 |
+
if auth != expected:
|
| 48 |
+
return JSONResponse(
|
| 49 |
+
status_code=401,
|
| 50 |
+
content={"detail": "Unauthorized"},
|
| 51 |
+
)
|
| 52 |
+
return await call_next(request)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
if API_KEY:
|
| 56 |
+
app.add_middleware(AuthMiddleware)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
class MessageRequest(BaseModel):
|
| 60 |
+
content: str = Field(min_length=1)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
class RejoinRequest(BaseModel):
|
| 64 |
+
token: str = Field(min_length=1)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
class RunMessageRequest(BaseModel):
|
| 68 |
+
content: str = Field(min_length=1)
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
@app.get("/health")
|
| 72 |
+
async def health() -> dict[str, str]:
|
| 73 |
+
return service.health()
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
@app.get("/skills")
|
| 77 |
+
async def list_skills() -> list[SkillInfo]:
|
| 78 |
+
return service.list_skills()
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
@app.post("/models/catalog")
|
| 82 |
+
async def list_provider_models(req: ModelProviderConfig) -> list[ProviderModel]:
|
| 83 |
+
try:
|
| 84 |
+
return service.list_provider_models(req)
|
| 85 |
+
except ValueError as exc:
|
| 86 |
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
| 87 |
+
except Exception as exc:
|
| 88 |
+
raise HTTPException(status_code=502, detail="Could not fetch provider models") from exc
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
@app.post("/models/catalog/refresh")
|
| 92 |
+
async def refresh_provider_models(req: ModelProviderConfig) -> list[ProviderModel]:
|
| 93 |
+
try:
|
| 94 |
+
return service.refresh_provider_models(req)
|
| 95 |
+
except ValueError as exc:
|
| 96 |
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
| 97 |
+
except Exception as exc:
|
| 98 |
+
raise HTTPException(status_code=502, detail="Could not refresh provider models") from exc
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
@app.post("/runs")
|
| 102 |
+
async def create_run(req: RunRequest) -> RunView:
|
| 103 |
+
return service.create_run(req).run
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
@app.post("/runs/start")
|
| 107 |
+
async def start_run(req: RunRequest) -> RunView:
|
| 108 |
+
return service.start_run(req).run
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
@app.get("/runs/{run_id}")
|
| 112 |
+
async def get_run(run_id: str) -> RunView:
|
| 113 |
+
try:
|
| 114 |
+
return service.get_run(run_id)
|
| 115 |
+
except RunNotFound as exc:
|
| 116 |
+
raise HTTPException(status_code=404, detail="Run not found") from exc
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
@app.get("/runs/{run_id}/events")
|
| 120 |
+
async def stream_run_events(run_id: str) -> StreamingResponse:
|
| 121 |
+
try:
|
| 122 |
+
service.get_run(run_id)
|
| 123 |
+
except RunNotFound as exc:
|
| 124 |
+
raise HTTPException(status_code=404, detail="Run not found") from exc
|
| 125 |
+
|
| 126 |
+
async def event_generator() -> AsyncIterator[str]:
|
| 127 |
+
import asyncio
|
| 128 |
+
q = service.subscribe_events(run_id)
|
| 129 |
+
loop = asyncio.get_event_loop()
|
| 130 |
+
while True:
|
| 131 |
+
event = await loop.run_in_executor(None, q.get)
|
| 132 |
+
if event is None:
|
| 133 |
+
break
|
| 134 |
+
yield f"data: {json.dumps(event, default=str)}\n\n"
|
| 135 |
+
|
| 136 |
+
return StreamingResponse(
|
| 137 |
+
event_generator(),
|
| 138 |
+
media_type="text/event-stream",
|
| 139 |
+
headers={
|
| 140 |
+
"Cache-Control": "no-cache",
|
| 141 |
+
"Connection": "keep-alive",
|
| 142 |
+
"X-Accel-Buffering": "no",
|
| 143 |
+
},
|
| 144 |
+
)
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
@app.get("/runs/{run_id}/codebase.zip")
|
| 148 |
+
async def get_run_codebase_zip(run_id: str) -> Response:
|
| 149 |
+
try:
|
| 150 |
+
payload = service.get_run_codebase_zip(run_id)
|
| 151 |
+
except RunNotFound as exc:
|
| 152 |
+
raise HTTPException(status_code=404, detail="Run not found") from exc
|
| 153 |
+
except FileAccessFailed as exc:
|
| 154 |
+
raise HTTPException(status_code=409, detail="Run has no codebase archive") from exc
|
| 155 |
+
return Response(
|
| 156 |
+
content=payload.data,
|
| 157 |
+
media_type=payload.content_type,
|
| 158 |
+
headers={"Content-Disposition": f'attachment; filename="{payload.filename}"'},
|
| 159 |
+
)
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
@app.get("/runs/{run_id}/field_notes.md")
|
| 163 |
+
async def get_run_field_notes(run_id: str) -> Response:
|
| 164 |
+
try:
|
| 165 |
+
payload = service.get_run_field_notes(run_id)
|
| 166 |
+
except RunNotFound as exc:
|
| 167 |
+
raise HTTPException(status_code=404, detail="Run not found") from exc
|
| 168 |
+
return Response(
|
| 169 |
+
content=payload.data,
|
| 170 |
+
media_type=payload.content_type,
|
| 171 |
+
headers={"Content-Disposition": f'attachment; filename="{payload.filename}"'},
|
| 172 |
+
)
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
@app.get("/runs/{run_id}/trace.json")
|
| 176 |
+
async def get_run_trace_json(run_id: str) -> Response:
|
| 177 |
+
try:
|
| 178 |
+
payload = service.get_run_trace_json(run_id)
|
| 179 |
+
except RunNotFound as exc:
|
| 180 |
+
raise HTTPException(status_code=404, detail="Run not found") from exc
|
| 181 |
+
return Response(
|
| 182 |
+
content=payload.data,
|
| 183 |
+
media_type=payload.content_type,
|
| 184 |
+
headers={"Content-Disposition": f'attachment; filename="{payload.filename}"'},
|
| 185 |
+
)
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
@app.get("/runs/{run_id}/submission.md")
|
| 189 |
+
async def get_run_submission_markdown(run_id: str) -> Response:
|
| 190 |
+
try:
|
| 191 |
+
payload = service.get_run_submission_markdown(run_id)
|
| 192 |
+
except RunNotFound as exc:
|
| 193 |
+
raise HTTPException(status_code=404, detail="Run not found") from exc
|
| 194 |
+
return Response(
|
| 195 |
+
content=payload.data,
|
| 196 |
+
media_type=payload.content_type,
|
| 197 |
+
headers={"Content-Disposition": f'attachment; filename="{payload.filename}"'},
|
| 198 |
+
)
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
@app.get("/runs/{run_id}/space_README.md")
|
| 202 |
+
async def get_run_space_readme(run_id: str) -> Response:
|
| 203 |
+
try:
|
| 204 |
+
payload = service.get_run_space_readme(run_id)
|
| 205 |
+
except RunNotFound as exc:
|
| 206 |
+
raise HTTPException(status_code=404, detail="Run not found") from exc
|
| 207 |
+
return Response(
|
| 208 |
+
content=payload.data,
|
| 209 |
+
media_type=payload.content_type,
|
| 210 |
+
headers={"Content-Disposition": f'attachment; filename="{payload.filename}"'},
|
| 211 |
+
)
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
@app.post("/runs/rejoin")
|
| 215 |
+
async def rejoin_run(req: RejoinRequest) -> RunView:
|
| 216 |
+
try:
|
| 217 |
+
return service.rejoin_run(req.token).run
|
| 218 |
+
except InvalidRunToken as exc:
|
| 219 |
+
raise HTTPException(status_code=404, detail="Token not found") from exc
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
@app.post("/runs/{run_id}/cancel")
|
| 223 |
+
async def cancel_run(run_id: str) -> RunView:
|
| 224 |
+
try:
|
| 225 |
+
return service.cancel_run(run_id)
|
| 226 |
+
except RunNotFound as exc:
|
| 227 |
+
raise HTTPException(status_code=404, detail="Run not found") from exc
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
@app.post("/runs/{run_id}/messages")
|
| 231 |
+
async def add_run_message(run_id: str, req: RunMessageRequest) -> RunView:
|
| 232 |
+
try:
|
| 233 |
+
return service.add_run_message(run_id, req.content)
|
| 234 |
+
except RunNotFound as exc:
|
| 235 |
+
raise HTTPException(status_code=404, detail="Run not found") from exc
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
@app.post("/runs/{run_id}/validate")
|
| 239 |
+
async def validate_run(run_id: str) -> ValidationReport:
|
| 240 |
+
try:
|
| 241 |
+
return service.validate_run(run_id)
|
| 242 |
+
except RunNotFound as exc:
|
| 243 |
+
raise HTTPException(status_code=404, detail="Run not found") from exc
|
| 244 |
+
except FileAccessFailed as exc:
|
| 245 |
+
raise HTTPException(status_code=409, detail="Run has no codebase archive") from exc
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
@app.post("/threads")
|
| 249 |
+
async def create_thread() -> ThreadResponse:
|
| 250 |
+
try:
|
| 251 |
+
return thread_inspector.create_thread()
|
| 252 |
+
except Exception as exc:
|
| 253 |
+
raise HTTPException(status_code=500, detail="Could not create agent session") from exc
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
@app.post("/threads/{thread_id}/messages")
|
| 257 |
+
async def send_message(thread_id: str, req: MessageRequest) -> MessageResponse:
|
| 258 |
+
try:
|
| 259 |
+
return thread_inspector.send_message(thread_id, req.content)
|
| 260 |
+
except ThreadNotFound as exc:
|
| 261 |
+
raise HTTPException(status_code=404, detail="Thread not found") from exc
|
| 262 |
+
except AgentRunFailed as exc:
|
| 263 |
+
raise HTTPException(status_code=502, detail="Agent run failed") from exc
|
| 264 |
+
|
| 265 |
+
|
| 266 |
+
@app.get("/threads/{thread_id}/files")
|
| 267 |
+
async def list_files(thread_id: str, path: str = Query("/")) -> list[FileEntry]:
|
| 268 |
+
try:
|
| 269 |
+
return thread_inspector.list_files(thread_id, path)
|
| 270 |
+
except ThreadNotFound as exc:
|
| 271 |
+
raise HTTPException(status_code=404, detail="Thread not found") from exc
|
| 272 |
+
except FileAccessFailed as exc:
|
| 273 |
+
raise HTTPException(status_code=400, detail="Could not list files") from exc
|
| 274 |
+
|
| 275 |
+
|
| 276 |
+
@app.get("/threads/{thread_id}/files/content")
|
| 277 |
+
async def read_file(thread_id: str, path: str) -> dict[str, str]:
|
| 278 |
+
try:
|
| 279 |
+
return thread_inspector.read_file(thread_id, path)
|
| 280 |
+
except ThreadNotFound as exc:
|
| 281 |
+
raise HTTPException(status_code=404, detail="Thread not found") from exc
|
| 282 |
+
except FileAccessFailed as exc:
|
| 283 |
+
raise HTTPException(status_code=400, detail="Could not read file") from exc
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
@app.delete("/threads/{thread_id}")
|
| 287 |
+
async def delete_thread(thread_id: str) -> dict[str, bool]:
|
| 288 |
+
try:
|
| 289 |
+
return thread_inspector.delete_thread(thread_id)
|
| 290 |
+
except ThreadNotFound as exc:
|
| 291 |
+
raise HTTPException(status_code=404, detail="Thread not found") from exc
|
arena/backyard_templates.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Backyard Demo Builder templates."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from dataclasses import dataclass
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
@dataclass(frozen=True)
|
| 9 |
+
class DemoTemplate:
|
| 10 |
+
id: str
|
| 11 |
+
label: str
|
| 12 |
+
prompt: str
|
| 13 |
+
criteria: str
|
| 14 |
+
checks: str
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
REAL_ESTATE_FOLLOWUP = DemoTemplate(
|
| 18 |
+
id="real_estate_followup_crm",
|
| 19 |
+
label="Real Estate Follow-up CRM",
|
| 20 |
+
prompt="""Build a small Gradio demo app for a real-estate agent who needs a cheap way to test a customer follow-up workflow before paying for full software.
|
| 21 |
+
|
| 22 |
+
The app should manage buyer/renter leads, compute next follow-up reminders from lead status and last-contact date, generate English/Vietnamese follow-up message drafts, include sample leads, and export a handoff spec.
|
| 23 |
+
|
| 24 |
+
Keep data session-only. No auth, no DB, no external messaging APIs.""",
|
| 25 |
+
criteria="""Works as a Gradio app
|
| 26 |
+
Uses English/Vietnamese UI toggle
|
| 27 |
+
Includes sample real-estate leads
|
| 28 |
+
Computes next follow-up reminders
|
| 29 |
+
Generates follow-up message drafts
|
| 30 |
+
Exports handoff spec and field notes
|
| 31 |
+
No DB or cloud storage required""",
|
| 32 |
+
checks="""python -m py_compile app.py
|
| 33 |
+
test -f README.md
|
| 34 |
+
test -f handoff_spec.md
|
| 35 |
+
test -f field_notes.md""",
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
TEMPLATES = (REAL_ESTATE_FOLLOWUP,)
|
| 40 |
+
DEFAULT_TEMPLATE_ID = REAL_ESTATE_FOLLOWUP.id
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def template_choices() -> list[str]:
|
| 44 |
+
return [template.label for template in TEMPLATES]
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def template_by_label(label: str) -> DemoTemplate:
|
| 48 |
+
for template in TEMPLATES:
|
| 49 |
+
if template.label == label or template.id == label:
|
| 50 |
+
return template
|
| 51 |
+
return REAL_ESTATE_FOLLOWUP
|
arena/browserbase_tools.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Browserbase Fetch and Search tools for the DeepAgent swarm."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
import os
|
| 7 |
+
from typing import Any
|
| 8 |
+
|
| 9 |
+
from browserbase import Browserbase
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def _client() -> Browserbase:
|
| 13 |
+
api_key = os.environ.get("BROWSERBASE_API_KEY", "")
|
| 14 |
+
if not api_key:
|
| 15 |
+
raise RuntimeError("BROWSERBASE_API_KEY is required for fetch/search tools")
|
| 16 |
+
return Browserbase(api_key=api_key)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def browserbase_fetch(url: str, allow_redirects: bool = False) -> str:
|
| 20 |
+
"""Fetch and return the content of a web page without a browser.
|
| 21 |
+
|
| 22 |
+
Use this to retrieve HTML, JSON, or text content from a URL.
|
| 23 |
+
Best for static pages or API endpoints that don't require JavaScript.
|
| 24 |
+
|
| 25 |
+
Args:
|
| 26 |
+
url: The URL to fetch.
|
| 27 |
+
allow_redirects: Whether to follow HTTP redirects (default False).
|
| 28 |
+
|
| 29 |
+
Returns:
|
| 30 |
+
The page content as a string, or an error message if the fetch fails.
|
| 31 |
+
"""
|
| 32 |
+
try:
|
| 33 |
+
response = _client().fetch_api.create(url=url, allow_redirects=allow_redirects)
|
| 34 |
+
if hasattr(response, "content"):
|
| 35 |
+
content = response.content
|
| 36 |
+
if len(content) > 10000:
|
| 37 |
+
content = content[:10000] + "\n...[truncated]"
|
| 38 |
+
return str(content)
|
| 39 |
+
return str(response)
|
| 40 |
+
except Exception as exc:
|
| 41 |
+
return f"Fetch error: {exc}"
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def browserbase_search(query: str, num_results: int = 5) -> str:
|
| 45 |
+
"""Search the web and return structured results without a browser.
|
| 46 |
+
|
| 47 |
+
Use this to find URLs, documentation, or reference material.
|
| 48 |
+
Returns title, URL, and description for each result.
|
| 49 |
+
|
| 50 |
+
Args:
|
| 51 |
+
query: The search query string.
|
| 52 |
+
num_results: Number of results to return (1-25, default 5).
|
| 53 |
+
|
| 54 |
+
Returns:
|
| 55 |
+
Formatted JSON string with search results, or an error message.
|
| 56 |
+
"""
|
| 57 |
+
try:
|
| 58 |
+
response = _client().search_api.create(query=query, num_results=num_results)
|
| 59 |
+
if hasattr(response, "results"):
|
| 60 |
+
formatted = []
|
| 61 |
+
for r in response.results:
|
| 62 |
+
entry = {}
|
| 63 |
+
if hasattr(r, "title"):
|
| 64 |
+
entry["title"] = r.title
|
| 65 |
+
if hasattr(r, "url"):
|
| 66 |
+
entry["url"] = r.url
|
| 67 |
+
if hasattr(r, "description"):
|
| 68 |
+
entry["description"] = r.description
|
| 69 |
+
formatted.append(entry)
|
| 70 |
+
return json.dumps(formatted, indent=2, ensure_ascii=False)
|
| 71 |
+
return str(response)
|
| 72 |
+
except Exception as exc:
|
| 73 |
+
return f"Search error: {exc}"
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def _fetch_tool_schema() -> dict[str, Any]:
|
| 77 |
+
return {
|
| 78 |
+
"name": "browserbase_fetch",
|
| 79 |
+
"description": "Fetch and return the content of a web page without a browser. Use for static pages or APIs.",
|
| 80 |
+
"parameters": {
|
| 81 |
+
"type": "object",
|
| 82 |
+
"properties": {
|
| 83 |
+
"url": {
|
| 84 |
+
"type": "string",
|
| 85 |
+
"description": "The URL to fetch.",
|
| 86 |
+
},
|
| 87 |
+
"allow_redirects": {
|
| 88 |
+
"type": "boolean",
|
| 89 |
+
"description": "Whether to follow HTTP redirects (default False).",
|
| 90 |
+
},
|
| 91 |
+
},
|
| 92 |
+
"required": ["url"],
|
| 93 |
+
},
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def _search_tool_schema() -> dict[str, Any]:
|
| 98 |
+
return {
|
| 99 |
+
"name": "browserbase_search",
|
| 100 |
+
"description": "Search the web for documentation, references, or examples. Returns title, URL, and description.",
|
| 101 |
+
"parameters": {
|
| 102 |
+
"type": "object",
|
| 103 |
+
"properties": {
|
| 104 |
+
"query": {
|
| 105 |
+
"type": "string",
|
| 106 |
+
"description": "The search query string.",
|
| 107 |
+
},
|
| 108 |
+
"num_results": {
|
| 109 |
+
"type": "integer",
|
| 110 |
+
"description": "Number of results to return (1-25, default 5).",
|
| 111 |
+
},
|
| 112 |
+
},
|
| 113 |
+
"required": ["query"],
|
| 114 |
+
},
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
SWARM_TOOL_SCHEMAS: list[dict[str, Any]] = [_fetch_tool_schema(), _search_tool_schema()]
|
| 119 |
+
|
| 120 |
+
SWARM_TOOLS: dict[str, Any] = {
|
| 121 |
+
"browserbase_fetch": browserbase_fetch,
|
| 122 |
+
"browserbase_search": browserbase_search,
|
| 123 |
+
}
|
arena/codebase_archive.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Codebase archive storage for Run and Validator workspaces.
|
| 2 |
+
|
| 3 |
+
Archives are content-addressed by the Run id, but hidden behind a small store
|
| 4 |
+
interface. Local development writes files to disk; hosted deployments can
|
| 5 |
+
persist compact archives in Redis so validation survives process restarts.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import base64
|
| 11 |
+
import io
|
| 12 |
+
import json
|
| 13 |
+
import os
|
| 14 |
+
import shutil
|
| 15 |
+
import tempfile
|
| 16 |
+
import zipfile
|
| 17 |
+
from dataclasses import dataclass
|
| 18 |
+
from pathlib import Path
|
| 19 |
+
from typing import Protocol
|
| 20 |
+
|
| 21 |
+
from pydantic import BaseModel, Field, field_validator, model_validator
|
| 22 |
+
|
| 23 |
+
from .redis_client import RedisClient, RedisStoreError, UpstashRedisRestClient
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
LOCAL_ARCHIVE_SCHEME = "local-snapshot://"
|
| 27 |
+
REDIS_ARCHIVE_SCHEME = "redis-archive://"
|
| 28 |
+
DEFAULT_ARCHIVE_ROOT = Path(tempfile.gettempdir()) / "agent-swarm-workbench-codebases"
|
| 29 |
+
ARCHIVE_FILE_MAX_BYTES = 10_000_000
|
| 30 |
+
ARCHIVE_TOTAL_MAX_BYTES = 50_000_000
|
| 31 |
+
ARCHIVE_IGNORED_DIR_NAMES = frozenset({"__pycache__"})
|
| 32 |
+
ARCHIVE_IGNORED_SUFFIXES = (".pyc", ".pyo")
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class CodebaseArchiveError(ValueError):
|
| 36 |
+
pass
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
class ArchiveFile(BaseModel):
|
| 40 |
+
path: str = Field(min_length=1)
|
| 41 |
+
content_base64: str
|
| 42 |
+
size_bytes: int = Field(ge=0, le=ARCHIVE_FILE_MAX_BYTES)
|
| 43 |
+
|
| 44 |
+
@field_validator("path")
|
| 45 |
+
@classmethod
|
| 46 |
+
def validate_path(cls, value: str) -> str:
|
| 47 |
+
return _safe_relative_path(value)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
class CodebaseArchiveSnapshot(BaseModel):
|
| 51 |
+
archive_id: str = Field(min_length=1)
|
| 52 |
+
files: list[ArchiveFile] = Field(default_factory=list)
|
| 53 |
+
|
| 54 |
+
@model_validator(mode="after")
|
| 55 |
+
def validate_total_size(self) -> "CodebaseArchiveSnapshot":
|
| 56 |
+
total = sum(file.size_bytes for file in self.files)
|
| 57 |
+
if total > ARCHIVE_TOTAL_MAX_BYTES:
|
| 58 |
+
raise ValueError("codebase archive exceeds total size cap")
|
| 59 |
+
return self
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
class CodebaseArchiveStore(Protocol):
|
| 63 |
+
def save(self, archive: CodebaseArchiveSnapshot) -> str:
|
| 64 |
+
...
|
| 65 |
+
|
| 66 |
+
def load(self, archive_pointer: str) -> CodebaseArchiveSnapshot:
|
| 67 |
+
...
|
| 68 |
+
|
| 69 |
+
def materialize(self, archive_pointer: str, destination: Path) -> None:
|
| 70 |
+
...
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
class LocalCodebaseArchiveStore:
|
| 74 |
+
def __init__(self, *, root_dir: Path | None = None) -> None:
|
| 75 |
+
self.root_dir = root_dir or DEFAULT_ARCHIVE_ROOT
|
| 76 |
+
|
| 77 |
+
def save(self, archive: CodebaseArchiveSnapshot) -> str:
|
| 78 |
+
destination = self.root_dir / archive.archive_id / "workspace"
|
| 79 |
+
if destination.exists():
|
| 80 |
+
shutil.rmtree(destination)
|
| 81 |
+
destination.mkdir(parents=True, exist_ok=True)
|
| 82 |
+
_write_archive_files(archive, destination)
|
| 83 |
+
return f"{LOCAL_ARCHIVE_SCHEME}{archive.archive_id}"
|
| 84 |
+
|
| 85 |
+
def load(self, archive_pointer: str) -> CodebaseArchiveSnapshot:
|
| 86 |
+
archive_id = _pointer_id(archive_pointer, LOCAL_ARCHIVE_SCHEME)
|
| 87 |
+
workspace = self.root_dir / archive_id / "workspace"
|
| 88 |
+
if not workspace.exists():
|
| 89 |
+
raise FileNotFoundError(f"snapshot workspace not found: {archive_id}")
|
| 90 |
+
return archive_from_directory(archive_id, workspace)
|
| 91 |
+
|
| 92 |
+
def materialize(self, archive_pointer: str, destination: Path) -> None:
|
| 93 |
+
archive = self.load(archive_pointer)
|
| 94 |
+
if destination.exists():
|
| 95 |
+
shutil.rmtree(destination)
|
| 96 |
+
destination.mkdir(parents=True, exist_ok=True)
|
| 97 |
+
_write_archive_files(archive, destination)
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
@dataclass
|
| 101 |
+
class RedisCodebaseArchiveStore:
|
| 102 |
+
client: RedisClient
|
| 103 |
+
key_prefix: str = "arena"
|
| 104 |
+
key_namespace: str = "codebase-archive"
|
| 105 |
+
|
| 106 |
+
def save(self, archive: CodebaseArchiveSnapshot) -> str:
|
| 107 |
+
key = self._key(archive.archive_id)
|
| 108 |
+
self.client.execute("SET", key, archive.model_dump_json())
|
| 109 |
+
return f"{REDIS_ARCHIVE_SCHEME}{archive.archive_id}"
|
| 110 |
+
|
| 111 |
+
def load(self, archive_pointer: str) -> CodebaseArchiveSnapshot:
|
| 112 |
+
archive_id = _pointer_id(archive_pointer, REDIS_ARCHIVE_SCHEME)
|
| 113 |
+
payload = self.client.execute("GET", self._key(archive_id))
|
| 114 |
+
if payload is None:
|
| 115 |
+
raise FileNotFoundError(f"codebase archive not found: {archive_id}")
|
| 116 |
+
try:
|
| 117 |
+
return CodebaseArchiveSnapshot.model_validate_json(str(payload))
|
| 118 |
+
except ValueError as exc:
|
| 119 |
+
raise RedisStoreError("codebase archive is invalid") from exc
|
| 120 |
+
|
| 121 |
+
def materialize(self, archive_pointer: str, destination: Path) -> None:
|
| 122 |
+
archive = self.load(archive_pointer)
|
| 123 |
+
if destination.exists():
|
| 124 |
+
shutil.rmtree(destination)
|
| 125 |
+
destination.mkdir(parents=True, exist_ok=True)
|
| 126 |
+
_write_archive_files(archive, destination)
|
| 127 |
+
|
| 128 |
+
def _key(self, archive_id: str) -> str:
|
| 129 |
+
return f"{self.key_prefix.rstrip(':')}:{self.key_namespace}:{archive_id}"
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def create_codebase_archive_store() -> CodebaseArchiveStore:
|
| 133 |
+
provider = (
|
| 134 |
+
os.getenv("CODEBASE_ARCHIVE_PROVIDER")
|
| 135 |
+
or os.getenv("MATCH_STORE_PROVIDER", "local")
|
| 136 |
+
)
|
| 137 |
+
provider = provider.lower()
|
| 138 |
+
if provider in {"local", "memory", "inmemory", "in-memory"}:
|
| 139 |
+
return LocalCodebaseArchiveStore()
|
| 140 |
+
if provider in {"redis", "upstash"}:
|
| 141 |
+
url = os.getenv("UPSTASH_REDIS_REST_URL", "")
|
| 142 |
+
token = os.getenv("UPSTASH_REDIS_REST_TOKEN", "")
|
| 143 |
+
if not url or not token:
|
| 144 |
+
raise ValueError(
|
| 145 |
+
"UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN are required "
|
| 146 |
+
"when CODEBASE_ARCHIVE_PROVIDER=redis"
|
| 147 |
+
)
|
| 148 |
+
return RedisCodebaseArchiveStore(client=UpstashRedisRestClient(url=url, token=token))
|
| 149 |
+
raise ValueError(f"Unsupported codebase archive provider: {provider}")
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def archive_from_directory(archive_id: str, workspace: Path) -> CodebaseArchiveSnapshot:
|
| 153 |
+
if not workspace.exists():
|
| 154 |
+
raise FileNotFoundError(f"workspace not found: {workspace}")
|
| 155 |
+
files = [
|
| 156 |
+
_archive_file(relative_path, path.read_bytes())
|
| 157 |
+
for path in sorted(workspace.rglob("*"))
|
| 158 |
+
for relative_path in [path.relative_to(workspace).as_posix()]
|
| 159 |
+
if path.is_file()
|
| 160 |
+
if not _is_ignored_archive_path(relative_path)
|
| 161 |
+
]
|
| 162 |
+
return CodebaseArchiveSnapshot(archive_id=archive_id, files=files)
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def archive_from_downloads(
|
| 166 |
+
archive_id: str,
|
| 167 |
+
downloads: list[tuple[str, bytes]],
|
| 168 |
+
) -> CodebaseArchiveSnapshot:
|
| 169 |
+
return CodebaseArchiveSnapshot(
|
| 170 |
+
archive_id=archive_id,
|
| 171 |
+
files=[
|
| 172 |
+
_archive_file(path, content)
|
| 173 |
+
for path, content in downloads
|
| 174 |
+
if not _is_ignored_archive_path(path)
|
| 175 |
+
],
|
| 176 |
+
)
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def archive_to_zip_bytes(archive: CodebaseArchiveSnapshot) -> bytes:
|
| 180 |
+
buffer = io.BytesIO()
|
| 181 |
+
with zipfile.ZipFile(buffer, mode="w", compression=zipfile.ZIP_DEFLATED) as zipped:
|
| 182 |
+
for file in sorted(archive.files, key=lambda item: item.path):
|
| 183 |
+
zipped.writestr(
|
| 184 |
+
_safe_relative_path(file.path),
|
| 185 |
+
base64.b64decode(file.content_base64.encode("ascii")),
|
| 186 |
+
)
|
| 187 |
+
return buffer.getvalue()
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
def supports_archive_pointer(archive_pointer: str) -> bool:
|
| 191 |
+
return archive_pointer.startswith(LOCAL_ARCHIVE_SCHEME) or archive_pointer.startswith(REDIS_ARCHIVE_SCHEME)
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
def archive_to_json(archive: CodebaseArchiveSnapshot) -> str:
|
| 195 |
+
return json.dumps(archive.model_dump(mode="json"), sort_keys=True)
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
def _archive_file(path: str, content: bytes) -> ArchiveFile:
|
| 199 |
+
safe_path = _safe_relative_path(path)
|
| 200 |
+
return ArchiveFile(
|
| 201 |
+
path=safe_path,
|
| 202 |
+
content_base64=base64.b64encode(content).decode("ascii"),
|
| 203 |
+
size_bytes=len(content),
|
| 204 |
+
)
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
def _is_ignored_archive_path(path: str) -> bool:
|
| 208 |
+
relative = Path(path)
|
| 209 |
+
return (
|
| 210 |
+
any(part in ARCHIVE_IGNORED_DIR_NAMES for part in relative.parts)
|
| 211 |
+
or relative.suffix in ARCHIVE_IGNORED_SUFFIXES
|
| 212 |
+
)
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
def _write_archive_files(archive: CodebaseArchiveSnapshot, destination: Path) -> None:
|
| 216 |
+
for file in archive.files:
|
| 217 |
+
relative = Path(_safe_relative_path(file.path))
|
| 218 |
+
target = destination / relative
|
| 219 |
+
target.parent.mkdir(parents=True, exist_ok=True)
|
| 220 |
+
target.write_bytes(base64.b64decode(file.content_base64.encode("ascii")))
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
def _safe_relative_path(path: str) -> str:
|
| 224 |
+
relative = Path(path)
|
| 225 |
+
if relative.is_absolute() or not path or ".." in relative.parts:
|
| 226 |
+
raise CodebaseArchiveError(f"unsafe archive path: {path}")
|
| 227 |
+
return relative.as_posix()
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
def _pointer_id(archive_pointer: str, scheme: str) -> str:
|
| 231 |
+
if not archive_pointer.startswith(scheme):
|
| 232 |
+
raise CodebaseArchiveError("unsupported archive pointer")
|
| 233 |
+
archive_id = archive_pointer.removeprefix(scheme)
|
| 234 |
+
if not archive_id or "/" in archive_id or ".." in archive_id:
|
| 235 |
+
raise CodebaseArchiveError("invalid archive pointer")
|
| 236 |
+
return archive_id
|
arena/codebase_executor.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Sandboxed command executors for Codebase archives."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import os
|
| 6 |
+
import shlex
|
| 7 |
+
import subprocess
|
| 8 |
+
import sys
|
| 9 |
+
import tempfile
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
from typing import Any, Callable
|
| 12 |
+
from uuid import uuid4
|
| 13 |
+
|
| 14 |
+
from deepagents.backends import LocalShellBackend
|
| 15 |
+
|
| 16 |
+
from .codebase_archive import (
|
| 17 |
+
CodebaseArchiveStore,
|
| 18 |
+
LocalCodebaseArchiveStore,
|
| 19 |
+
create_codebase_archive_store,
|
| 20 |
+
)
|
| 21 |
+
from .codebase_handoff import (
|
| 22 |
+
CodebaseHandoff,
|
| 23 |
+
DEFAULT_CODEBASE_HANDOFF_ROOT,
|
| 24 |
+
local_snapshot_workspace,
|
| 25 |
+
pointer_error,
|
| 26 |
+
)
|
| 27 |
+
from .sandbox_lease import SandboxLeaseManager
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
DEFAULT_SNAPSHOT_ROOT = DEFAULT_CODEBASE_HANDOFF_ROOT
|
| 31 |
+
DEFAULT_VALIDATOR_SANDBOX_ROOT = Path(tempfile.gettempdir()) / "agent-swarm-workbench-validator"
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class LocalCodebaseExecutor:
|
| 35 |
+
"""Runs validation checks against local Codebase archive materializations."""
|
| 36 |
+
|
| 37 |
+
def __init__(self, *, root_dir: Path | None = None, timeout_seconds: int = 20) -> None:
|
| 38 |
+
self.root_dir = root_dir or DEFAULT_SNAPSHOT_ROOT
|
| 39 |
+
self.timeout_seconds = timeout_seconds
|
| 40 |
+
|
| 41 |
+
def run(self, archive_pointer: str, command: str) -> tuple[bool, str]:
|
| 42 |
+
workspace = local_snapshot_workspace(self.root_dir, archive_pointer)
|
| 43 |
+
if workspace is None:
|
| 44 |
+
return False, pointer_error(archive_pointer)
|
| 45 |
+
if not workspace.exists():
|
| 46 |
+
return False, f"snapshot workspace not found: {archive_pointer}"
|
| 47 |
+
|
| 48 |
+
try:
|
| 49 |
+
result = subprocess.run(
|
| 50 |
+
["/bin/sh", "-lc", command],
|
| 51 |
+
cwd=workspace,
|
| 52 |
+
capture_output=True,
|
| 53 |
+
text=True,
|
| 54 |
+
timeout=self.timeout_seconds,
|
| 55 |
+
env={"PATH": _executor_path()},
|
| 56 |
+
check=False,
|
| 57 |
+
)
|
| 58 |
+
except subprocess.TimeoutExpired as exc:
|
| 59 |
+
output = (exc.stdout or "") + (exc.stderr or "")
|
| 60 |
+
return False, f"{output}\n[Command timed out after {self.timeout_seconds}s]"
|
| 61 |
+
|
| 62 |
+
output = result.stdout
|
| 63 |
+
if result.stderr:
|
| 64 |
+
output = f"{output}[stderr] {result.stderr}"
|
| 65 |
+
status = "succeeded" if result.returncode == 0 else "failed"
|
| 66 |
+
return result.returncode == 0, f"{output}\n[Command {status} with exit code {result.returncode}]"
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
class CodebaseSandboxExecutor:
|
| 70 |
+
"""Restores Codebase archives into a sandbox backend before running checks."""
|
| 71 |
+
|
| 72 |
+
def __init__(
|
| 73 |
+
self,
|
| 74 |
+
*,
|
| 75 |
+
backend_factory: Callable[[], Any],
|
| 76 |
+
root_dir: Path | None = None,
|
| 77 |
+
archive_store: CodebaseArchiveStore | None = None,
|
| 78 |
+
timeout_seconds: int = 20,
|
| 79 |
+
sandbox_ttl_seconds: float | None = None,
|
| 80 |
+
sandbox_leases: SandboxLeaseManager | None = None,
|
| 81 |
+
) -> None:
|
| 82 |
+
self.root_dir = root_dir or DEFAULT_SNAPSHOT_ROOT
|
| 83 |
+
self.archive_store = archive_store or LocalCodebaseArchiveStore(root_dir=self.root_dir)
|
| 84 |
+
self.handoff = CodebaseHandoff(archive_store=self.archive_store, root_dir=self.root_dir)
|
| 85 |
+
self.backend_factory = backend_factory
|
| 86 |
+
self.backend = None
|
| 87 |
+
self.timeout_seconds = timeout_seconds
|
| 88 |
+
self.workspace_dir = "workspace"
|
| 89 |
+
self._restored: set[str] = set()
|
| 90 |
+
self._lease_id = f"validator-{uuid4().hex}"
|
| 91 |
+
self._sandbox_leases = sandbox_leases or SandboxLeaseManager(ttl_seconds=sandbox_ttl_seconds)
|
| 92 |
+
|
| 93 |
+
def run(self, archive_pointer: str, command: str) -> tuple[bool, str]:
|
| 94 |
+
self._ensure_backend()
|
| 95 |
+
self._sandbox_leases.touch(self._lease_id)
|
| 96 |
+
try:
|
| 97 |
+
self._restore(archive_pointer)
|
| 98 |
+
except (OSError, ValueError) as exc:
|
| 99 |
+
return False, f"could not restore snapshot: {exc}"
|
| 100 |
+
|
| 101 |
+
result = self.backend.execute(f"cd {shlex.quote(self.workspace_dir)} && {command}", timeout=self.timeout_seconds)
|
| 102 |
+
output = getattr(result, "output", "")
|
| 103 |
+
exit_code = getattr(result, "exit_code", None)
|
| 104 |
+
if exit_code is None:
|
| 105 |
+
return True, output
|
| 106 |
+
status = "succeeded" if exit_code == 0 else "failed"
|
| 107 |
+
return exit_code == 0, f"{output}\n[Command {status} with exit code {exit_code}]"
|
| 108 |
+
|
| 109 |
+
def close(self) -> None:
|
| 110 |
+
self._sandbox_leases.close(self._lease_id)
|
| 111 |
+
|
| 112 |
+
def _open_backend(self) -> None:
|
| 113 |
+
self.backend = self.backend_factory()
|
| 114 |
+
self.workspace_dir = getattr(self.backend, "_validator_workspace", "workspace")
|
| 115 |
+
self._restored = set()
|
| 116 |
+
self._sandbox_leases.start(self._lease_id, self._close_backend)
|
| 117 |
+
|
| 118 |
+
def _ensure_backend(self) -> None:
|
| 119 |
+
if self.backend is None:
|
| 120 |
+
self._open_backend()
|
| 121 |
+
|
| 122 |
+
def _close_backend(self) -> None:
|
| 123 |
+
if self.backend is None:
|
| 124 |
+
return
|
| 125 |
+
sandbox = getattr(self.backend, "_daytona_sandbox", None)
|
| 126 |
+
cleanup = None
|
| 127 |
+
if sandbox is not None:
|
| 128 |
+
cleanup = getattr(sandbox, "delete", None) or getattr(sandbox, "stop", None)
|
| 129 |
+
if cleanup is None:
|
| 130 |
+
cleanup = getattr(self.backend, "delete", None) or getattr(self.backend, "stop", None)
|
| 131 |
+
if callable(cleanup):
|
| 132 |
+
cleanup()
|
| 133 |
+
self.backend = None
|
| 134 |
+
self._restored = set()
|
| 135 |
+
|
| 136 |
+
def _restore(self, archive_pointer: str) -> None:
|
| 137 |
+
if archive_pointer in self._restored:
|
| 138 |
+
return
|
| 139 |
+
self.handoff.restore_to_backend(
|
| 140 |
+
archive_pointer,
|
| 141 |
+
self.backend,
|
| 142 |
+
workspace_dir=self.workspace_dir,
|
| 143 |
+
)
|
| 144 |
+
self._restored.add(archive_pointer)
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def create_codebase_executor(
|
| 148 |
+
archive_store: CodebaseArchiveStore | None = None,
|
| 149 |
+
provider: str | None = None,
|
| 150 |
+
) -> CodebaseSandboxExecutor:
|
| 151 |
+
provider = (provider or os.getenv("VALIDATOR_SANDBOX_PROVIDER", "local")).lower()
|
| 152 |
+
archive_store = archive_store or create_codebase_archive_store()
|
| 153 |
+
if provider == "local":
|
| 154 |
+
return CodebaseSandboxExecutor(backend_factory=_local_validator_backend, archive_store=archive_store)
|
| 155 |
+
if provider == "daytona":
|
| 156 |
+
return CodebaseSandboxExecutor(backend_factory=_daytona_validator_backend, archive_store=archive_store)
|
| 157 |
+
raise ValueError(f"unsupported validator sandbox provider: {provider}")
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def _executor_path() -> str:
|
| 161 |
+
executable_dir = str(Path(sys.executable).parent)
|
| 162 |
+
return os.pathsep.join([executable_dir, "/usr/local/bin", "/usr/bin", "/bin"])
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def _local_validator_backend() -> LocalShellBackend:
|
| 166 |
+
root = DEFAULT_VALIDATOR_SANDBOX_ROOT / uuid4().hex
|
| 167 |
+
root.mkdir(parents=True, exist_ok=True)
|
| 168 |
+
backend = LocalShellBackend(root_dir=root, virtual_mode=True, inherit_env=True)
|
| 169 |
+
backend._validator_workspace = "workspace"
|
| 170 |
+
return backend
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def _daytona_validator_backend() -> Any:
|
| 174 |
+
from daytona import Daytona
|
| 175 |
+
from langchain_daytona import DaytonaSandbox
|
| 176 |
+
|
| 177 |
+
client = Daytona()
|
| 178 |
+
sandbox = client.create()
|
| 179 |
+
backend = DaytonaSandbox(sandbox=sandbox)
|
| 180 |
+
backend._daytona_client = client
|
| 181 |
+
backend._daytona_sandbox = sandbox
|
| 182 |
+
backend._validator_workspace = "/home/daytona/workspace"
|
| 183 |
+
return backend
|
arena/codebase_handoff.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Codebase handoff between Swarm workspaces, archives, and Validator sandboxes."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import re
|
| 6 |
+
import tempfile
|
| 7 |
+
from dataclasses import dataclass
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from typing import Any
|
| 10 |
+
|
| 11 |
+
from .codebase_archive import (
|
| 12 |
+
CodebaseArchiveStore,
|
| 13 |
+
LocalCodebaseArchiveStore,
|
| 14 |
+
archive_from_directory,
|
| 15 |
+
archive_from_downloads,
|
| 16 |
+
supports_archive_pointer,
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
DEFAULT_CODEBASE_HANDOFF_ROOT = Path(tempfile.gettempdir()) / "agent-swarm-workbench-codebases"
|
| 21 |
+
LOCAL_SNAPSHOT_SCHEME = "local-snapshot://"
|
| 22 |
+
CODEBASE_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]+$")
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
@dataclass
|
| 26 |
+
class CodebaseHandoff:
|
| 27 |
+
"""Moves a Codebase across Workspace, archive, and Validator sandbox seams."""
|
| 28 |
+
|
| 29 |
+
archive_store: CodebaseArchiveStore | None = None
|
| 30 |
+
root_dir: Path | None = None
|
| 31 |
+
|
| 32 |
+
def __post_init__(self) -> None:
|
| 33 |
+
root = self.root_dir or DEFAULT_CODEBASE_HANDOFF_ROOT
|
| 34 |
+
if self.archive_store is None:
|
| 35 |
+
self.archive_store = LocalCodebaseArchiveStore(root_dir=root)
|
| 36 |
+
|
| 37 |
+
def snapshot_local_workspace(self, source_root: Path, codebase_id: str) -> str:
|
| 38 |
+
_validate_codebase_id(codebase_id)
|
| 39 |
+
return self.archive_store.save(archive_from_directory(codebase_id, source_root / "workspace"))
|
| 40 |
+
|
| 41 |
+
def snapshot_backend_workspace(
|
| 42 |
+
self,
|
| 43 |
+
backend: Any,
|
| 44 |
+
codebase_id: str,
|
| 45 |
+
*,
|
| 46 |
+
workspace_dir: str = "/workspace",
|
| 47 |
+
) -> str:
|
| 48 |
+
_validate_codebase_id(codebase_id)
|
| 49 |
+
workspace_dir = normalize_workspace_dir(workspace_dir)
|
| 50 |
+
glob_result = backend.glob(f"{workspace_dir}/**")
|
| 51 |
+
if getattr(glob_result, "error", None):
|
| 52 |
+
raise RuntimeError("could not list workspace files")
|
| 53 |
+
|
| 54 |
+
file_paths = [
|
| 55 |
+
_entry_path(match)
|
| 56 |
+
for match in getattr(glob_result, "matches", [])
|
| 57 |
+
if not _entry_is_dir(match)
|
| 58 |
+
]
|
| 59 |
+
if not file_paths:
|
| 60 |
+
raise FileNotFoundError("workspace has no files")
|
| 61 |
+
|
| 62 |
+
archive_files: list[tuple[str, bytes]] = []
|
| 63 |
+
for item in backend.download_files(file_paths):
|
| 64 |
+
if getattr(item, "error", None):
|
| 65 |
+
raise RuntimeError(f"could not download workspace file: {getattr(item, 'path', '')}")
|
| 66 |
+
source_path = str(getattr(item, "path", ""))
|
| 67 |
+
relative = workspace_relative_path(source_path, workspace_dir=workspace_dir)
|
| 68 |
+
archive_files.append((relative.as_posix(), getattr(item, "content", b"") or b""))
|
| 69 |
+
|
| 70 |
+
return self.archive_store.save(archive_from_downloads(codebase_id, archive_files))
|
| 71 |
+
|
| 72 |
+
def restore_to_backend(
|
| 73 |
+
self,
|
| 74 |
+
archive_pointer: str,
|
| 75 |
+
backend: Any,
|
| 76 |
+
*,
|
| 77 |
+
workspace_dir: str,
|
| 78 |
+
) -> int:
|
| 79 |
+
if not supports_archive_pointer(archive_pointer):
|
| 80 |
+
raise ValueError(pointer_error(archive_pointer))
|
| 81 |
+
|
| 82 |
+
workspace = (self.root_dir or DEFAULT_CODEBASE_HANDOFF_ROOT) / restore_id(archive_pointer) / "workspace"
|
| 83 |
+
self.archive_store.materialize(archive_pointer, workspace)
|
| 84 |
+
files = [
|
| 85 |
+
(sandbox_workspace_path(workspace_dir, path.relative_to(workspace).as_posix()), path.read_bytes())
|
| 86 |
+
for path in workspace.rglob("*")
|
| 87 |
+
if path.is_file()
|
| 88 |
+
]
|
| 89 |
+
results = backend.upload_files(files)
|
| 90 |
+
for result in results:
|
| 91 |
+
if getattr(result, "error", None):
|
| 92 |
+
raise RuntimeError(
|
| 93 |
+
f"could not upload file: {getattr(result, 'path', '')}: {getattr(result, 'error', '')}"
|
| 94 |
+
)
|
| 95 |
+
return len(files)
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def local_snapshot_workspace(root_dir: Path, archive_pointer: str) -> Path | None:
|
| 99 |
+
snapshot_id = local_snapshot_id(archive_pointer)
|
| 100 |
+
if snapshot_id is None:
|
| 101 |
+
return None
|
| 102 |
+
return root_dir / snapshot_id / "workspace"
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def local_snapshot_id(archive_pointer: str) -> str | None:
|
| 106 |
+
if not archive_pointer.startswith(LOCAL_SNAPSHOT_SCHEME):
|
| 107 |
+
return None
|
| 108 |
+
snapshot_id = archive_pointer.removeprefix(LOCAL_SNAPSHOT_SCHEME)
|
| 109 |
+
if not CODEBASE_ID_PATTERN.fullmatch(snapshot_id):
|
| 110 |
+
return None
|
| 111 |
+
return snapshot_id
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def restore_id(archive_pointer: str) -> str:
|
| 115 |
+
if "://" not in archive_pointer:
|
| 116 |
+
raise ValueError("unsupported archive pointer")
|
| 117 |
+
snapshot_id = archive_pointer.split("://", 1)[1]
|
| 118 |
+
_validate_codebase_id(snapshot_id)
|
| 119 |
+
return snapshot_id
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def pointer_error(archive_pointer: str) -> str:
|
| 123 |
+
if archive_pointer.startswith(LOCAL_SNAPSHOT_SCHEME):
|
| 124 |
+
return "invalid snapshot id"
|
| 125 |
+
return "unsupported archive pointer"
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def normalize_workspace_dir(workspace_dir: str) -> str:
|
| 129 |
+
return workspace_dir.rstrip("/") or "/workspace"
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def workspace_relative_path(path: str, *, workspace_dir: str = "/workspace") -> Path:
|
| 133 |
+
prefix = f"{normalize_workspace_dir(workspace_dir)}/"
|
| 134 |
+
if not path.startswith(prefix):
|
| 135 |
+
raise ValueError(f"path outside workspace: {path}")
|
| 136 |
+
relative = path.removeprefix(prefix)
|
| 137 |
+
if not relative or ".." in Path(relative).parts:
|
| 138 |
+
raise ValueError(f"unsafe workspace path: {path}")
|
| 139 |
+
return Path(relative)
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def sandbox_workspace_path(workspace_dir: str, relative_path: str) -> str:
|
| 143 |
+
return f"{normalize_workspace_dir(workspace_dir)}/{relative_path}"
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def _validate_codebase_id(codebase_id: str) -> None:
|
| 147 |
+
if not CODEBASE_ID_PATTERN.fullmatch(codebase_id):
|
| 148 |
+
raise ValueError("invalid codebase id")
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
def _entry_path(entry: Any) -> str:
|
| 152 |
+
return str(entry.get("path", "") if isinstance(entry, dict) else getattr(entry, "path", ""))
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def _entry_is_dir(entry: Any) -> bool:
|
| 156 |
+
return bool(entry.get("is_dir") if isinstance(entry, dict) else getattr(entry, "is_dir", False))
|
arena/docker_backend.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Docker-based sandbox backend for isolated execution.
|
| 2 |
+
|
| 3 |
+
Uses ``subprocess`` to manage a single container, avoiding extra dependencies.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import json
|
| 9 |
+
import shlex
|
| 10 |
+
import subprocess
|
| 11 |
+
import tempfile
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
from typing import Any
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class DockerSandboxBackend:
|
| 17 |
+
"""A sandbox backend backed by a Docker container for process isolation."""
|
| 18 |
+
|
| 19 |
+
def __init__(
|
| 20 |
+
self,
|
| 21 |
+
image: str = "python:3.11-slim",
|
| 22 |
+
timeout_seconds: int = 20,
|
| 23 |
+
) -> None:
|
| 24 |
+
self._image = image
|
| 25 |
+
self._timeout = timeout_seconds
|
| 26 |
+
self._container_id: str | None = None
|
| 27 |
+
self._workspace_dir = "/workspace"
|
| 28 |
+
self._start_container()
|
| 29 |
+
|
| 30 |
+
def _start_container(self) -> None:
|
| 31 |
+
result = subprocess.run(
|
| 32 |
+
[
|
| 33 |
+
"docker", "run", "-d", "--rm",
|
| 34 |
+
"-w", "/workspace",
|
| 35 |
+
self._image,
|
| 36 |
+
"tail", "-f", "/dev/null",
|
| 37 |
+
],
|
| 38 |
+
capture_output=True, text=True, timeout=30,
|
| 39 |
+
check=False,
|
| 40 |
+
)
|
| 41 |
+
if result.returncode != 0:
|
| 42 |
+
stderr = result.stderr.strip()
|
| 43 |
+
if "docker" in stderr.lower() and ("not found" in stderr.lower() or "daemon" in stderr.lower()):
|
| 44 |
+
raise RuntimeError(
|
| 45 |
+
"Docker is not available. Install Docker or set DEEPAGENT_SANDBOX_PROVIDER=local."
|
| 46 |
+
)
|
| 47 |
+
raise RuntimeError(f"Failed to start Docker container: {stderr}")
|
| 48 |
+
self._container_id = result.stdout.strip()
|
| 49 |
+
self._exec("mkdir", "-p", self._workspace_dir)
|
| 50 |
+
|
| 51 |
+
def write(self, path: str, content: str) -> Any:
|
| 52 |
+
return self._copy_to(path, content.encode("utf-8"))
|
| 53 |
+
|
| 54 |
+
def read(self, path: str) -> Any:
|
| 55 |
+
data = self._copy_from(path)
|
| 56 |
+
content = data.decode("utf-8") if isinstance(data, bytes) else str(data)
|
| 57 |
+
return type("ReadResult", (), {"error": None, "file_data": {"content": content}})()
|
| 58 |
+
|
| 59 |
+
def ls(self, path: str) -> Any:
|
| 60 |
+
output = self._exec_output("find", path.rstrip("/"), "-maxdepth", "1", "-printf", "%p\t%y\n")
|
| 61 |
+
entries = []
|
| 62 |
+
for line in output.splitlines():
|
| 63 |
+
if not line.strip():
|
| 64 |
+
continue
|
| 65 |
+
parts = line.split("\t")
|
| 66 |
+
if len(parts) < 2:
|
| 67 |
+
continue
|
| 68 |
+
filepath, kind = parts[0], parts[1]
|
| 69 |
+
if filepath == path.rstrip("/"):
|
| 70 |
+
continue
|
| 71 |
+
entries.append({"path": filepath, "is_dir": kind == "d"})
|
| 72 |
+
return type("LsResult", (), {"error": None, "entries": entries})()
|
| 73 |
+
|
| 74 |
+
def execute(self, command: str, timeout: int | None = None) -> Any:
|
| 75 |
+
t = timeout or self._timeout
|
| 76 |
+
result = subprocess.run(
|
| 77 |
+
["docker", "exec", self._container_id, "sh", "-c", command],
|
| 78 |
+
capture_output=True, text=True, timeout=t,
|
| 79 |
+
check=False,
|
| 80 |
+
)
|
| 81 |
+
output = result.stdout
|
| 82 |
+
if result.stderr:
|
| 83 |
+
output = f"{output}\n{result.stderr}"
|
| 84 |
+
return type("ExecResult", (), {"error": None, "output": output, "exit_code": result.returncode})()
|
| 85 |
+
|
| 86 |
+
def glob(self, pattern: str) -> Any:
|
| 87 |
+
find_pattern = "/".join(
|
| 88 |
+
part.replace("**", ".").replace("*", "[^/]*") if part != "**" else part
|
| 89 |
+
for part in pattern.split("/")
|
| 90 |
+
)
|
| 91 |
+
output = self._exec_output("find", "/", "-path", pattern, "-not", "-type", "d")
|
| 92 |
+
matches = []
|
| 93 |
+
for line in output.splitlines():
|
| 94 |
+
line = line.strip()
|
| 95 |
+
if line:
|
| 96 |
+
matches.append({"path": line, "is_dir": False})
|
| 97 |
+
return type("GlobResult", (), {"error": None, "matches": matches})()
|
| 98 |
+
|
| 99 |
+
def download_files(self, paths: list[str]) -> list[Any]:
|
| 100 |
+
results = []
|
| 101 |
+
for path in paths:
|
| 102 |
+
try:
|
| 103 |
+
data = self._copy_from(path)
|
| 104 |
+
results.append(type("DownloadResult", (), {"error": None, "path": path, "content": data})())
|
| 105 |
+
except Exception as exc:
|
| 106 |
+
results.append(type("DownloadResult", (), {"error": str(exc), "path": path, "content": b""})())
|
| 107 |
+
return results
|
| 108 |
+
|
| 109 |
+
def upload_files(self, files: list[tuple[str, bytes]]) -> list[Any]:
|
| 110 |
+
results = []
|
| 111 |
+
for path, content in files:
|
| 112 |
+
try:
|
| 113 |
+
self._copy_to(path, content)
|
| 114 |
+
results.append(type("UploadResult", (), {"error": None, "path": path})())
|
| 115 |
+
except Exception as exc:
|
| 116 |
+
results.append(type("UploadResult", (), {"error": str(exc), "path": path})())
|
| 117 |
+
return results
|
| 118 |
+
|
| 119 |
+
def stop(self) -> None:
|
| 120 |
+
if self._container_id:
|
| 121 |
+
subprocess.run(
|
| 122 |
+
["docker", "rm", "-f", self._container_id],
|
| 123 |
+
capture_output=True, timeout=10, check=False,
|
| 124 |
+
)
|
| 125 |
+
self._container_id = None
|
| 126 |
+
|
| 127 |
+
def delete(self) -> None:
|
| 128 |
+
self.stop()
|
| 129 |
+
|
| 130 |
+
def _exec_output(self, *cmd: str) -> str:
|
| 131 |
+
result = subprocess.run(
|
| 132 |
+
["docker", "exec", self._container_id, *cmd],
|
| 133 |
+
capture_output=True, text=True, timeout=self._timeout,
|
| 134 |
+
check=False,
|
| 135 |
+
)
|
| 136 |
+
return result.stdout
|
| 137 |
+
|
| 138 |
+
def _exec(self, *cmd: str) -> None:
|
| 139 |
+
result = subprocess.run(
|
| 140 |
+
["docker", "exec", self._container_id, *cmd],
|
| 141 |
+
capture_output=True, text=True, timeout=self._timeout,
|
| 142 |
+
check=False,
|
| 143 |
+
)
|
| 144 |
+
if result.returncode != 0:
|
| 145 |
+
raise RuntimeError(f"Docker exec failed: {result.stderr.strip()}")
|
| 146 |
+
|
| 147 |
+
def _copy_to(self, container_path: str, content: bytes) -> Any:
|
| 148 |
+
with tempfile.NamedTemporaryFile(delete=False) as tmp:
|
| 149 |
+
tmp.write(content)
|
| 150 |
+
tmp_path = tmp.name
|
| 151 |
+
try:
|
| 152 |
+
result = subprocess.run(
|
| 153 |
+
["docker", "cp", tmp_path, f"{self._container_id}:{shlex.quote(container_path)}"],
|
| 154 |
+
capture_output=True, text=True, timeout=self._timeout,
|
| 155 |
+
check=False,
|
| 156 |
+
)
|
| 157 |
+
finally:
|
| 158 |
+
Path(tmp_path).unlink(missing_ok=True)
|
| 159 |
+
if result.returncode != 0:
|
| 160 |
+
raise RuntimeError(f"docker cp failed: {result.stderr.strip()}")
|
| 161 |
+
return type("WriteResult", (), {"error": None})()
|
| 162 |
+
|
| 163 |
+
def _copy_from(self, container_path: str) -> bytes:
|
| 164 |
+
with tempfile.NamedTemporaryFile(delete=False) as tmp:
|
| 165 |
+
tmp_path = tmp.name
|
| 166 |
+
try:
|
| 167 |
+
result = subprocess.run(
|
| 168 |
+
["docker", "cp", f"{self._container_id}:{shlex.quote(container_path)}", tmp_path],
|
| 169 |
+
capture_output=True, text=True, timeout=self._timeout,
|
| 170 |
+
check=False,
|
| 171 |
+
)
|
| 172 |
+
if result.returncode != 0:
|
| 173 |
+
raise RuntimeError(f"docker cp failed: {result.stderr.strip()}")
|
| 174 |
+
return Path(tmp_path).read_bytes()
|
| 175 |
+
finally:
|
| 176 |
+
Path(tmp_path).unlink(missing_ok=True)
|
| 177 |
+
|
| 178 |
+
def _workspace_dir(self) -> str:
|
| 179 |
+
return self._workspace_dir
|
arena/event_bus.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""In-process event bus for Run event streaming."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import queue
|
| 6 |
+
from typing import Any, Callable
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
EventListener = Callable[[str, dict[str, Any]], None]
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class EventBus:
|
| 13 |
+
def __init__(self) -> None:
|
| 14 |
+
self._queues: dict[str, queue.Queue[dict[str, Any] | None]] = {}
|
| 15 |
+
self._listeners: dict[str, list[EventListener]] = {}
|
| 16 |
+
|
| 17 |
+
def subscribe(self, run_id: str) -> queue.Queue[dict[str, Any] | None]:
|
| 18 |
+
q: queue.Queue[dict[str, Any] | None] = queue.Queue()
|
| 19 |
+
self._queues[run_id] = q
|
| 20 |
+
return q
|
| 21 |
+
|
| 22 |
+
def publish(self, run_id: str, event: dict[str, Any]) -> None:
|
| 23 |
+
for listener in self._listeners.get(run_id, []):
|
| 24 |
+
listener(run_id, event)
|
| 25 |
+
q = self._queues.get(run_id)
|
| 26 |
+
if q is not None:
|
| 27 |
+
q.put(event)
|
| 28 |
+
|
| 29 |
+
def close(self, run_id: str) -> None:
|
| 30 |
+
self._listeners.pop(run_id, None)
|
| 31 |
+
q = self._queues.pop(run_id, None)
|
| 32 |
+
if q is not None:
|
| 33 |
+
q.put(None)
|
| 34 |
+
|
| 35 |
+
def add_listener(self, run_id: str, listener: EventListener) -> None:
|
| 36 |
+
self._listeners.setdefault(run_id, []).append(listener)
|
arena/gradio_app.py
ADDED
|
@@ -0,0 +1,1141 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Prompt-first Gradio surface for Backyard Demo Builder."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import gradio as gr
|
| 6 |
+
|
| 7 |
+
from .gradio_markup import FOOTER_HTML, HERO_HTML, RAIL_HTML, TOPBAR_HTML
|
| 8 |
+
from .gradio_presenter import RunOutputs, UiState, empty_outputs, run_outputs
|
| 9 |
+
from .backyard_templates import template_by_label, template_choices
|
| 10 |
+
from .gradio_run import (
|
| 11 |
+
DEFAULT_BACKYARD_CHECKS,
|
| 12 |
+
DEFAULT_BACKYARD_CRITERIA,
|
| 13 |
+
DEFAULT_BACKYARD_PROMPT,
|
| 14 |
+
run_request_from_text,
|
| 15 |
+
)
|
| 16 |
+
from .model_catalog import ModelProviderConfig, eligible_models
|
| 17 |
+
from .service import ArenaService
|
| 18 |
+
|
| 19 |
+
HOSTED_MODEL_PROVIDERS = {"openrouter", "gemini", "nebius", "huggingface", "hf"}
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
try:
|
| 23 |
+
import spaces
|
| 24 |
+
except Exception:
|
| 25 |
+
class _SpacesShim:
|
| 26 |
+
def GPU(self, fn=None, **kwargs):
|
| 27 |
+
del kwargs
|
| 28 |
+
|
| 29 |
+
def decorator(inner):
|
| 30 |
+
return inner
|
| 31 |
+
|
| 32 |
+
return decorator(fn) if fn else decorator
|
| 33 |
+
|
| 34 |
+
spaces = _SpacesShim()
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class GradioArena:
|
| 38 |
+
def __init__(self, service: ArenaService | None = None) -> None:
|
| 39 |
+
self.service = service or ArenaService()
|
| 40 |
+
|
| 41 |
+
def create_run(
|
| 42 |
+
self,
|
| 43 |
+
prompt: str,
|
| 44 |
+
criteria_text: str,
|
| 45 |
+
user_tests_text: str,
|
| 46 |
+
provider: str = "openrouter",
|
| 47 |
+
model: str = "",
|
| 48 |
+
api_key: str = "",
|
| 49 |
+
base_url: str = "",
|
| 50 |
+
) -> RunOutputs:
|
| 51 |
+
provider = provider or "openrouter"
|
| 52 |
+
model = model or ""
|
| 53 |
+
api_key = api_key or ""
|
| 54 |
+
base_url = base_url or ""
|
| 55 |
+
if _requires_user_key(provider) and not api_key.strip():
|
| 56 |
+
return self._empty(
|
| 57 |
+
UiState(),
|
| 58 |
+
"Choose a fetched model and enter your own API key. Server API keys are not used by default.",
|
| 59 |
+
)
|
| 60 |
+
request = run_request_from_text(prompt, criteria_text, user_tests_text, provider, model, api_key, base_url)
|
| 61 |
+
if _is_local_zerogpu_provider(provider, base_url):
|
| 62 |
+
access = self.service.create_run(request)
|
| 63 |
+
else:
|
| 64 |
+
access = self.service.start_run(request)
|
| 65 |
+
state = UiState(role="run", run_id=access.run.id)
|
| 66 |
+
return self._run_outputs(state)
|
| 67 |
+
|
| 68 |
+
def rejoin_run(self, token: str) -> RunOutputs:
|
| 69 |
+
access = self.service.rejoin_run(token.strip())
|
| 70 |
+
state = UiState(role="run", run_id=access.run.id)
|
| 71 |
+
return self._run_outputs(state)
|
| 72 |
+
|
| 73 |
+
def cancel_run(self, state: UiState) -> RunOutputs:
|
| 74 |
+
if state.role != "run" or not state.run_id:
|
| 75 |
+
return self._empty(state, "No active run")
|
| 76 |
+
self.service.cancel_run(state.run_id)
|
| 77 |
+
return self._run_outputs(state)
|
| 78 |
+
|
| 79 |
+
def send_message(self, state: UiState, message: str) -> RunOutputs:
|
| 80 |
+
if state.role != "run" or not state.run_id:
|
| 81 |
+
return self._empty(state, "No active run")
|
| 82 |
+
if message.strip():
|
| 83 |
+
self.service.add_run_message(state.run_id, message.strip())
|
| 84 |
+
return self._run_outputs(state)
|
| 85 |
+
|
| 86 |
+
def record_feedback(
|
| 87 |
+
self,
|
| 88 |
+
state: UiState,
|
| 89 |
+
tester: str,
|
| 90 |
+
quote: str,
|
| 91 |
+
decision: str,
|
| 92 |
+
notes: str,
|
| 93 |
+
) -> RunOutputs:
|
| 94 |
+
if state.role != "run" or not state.run_id:
|
| 95 |
+
return self._empty(state, "No active run")
|
| 96 |
+
parts = [
|
| 97 |
+
"Mom Test feedback",
|
| 98 |
+
f"Tester: {tester.strip() or 'Mom'}",
|
| 99 |
+
f"Decision: {decision.strip() or 'undecided'}",
|
| 100 |
+
]
|
| 101 |
+
if quote.strip():
|
| 102 |
+
parts.append(f"Quote: {quote.strip()}")
|
| 103 |
+
if notes.strip():
|
| 104 |
+
parts.append(f"Notes: {notes.strip()}")
|
| 105 |
+
self.service.add_run_message(state.run_id, "\n".join(parts))
|
| 106 |
+
return self._run_outputs(state)
|
| 107 |
+
|
| 108 |
+
def refresh(self, state: UiState) -> RunOutputs:
|
| 109 |
+
if state.role != "run" or not state.run_id:
|
| 110 |
+
return self._empty(state, "No active run")
|
| 111 |
+
return self._run_outputs(state)
|
| 112 |
+
|
| 113 |
+
def refresh_models(self, provider: str | None, api_key: str | None, base_url: str | None):
|
| 114 |
+
api_key = api_key or ""
|
| 115 |
+
base_url = base_url or ""
|
| 116 |
+
models = self.service.refresh_provider_models(
|
| 117 |
+
ModelProviderConfig(
|
| 118 |
+
provider=provider or "openrouter",
|
| 119 |
+
api_key=api_key.strip() or None,
|
| 120 |
+
base_url=base_url.strip(),
|
| 121 |
+
)
|
| 122 |
+
)
|
| 123 |
+
selectable_models = list(eligible_models(models))
|
| 124 |
+
choices = [model.id for model in selectable_models]
|
| 125 |
+
value = choices[0] if choices else None
|
| 126 |
+
return gr.Dropdown(choices=choices, value=value), [model.model_dump() for model in selectable_models]
|
| 127 |
+
|
| 128 |
+
def apply_template(self, label: str) -> tuple[str, str, str]:
|
| 129 |
+
template = template_by_label(label)
|
| 130 |
+
return template.prompt, template.criteria, template.checks
|
| 131 |
+
|
| 132 |
+
def _run_outputs(self, state: UiState) -> RunOutputs:
|
| 133 |
+
run = self.service.get_run(state.run_id)
|
| 134 |
+
return run_outputs(state, run)
|
| 135 |
+
|
| 136 |
+
def _empty(self, state: UiState, message: str) -> RunOutputs:
|
| 137 |
+
return empty_outputs(state, message)
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def _requires_user_key(provider: str) -> bool:
|
| 141 |
+
return provider.strip().lower().replace("_", "-") in HOSTED_MODEL_PROVIDERS
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
@spaces.GPU(duration=1)
|
| 145 |
+
def zerogpu_ready_marker() -> str:
|
| 146 |
+
return "ready"
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def _is_local_zerogpu_provider(provider: str, base_url: str) -> bool:
|
| 150 |
+
return provider.strip().lower().replace("_", "-") == "local" and not base_url.strip()
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
THEME = gr.themes.Base(
|
| 154 |
+
primary_hue="neutral",
|
| 155 |
+
secondary_hue="neutral",
|
| 156 |
+
neutral_hue="neutral",
|
| 157 |
+
font=["Geist", "Inter", "system-ui", "sans-serif"],
|
| 158 |
+
font_mono=["IBM Plex Mono", "SFMono-Regular", "ui-monospace", "monospace"],
|
| 159 |
+
).set(
|
| 160 |
+
body_background_fill="#0a0a0a",
|
| 161 |
+
body_background_fill_dark="#0a0a0a",
|
| 162 |
+
block_background_fill="#111113",
|
| 163 |
+
block_background_fill_dark="#111113",
|
| 164 |
+
block_border_width="1px",
|
| 165 |
+
block_border_color="#27272a",
|
| 166 |
+
block_border_color_dark="#27272a",
|
| 167 |
+
block_title_text_color="#fafafa",
|
| 168 |
+
block_title_text_color_dark="#fafafa",
|
| 169 |
+
block_label_text_color="#a1a1aa",
|
| 170 |
+
block_label_text_color_dark="#a1a1aa",
|
| 171 |
+
input_background_fill="#0a0a0a",
|
| 172 |
+
input_background_fill_dark="#0a0a0a",
|
| 173 |
+
input_border_color="#27272a",
|
| 174 |
+
input_border_color_dark="#27272a",
|
| 175 |
+
input_radius="0px",
|
| 176 |
+
button_primary_background_fill="#fafafa",
|
| 177 |
+
button_primary_background_fill_hover="#e4e4e7",
|
| 178 |
+
button_primary_border_color="#fafafa",
|
| 179 |
+
button_primary_text_color="#0a0a0a",
|
| 180 |
+
button_secondary_background_fill="#0a0a0a",
|
| 181 |
+
button_secondary_background_fill_hover="#1f1f23",
|
| 182 |
+
button_secondary_border_color="#3f3f46",
|
| 183 |
+
button_secondary_text_color="#fafafa",
|
| 184 |
+
button_cancel_background_fill="#0a0a0a",
|
| 185 |
+
button_cancel_background_fill_hover="#1f1f23",
|
| 186 |
+
button_cancel_border_color="#3f3f46",
|
| 187 |
+
button_cancel_text_color="#fafafa",
|
| 188 |
+
button_large_radius="0px",
|
| 189 |
+
checkbox_label_text_color="#fafafa",
|
| 190 |
+
checkbox_background_color="#0a0a0a",
|
| 191 |
+
checkbox_border_color="#3f3f46",
|
| 192 |
+
)
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
CSS = """
|
| 196 |
+
:root {
|
| 197 |
+
--bg: #0a0a0a;
|
| 198 |
+
--panel: #111113;
|
| 199 |
+
--panel-soft: #18181b;
|
| 200 |
+
--panel-strong: #050505;
|
| 201 |
+
--ink: #fafafa;
|
| 202 |
+
--ink-soft: #e4e4e7;
|
| 203 |
+
--muted: #a1a1aa;
|
| 204 |
+
--faint: #71717a;
|
| 205 |
+
--rule: #27272a;
|
| 206 |
+
--rule-strong: #3f3f46;
|
| 207 |
+
--sans: "Geist", "Inter", system-ui, -apple-system, sans-serif;
|
| 208 |
+
--mono: "IBM Plex Mono", "SFMono-Regular", ui-monospace, monospace;
|
| 209 |
+
|
| 210 |
+
--container-pad: clamp(24px, 3vw, 56px);
|
| 211 |
+
--container-pad-sm: 20px;
|
| 212 |
+
--section-gap: clamp(32px, 5vw, 96px);
|
| 213 |
+
--hero-py-top: clamp(80px, 10vw, 120px);
|
| 214 |
+
--hero-py-bottom: clamp(56px, 6vw, 80px);
|
| 215 |
+
--hero-cols: minmax(0, 1.4fr) minmax(280px, 380px);
|
| 216 |
+
--hero-grid-size: 96px 100%;
|
| 217 |
+
--hero-grid-line: rgba(255, 255, 255, 0.022);
|
| 218 |
+
--hero-title-size: clamp(44px, 5.4vw, 96px);
|
| 219 |
+
--workbench-cols: minmax(0, 1.6fr) minmax(320px, 380px) minmax(0, 1fr);
|
| 220 |
+
--workbench-cols-md: minmax(0, 1.5fr) minmax(320px, 360px);
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
footer:not(.footer) { display: none !important; }
|
| 224 |
+
|
| 225 |
+
html, body {
|
| 226 |
+
background: var(--bg) !important;
|
| 227 |
+
color: var(--ink) !important;
|
| 228 |
+
font-family: var(--sans) !important;
|
| 229 |
+
font-feature-settings: "ss01", "cv11";
|
| 230 |
+
-webkit-font-smoothing: antialiased;
|
| 231 |
+
-moz-osx-font-smoothing: grayscale;
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
* { letter-spacing: 0 !important; }
|
| 235 |
+
|
| 236 |
+
.gradio-container {
|
| 237 |
+
max-width: 100% !important;
|
| 238 |
+
background: var(--bg) !important;
|
| 239 |
+
padding: 0 var(--container-pad) !important;
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
#run-shell {
|
| 243 |
+
padding: 0 !important;
|
| 244 |
+
background: var(--bg) !important;
|
| 245 |
+
max-width: 100% !important;
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
.block,
|
| 249 |
+
.form,
|
| 250 |
+
.panel,
|
| 251 |
+
.gr-box {
|
| 252 |
+
border-radius: 0 !important;
|
| 253 |
+
box-shadow: none !important;
|
| 254 |
+
background: var(--panel) !important;
|
| 255 |
+
border-color: var(--rule) !important;
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
.contain { border-radius: 0 !important; }
|
| 259 |
+
|
| 260 |
+
label,
|
| 261 |
+
.gr-textbox label,
|
| 262 |
+
.gr-dataframe label,
|
| 263 |
+
.gr-button {
|
| 264 |
+
font-family: var(--mono) !important;
|
| 265 |
+
color: var(--muted) !important;
|
| 266 |
+
text-transform: uppercase !important;
|
| 267 |
+
font-size: 10.5px !important;
|
| 268 |
+
font-weight: 600 !important;
|
| 269 |
+
letter-spacing: 0.14em !important;
|
| 270 |
+
}
|
| 271 |
+
|
| 272 |
+
label { color: var(--muted) !important; }
|
| 273 |
+
|
| 274 |
+
textarea,
|
| 275 |
+
input {
|
| 276 |
+
color: var(--ink) !important;
|
| 277 |
+
background: var(--panel) !important;
|
| 278 |
+
font-family: var(--mono) !important;
|
| 279 |
+
font-size: 13px !important;
|
| 280 |
+
line-height: 1.55 !important;
|
| 281 |
+
border: 1px solid var(--rule) !important;
|
| 282 |
+
}
|
| 283 |
+
|
| 284 |
+
textarea::placeholder,
|
| 285 |
+
input::placeholder {
|
| 286 |
+
color: var(--faint) !important;
|
| 287 |
+
opacity: 1 !important;
|
| 288 |
+
font-family: var(--mono) !important;
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
+
textarea:focus,
|
| 292 |
+
input:focus {
|
| 293 |
+
outline: none !important;
|
| 294 |
+
border-color: var(--ink) !important;
|
| 295 |
+
box-shadow: 0 0 0 1px var(--ink) !important;
|
| 296 |
+
}
|
| 297 |
+
|
| 298 |
+
button {
|
| 299 |
+
background: var(--panel) !important;
|
| 300 |
+
color: var(--ink) !important;
|
| 301 |
+
border-radius: 0 !important;
|
| 302 |
+
border: 1px solid var(--rule-strong) !important;
|
| 303 |
+
box-shadow: none !important;
|
| 304 |
+
font-family: var(--mono) !important;
|
| 305 |
+
font-weight: 600 !important;
|
| 306 |
+
letter-spacing: 0.14em !important;
|
| 307 |
+
text-transform: uppercase !important;
|
| 308 |
+
font-size: 11px !important;
|
| 309 |
+
transition:
|
| 310 |
+
background 120ms ease,
|
| 311 |
+
color 120ms ease,
|
| 312 |
+
border-color 120ms ease,
|
| 313 |
+
transform 120ms ease !important;
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
+
button:hover {
|
| 317 |
+
background: var(--ink) !important;
|
| 318 |
+
color: var(--bg) !important;
|
| 319 |
+
border-color: var(--ink) !important;
|
| 320 |
+
}
|
| 321 |
+
|
| 322 |
+
button:active { transform: translateY(1px); }
|
| 323 |
+
|
| 324 |
+
#start-run-btn {
|
| 325 |
+
min-height: 52px !important;
|
| 326 |
+
background: var(--ink) !important;
|
| 327 |
+
color: var(--bg) !important;
|
| 328 |
+
border-color: var(--ink) !important;
|
| 329 |
+
font-size: 12px !important;
|
| 330 |
+
}
|
| 331 |
+
|
| 332 |
+
#start-run-btn:hover {
|
| 333 |
+
background: var(--ink-soft) !important;
|
| 334 |
+
border-color: var(--ink-soft) !important;
|
| 335 |
+
}
|
| 336 |
+
|
| 337 |
+
#cancel-run-btn,
|
| 338 |
+
#refresh-run-btn,
|
| 339 |
+
#send-message-btn {
|
| 340 |
+
min-height: 40px !important;
|
| 341 |
+
}
|
| 342 |
+
|
| 343 |
+
.dataframe,
|
| 344 |
+
table {
|
| 345 |
+
font-family: var(--mono) !important;
|
| 346 |
+
font-size: 11.5px !important;
|
| 347 |
+
background: var(--panel) !important;
|
| 348 |
+
color: var(--ink) !important;
|
| 349 |
+
border-color: var(--rule) !important;
|
| 350 |
+
}
|
| 351 |
+
|
| 352 |
+
.dataframe thead,
|
| 353 |
+
table thead {
|
| 354 |
+
background: var(--panel-soft) !important;
|
| 355 |
+
color: var(--muted) !important;
|
| 356 |
+
text-transform: uppercase !important;
|
| 357 |
+
letter-spacing: 0.12em !important;
|
| 358 |
+
font-size: 10.5px !important;
|
| 359 |
+
font-weight: 600 !important;
|
| 360 |
+
}
|
| 361 |
+
|
| 362 |
+
.dataframe td,
|
| 363 |
+
.dataframe th,
|
| 364 |
+
table td,
|
| 365 |
+
table th {
|
| 366 |
+
border-color: var(--rule) !important;
|
| 367 |
+
padding: 8px 12px !important;
|
| 368 |
+
}
|
| 369 |
+
|
| 370 |
+
.topbar {
|
| 371 |
+
display: flex;
|
| 372 |
+
align-items: center;
|
| 373 |
+
justify-content: space-between;
|
| 374 |
+
height: 64px;
|
| 375 |
+
padding: 0 var(--container-pad);
|
| 376 |
+
border-bottom: 1px solid var(--rule);
|
| 377 |
+
background: var(--bg);
|
| 378 |
+
}
|
| 379 |
+
|
| 380 |
+
.topbar .wordmark {
|
| 381 |
+
margin: 0;
|
| 382 |
+
font-family: var(--mono);
|
| 383 |
+
font-size: 11px;
|
| 384 |
+
font-weight: 600;
|
| 385 |
+
text-transform: uppercase;
|
| 386 |
+
letter-spacing: 0.18em;
|
| 387 |
+
color: var(--ink);
|
| 388 |
+
}
|
| 389 |
+
|
| 390 |
+
.topbar .wordmark-dot {
|
| 391 |
+
display: inline-block;
|
| 392 |
+
width: 6px;
|
| 393 |
+
height: 6px;
|
| 394 |
+
background: var(--ink);
|
| 395 |
+
margin-right: 10px;
|
| 396 |
+
vertical-align: middle;
|
| 397 |
+
}
|
| 398 |
+
|
| 399 |
+
.topbar .topbar-meta {
|
| 400 |
+
margin: 0;
|
| 401 |
+
font-family: var(--mono);
|
| 402 |
+
font-size: 10.5px;
|
| 403 |
+
text-transform: uppercase;
|
| 404 |
+
letter-spacing: 0.18em;
|
| 405 |
+
color: var(--faint);
|
| 406 |
+
}
|
| 407 |
+
|
| 408 |
+
#status-bar {
|
| 409 |
+
position: fixed !important;
|
| 410 |
+
top: 16px;
|
| 411 |
+
right: var(--container-pad);
|
| 412 |
+
z-index: 100;
|
| 413 |
+
display: flex;
|
| 414 |
+
align-items: center;
|
| 415 |
+
justify-content: flex-end;
|
| 416 |
+
gap: 8px;
|
| 417 |
+
background: transparent !important;
|
| 418 |
+
border: 0 !important;
|
| 419 |
+
padding: 6px 10px !important;
|
| 420 |
+
width: max-content !important;
|
| 421 |
+
max-width: 280px !important;
|
| 422 |
+
box-shadow: none !important;
|
| 423 |
+
margin: 0 !important;
|
| 424 |
+
}
|
| 425 |
+
|
| 426 |
+
#status-bar:not(:has(.run-status-chip)) { display: none !important; }
|
| 427 |
+
|
| 428 |
+
.hero {
|
| 429 |
+
display: grid;
|
| 430 |
+
grid-template-columns: var(--hero-cols);
|
| 431 |
+
gap: var(--section-gap);
|
| 432 |
+
align-items: end;
|
| 433 |
+
padding: var(--hero-py-top) var(--container-pad) var(--hero-py-bottom);
|
| 434 |
+
border-bottom: 1px solid var(--rule);
|
| 435 |
+
background-image:
|
| 436 |
+
linear-gradient(to right, var(--hero-grid-line) 1px, transparent 1px);
|
| 437 |
+
background-size: var(--hero-grid-size);
|
| 438 |
+
background-position: 0 0;
|
| 439 |
+
}
|
| 440 |
+
|
| 441 |
+
.hero-main {
|
| 442 |
+
display: flex;
|
| 443 |
+
flex-direction: column;
|
| 444 |
+
gap: clamp(20px, 2vw, 32px);
|
| 445 |
+
}
|
| 446 |
+
|
| 447 |
+
.hero-side {
|
| 448 |
+
padding-bottom: 12px;
|
| 449 |
+
}
|
| 450 |
+
|
| 451 |
+
.hero-marks {
|
| 452 |
+
list-style: none;
|
| 453 |
+
margin: 0;
|
| 454 |
+
padding: 0;
|
| 455 |
+
display: flex;
|
| 456 |
+
flex-direction: column;
|
| 457 |
+
gap: 0;
|
| 458 |
+
border-top: 1px solid var(--rule);
|
| 459 |
+
}
|
| 460 |
+
|
| 461 |
+
.hero-marks li {
|
| 462 |
+
display: grid;
|
| 463 |
+
grid-template-columns: 56px 1fr;
|
| 464 |
+
gap: 16px;
|
| 465 |
+
align-items: baseline;
|
| 466 |
+
padding: 18px 0;
|
| 467 |
+
border-bottom: 1px solid var(--rule);
|
| 468 |
+
}
|
| 469 |
+
|
| 470 |
+
.mark-num {
|
| 471 |
+
font-family: var(--mono);
|
| 472 |
+
font-size: 10.5px;
|
| 473 |
+
letter-spacing: 0.18em;
|
| 474 |
+
color: var(--faint);
|
| 475 |
+
text-transform: uppercase;
|
| 476 |
+
}
|
| 477 |
+
|
| 478 |
+
.mark-text {
|
| 479 |
+
font-family: var(--mono);
|
| 480 |
+
font-size: 12.5px;
|
| 481 |
+
line-height: 1.5;
|
| 482 |
+
color: var(--ink);
|
| 483 |
+
}
|
| 484 |
+
|
| 485 |
+
.hero .hero-title {
|
| 486 |
+
margin: 0;
|
| 487 |
+
max-width: 980px;
|
| 488 |
+
font-family: var(--sans);
|
| 489 |
+
font-size: var(--hero-title-size) !important;
|
| 490 |
+
line-height: 0.96 !important;
|
| 491 |
+
font-weight: 700 !important;
|
| 492 |
+
letter-spacing: -0.025em;
|
| 493 |
+
color: var(--ink);
|
| 494 |
+
}
|
| 495 |
+
|
| 496 |
+
.hero .hero-title .line {
|
| 497 |
+
display: block;
|
| 498 |
+
}
|
| 499 |
+
|
| 500 |
+
.hero .hero-title .line-accent {
|
| 501 |
+
color: var(--muted);
|
| 502 |
+
font-style: normal;
|
| 503 |
+
}
|
| 504 |
+
|
| 505 |
+
.hero .hero-subtext {
|
| 506 |
+
max-width: 620px;
|
| 507 |
+
margin: 0;
|
| 508 |
+
font-family: var(--mono);
|
| 509 |
+
font-size: 13.5px;
|
| 510 |
+
line-height: 1.7;
|
| 511 |
+
color: var(--muted);
|
| 512 |
+
}
|
| 513 |
+
|
| 514 |
+
.workbench {
|
| 515 |
+
display: grid;
|
| 516 |
+
grid-template-columns: var(--workbench-cols);
|
| 517 |
+
gap: 0;
|
| 518 |
+
border-bottom: 1px solid var(--rule);
|
| 519 |
+
}
|
| 520 |
+
|
| 521 |
+
.workbench .workbench-input {
|
| 522 |
+
padding: 40px var(--container-pad) 40px var(--container-pad);
|
| 523 |
+
border-right: 1px solid var(--rule);
|
| 524 |
+
}
|
| 525 |
+
|
| 526 |
+
.workbench .workbench-dispatch {
|
| 527 |
+
padding: 40px clamp(20px, 2vw, 36px);
|
| 528 |
+
display: flex;
|
| 529 |
+
flex-direction: column;
|
| 530 |
+
gap: 20px;
|
| 531 |
+
background: var(--panel-soft);
|
| 532 |
+
border-right: 1px solid var(--rule);
|
| 533 |
+
}
|
| 534 |
+
|
| 535 |
+
.workbench .workbench-rail {
|
| 536 |
+
padding: 40px var(--container-pad) 40px clamp(20px, 2vw, 36px);
|
| 537 |
+
display: flex;
|
| 538 |
+
flex-direction: column;
|
| 539 |
+
gap: 16px;
|
| 540 |
+
background: var(--bg);
|
| 541 |
+
}
|
| 542 |
+
|
| 543 |
+
.rail-block {
|
| 544 |
+
display: flex;
|
| 545 |
+
flex-direction: column;
|
| 546 |
+
gap: 10px;
|
| 547 |
+
padding: 16px 18px;
|
| 548 |
+
border: 1px solid var(--rule);
|
| 549 |
+
background: var(--panel);
|
| 550 |
+
}
|
| 551 |
+
|
| 552 |
+
.rail-grid {
|
| 553 |
+
display: flex;
|
| 554 |
+
flex-direction: column;
|
| 555 |
+
gap: 8px;
|
| 556 |
+
}
|
| 557 |
+
|
| 558 |
+
.rail-row {
|
| 559 |
+
display: flex;
|
| 560 |
+
align-items: center;
|
| 561 |
+
justify-content: space-between;
|
| 562 |
+
gap: 12px;
|
| 563 |
+
font-family: var(--mono);
|
| 564 |
+
font-size: 11px;
|
| 565 |
+
color: var(--muted);
|
| 566 |
+
}
|
| 567 |
+
|
| 568 |
+
.rail-label {
|
| 569 |
+
text-transform: uppercase;
|
| 570 |
+
letter-spacing: 0.14em;
|
| 571 |
+
font-size: 10px;
|
| 572 |
+
color: var(--faint);
|
| 573 |
+
}
|
| 574 |
+
|
| 575 |
+
.rail-value {
|
| 576 |
+
color: var(--ink);
|
| 577 |
+
text-align: right;
|
| 578 |
+
}
|
| 579 |
+
|
| 580 |
+
.rail-kbd {
|
| 581 |
+
color: var(--ink);
|
| 582 |
+
font-family: var(--mono);
|
| 583 |
+
font-size: 10.5px;
|
| 584 |
+
text-align: right;
|
| 585 |
+
letter-spacing: 0.04em;
|
| 586 |
+
}
|
| 587 |
+
|
| 588 |
+
.rail-status-block {
|
| 589 |
+
gap: 8px;
|
| 590 |
+
}
|
| 591 |
+
|
| 592 |
+
.rail-status-text {
|
| 593 |
+
margin: 0;
|
| 594 |
+
font-family: var(--mono);
|
| 595 |
+
font-size: 11.5px;
|
| 596 |
+
line-height: 1.6;
|
| 597 |
+
color: var(--muted);
|
| 598 |
+
}
|
| 599 |
+
|
| 600 |
+
.field-stack {
|
| 601 |
+
display: flex;
|
| 602 |
+
flex-direction: column;
|
| 603 |
+
gap: 24px;
|
| 604 |
+
}
|
| 605 |
+
|
| 606 |
+
.field-pair {
|
| 607 |
+
display: grid;
|
| 608 |
+
grid-template-columns: 1fr 1fr;
|
| 609 |
+
gap: 20px;
|
| 610 |
+
}
|
| 611 |
+
|
| 612 |
+
.dispatch-stack {
|
| 613 |
+
display: flex;
|
| 614 |
+
flex-direction: column;
|
| 615 |
+
gap: 10px;
|
| 616 |
+
}
|
| 617 |
+
|
| 618 |
+
.dispatch-divider {
|
| 619 |
+
height: 1px;
|
| 620 |
+
background: var(--rule);
|
| 621 |
+
margin: 6px 0;
|
| 622 |
+
}
|
| 623 |
+
|
| 624 |
+
.dispatch-row {
|
| 625 |
+
display: grid;
|
| 626 |
+
grid-template-columns: 1fr auto;
|
| 627 |
+
gap: 8px;
|
| 628 |
+
}
|
| 629 |
+
|
| 630 |
+
.dispatch-meta {
|
| 631 |
+
margin: 0;
|
| 632 |
+
font-family: var(--mono);
|
| 633 |
+
font-size: 10px;
|
| 634 |
+
text-transform: uppercase;
|
| 635 |
+
letter-spacing: 0.18em;
|
| 636 |
+
color: var(--faint);
|
| 637 |
+
}
|
| 638 |
+
|
| 639 |
+
#status-panel:not(:has(.run-status-chip)) { display: none; }
|
| 640 |
+
#download-area:not(:has(.download-button)) { display: none; }
|
| 641 |
+
|
| 642 |
+
#download-area {
|
| 643 |
+
border-top: 1px solid var(--rule);
|
| 644 |
+
padding: var(--container-pad);
|
| 645 |
+
background: var(--bg);
|
| 646 |
+
}
|
| 647 |
+
|
| 648 |
+
#status-panel {
|
| 649 |
+
min-height: 88px;
|
| 650 |
+
padding: 20px var(--container-pad);
|
| 651 |
+
border-bottom: 1px solid var(--rule);
|
| 652 |
+
background: var(--panel);
|
| 653 |
+
display: flex;
|
| 654 |
+
align-items: center;
|
| 655 |
+
flex-wrap: wrap;
|
| 656 |
+
gap: 10px;
|
| 657 |
+
}
|
| 658 |
+
|
| 659 |
+
.rail-status-block #status-panel {
|
| 660 |
+
min-height: 0;
|
| 661 |
+
padding: 0;
|
| 662 |
+
border-bottom: 0;
|
| 663 |
+
background: transparent;
|
| 664 |
+
}
|
| 665 |
+
|
| 666 |
+
.run-status-chip {
|
| 667 |
+
display: inline-flex;
|
| 668 |
+
align-items: center;
|
| 669 |
+
min-height: 26px;
|
| 670 |
+
padding: 4px 10px;
|
| 671 |
+
border: 1px solid var(--ink);
|
| 672 |
+
background: var(--ink);
|
| 673 |
+
color: var(--bg);
|
| 674 |
+
font-family: var(--mono);
|
| 675 |
+
font-size: 10.5px;
|
| 676 |
+
font-weight: 700;
|
| 677 |
+
text-transform: uppercase;
|
| 678 |
+
letter-spacing: 0.16em;
|
| 679 |
+
}
|
| 680 |
+
|
| 681 |
+
.run-status-chip.is-live {
|
| 682 |
+
background: var(--bg);
|
| 683 |
+
color: var(--ink);
|
| 684 |
+
border-color: var(--ink);
|
| 685 |
+
position: relative;
|
| 686 |
+
padding-left: 22px;
|
| 687 |
+
}
|
| 688 |
+
|
| 689 |
+
.run-status-chip.is-live::before {
|
| 690 |
+
content: "";
|
| 691 |
+
position: absolute;
|
| 692 |
+
left: 10px;
|
| 693 |
+
top: 50%;
|
| 694 |
+
width: 6px;
|
| 695 |
+
height: 6px;
|
| 696 |
+
background: var(--ink);
|
| 697 |
+
transform: translateY(-50%);
|
| 698 |
+
animation: live-pulse 1.6s ease-in-out infinite;
|
| 699 |
+
}
|
| 700 |
+
|
| 701 |
+
@keyframes live-pulse {
|
| 702 |
+
0%, 100% { opacity: 1; }
|
| 703 |
+
50% { opacity: 0.35; }
|
| 704 |
+
}
|
| 705 |
+
|
| 706 |
+
.validation-chip {
|
| 707 |
+
border-color: var(--rule-strong);
|
| 708 |
+
background: var(--bg);
|
| 709 |
+
color: var(--ink);
|
| 710 |
+
}
|
| 711 |
+
|
| 712 |
+
.run-metric {
|
| 713 |
+
display: inline-flex;
|
| 714 |
+
align-items: center;
|
| 715 |
+
min-height: 26px;
|
| 716 |
+
padding: 4px 10px;
|
| 717 |
+
border: 1px solid var(--rule);
|
| 718 |
+
color: var(--muted);
|
| 719 |
+
font-family: var(--mono);
|
| 720 |
+
font-size: 10.5px;
|
| 721 |
+
font-weight: 600;
|
| 722 |
+
text-transform: uppercase;
|
| 723 |
+
letter-spacing: 0.16em;
|
| 724 |
+
}
|
| 725 |
+
|
| 726 |
+
.run-summary {
|
| 727 |
+
flex-basis: 100%;
|
| 728 |
+
margin-top: 14px;
|
| 729 |
+
color: var(--muted);
|
| 730 |
+
font-family: var(--mono);
|
| 731 |
+
font-size: 11.5px;
|
| 732 |
+
line-height: 1.6;
|
| 733 |
+
}
|
| 734 |
+
|
| 735 |
+
.data-panel {
|
| 736 |
+
display: grid;
|
| 737 |
+
grid-template-columns: 1fr 1fr;
|
| 738 |
+
border-bottom: 1px solid var(--rule);
|
| 739 |
+
padding: 0 var(--container-pad);
|
| 740 |
+
gap: 0;
|
| 741 |
+
}
|
| 742 |
+
|
| 743 |
+
.data-panel > * {
|
| 744 |
+
border-right: 1px solid var(--rule);
|
| 745 |
+
}
|
| 746 |
+
|
| 747 |
+
.data-panel > *:last-child {
|
| 748 |
+
border-right: 0;
|
| 749 |
+
}
|
| 750 |
+
|
| 751 |
+
.data-panel .block {
|
| 752 |
+
min-height: 240px;
|
| 753 |
+
background: var(--bg) !important;
|
| 754 |
+
border: 0 !important;
|
| 755 |
+
border-radius: 0 !important;
|
| 756 |
+
}
|
| 757 |
+
|
| 758 |
+
.chat-panel {
|
| 759 |
+
margin: 0;
|
| 760 |
+
padding: 22px var(--container-pad) 24px;
|
| 761 |
+
border-bottom: 1px solid var(--rule);
|
| 762 |
+
background: var(--panel-strong);
|
| 763 |
+
}
|
| 764 |
+
|
| 765 |
+
.chat-panel textarea {
|
| 766 |
+
min-height: 68px !important;
|
| 767 |
+
}
|
| 768 |
+
|
| 769 |
+
.chat-timeline {
|
| 770 |
+
display: flex;
|
| 771 |
+
flex-direction: column;
|
| 772 |
+
gap: 12px;
|
| 773 |
+
max-height: 460px;
|
| 774 |
+
overflow-y: auto;
|
| 775 |
+
padding: 4px 2px 14px;
|
| 776 |
+
}
|
| 777 |
+
|
| 778 |
+
.chat-empty {
|
| 779 |
+
color: var(--faint);
|
| 780 |
+
font-family: var(--mono);
|
| 781 |
+
font-size: 12px;
|
| 782 |
+
padding: 14px 0;
|
| 783 |
+
}
|
| 784 |
+
|
| 785 |
+
.chat-bubble {
|
| 786 |
+
width: min(760px, 92%);
|
| 787 |
+
border: 1px solid var(--rule);
|
| 788 |
+
background: var(--panel);
|
| 789 |
+
padding: 12px 14px;
|
| 790 |
+
}
|
| 791 |
+
|
| 792 |
+
.chat-bubble.is-user {
|
| 793 |
+
align-self: flex-end;
|
| 794 |
+
background: #f4f4f5;
|
| 795 |
+
color: #09090b;
|
| 796 |
+
border-color: #f4f4f5;
|
| 797 |
+
}
|
| 798 |
+
|
| 799 |
+
.chat-bubble.is-assistant {
|
| 800 |
+
align-self: flex-start;
|
| 801 |
+
}
|
| 802 |
+
|
| 803 |
+
.chat-label {
|
| 804 |
+
font-family: var(--mono);
|
| 805 |
+
font-size: 10px;
|
| 806 |
+
font-weight: 700;
|
| 807 |
+
text-transform: uppercase;
|
| 808 |
+
color: var(--muted);
|
| 809 |
+
margin-bottom: 7px;
|
| 810 |
+
}
|
| 811 |
+
|
| 812 |
+
.chat-bubble.is-user .chat-label {
|
| 813 |
+
color: #52525b;
|
| 814 |
+
}
|
| 815 |
+
|
| 816 |
+
.chat-text {
|
| 817 |
+
white-space: pre-wrap;
|
| 818 |
+
overflow-wrap: anywhere;
|
| 819 |
+
font-family: var(--sans);
|
| 820 |
+
font-size: 13.5px;
|
| 821 |
+
line-height: 1.55;
|
| 822 |
+
}
|
| 823 |
+
|
| 824 |
+
.download-button {
|
| 825 |
+
display: flex;
|
| 826 |
+
align-items: center;
|
| 827 |
+
justify-content: space-between;
|
| 828 |
+
gap: 16px;
|
| 829 |
+
width: 100%;
|
| 830 |
+
min-height: 56px;
|
| 831 |
+
padding: 0 20px;
|
| 832 |
+
border: 1px solid var(--ink);
|
| 833 |
+
background: var(--ink);
|
| 834 |
+
color: var(--bg) !important;
|
| 835 |
+
font-family: var(--mono);
|
| 836 |
+
font-size: 11px;
|
| 837 |
+
font-weight: 700;
|
| 838 |
+
text-transform: uppercase;
|
| 839 |
+
letter-spacing: 0.18em;
|
| 840 |
+
text-decoration: none;
|
| 841 |
+
transition:
|
| 842 |
+
background 120ms ease,
|
| 843 |
+
color 120ms ease,
|
| 844 |
+
transform 120ms ease;
|
| 845 |
+
}
|
| 846 |
+
|
| 847 |
+
.download-button:hover {
|
| 848 |
+
background: var(--ink-soft);
|
| 849 |
+
color: var(--bg) !important;
|
| 850 |
+
transform: translateY(-1px);
|
| 851 |
+
}
|
| 852 |
+
|
| 853 |
+
.download-button:active {
|
| 854 |
+
transform: translateY(0);
|
| 855 |
+
}
|
| 856 |
+
|
| 857 |
+
.download-arrow {
|
| 858 |
+
font-family: var(--mono);
|
| 859 |
+
font-size: 10px;
|
| 860 |
+
letter-spacing: 0.2em;
|
| 861 |
+
}
|
| 862 |
+
|
| 863 |
+
.footer {
|
| 864 |
+
padding: 28px var(--container-pad);
|
| 865 |
+
border-top: 1px solid var(--rule);
|
| 866 |
+
display: flex;
|
| 867 |
+
justify-content: space-between;
|
| 868 |
+
align-items: center;
|
| 869 |
+
font-family: var(--mono);
|
| 870 |
+
font-size: 10.5px;
|
| 871 |
+
text-transform: uppercase;
|
| 872 |
+
letter-spacing: 0.18em;
|
| 873 |
+
color: var(--faint);
|
| 874 |
+
}
|
| 875 |
+
|
| 876 |
+
.footer .footer-links {
|
| 877 |
+
display: flex;
|
| 878 |
+
gap: 20px;
|
| 879 |
+
}
|
| 880 |
+
|
| 881 |
+
.footer a {
|
| 882 |
+
color: var(--muted);
|
| 883 |
+
text-decoration: none;
|
| 884 |
+
}
|
| 885 |
+
|
| 886 |
+
.footer a:hover { color: var(--ink); }
|
| 887 |
+
|
| 888 |
+
@media (max-width: 1100px) {
|
| 889 |
+
.workbench { grid-template-columns: var(--workbench-cols-md); }
|
| 890 |
+
.workbench .workbench-rail { display: none; }
|
| 891 |
+
.hero { grid-template-columns: 1fr; gap: 32px; align-items: start; }
|
| 892 |
+
.hero-side { padding-bottom: 0; }
|
| 893 |
+
}
|
| 894 |
+
|
| 895 |
+
@media (max-width: 880px) {
|
| 896 |
+
.workbench { grid-template-columns: 1fr; }
|
| 897 |
+
.workbench .workbench-input {
|
| 898 |
+
border-right: 0;
|
| 899 |
+
border-bottom: 1px solid var(--rule);
|
| 900 |
+
}
|
| 901 |
+
.workbench .workbench-dispatch {
|
| 902 |
+
border-right: 0;
|
| 903 |
+
border-bottom: 1px solid var(--rule);
|
| 904 |
+
}
|
| 905 |
+
.data-panel { grid-template-columns: 1fr; }
|
| 906 |
+
.data-panel > * { border-right: 0; border-bottom: 1px solid var(--rule); }
|
| 907 |
+
.data-panel > *:last-child { border-bottom: 0; }
|
| 908 |
+
.field-pair { grid-template-columns: 1fr; }
|
| 909 |
+
.hero { padding: 56px 20px 40px; }
|
| 910 |
+
.hero .hero-title {
|
| 911 |
+
font-size: clamp(34px, 11vw, 56px) !important;
|
| 912 |
+
line-height: 0.98 !important;
|
| 913 |
+
}
|
| 914 |
+
.hero .hero-subtext { font-size: 13px; }
|
| 915 |
+
.workbench-input,
|
| 916 |
+
.workbench-dispatch,
|
| 917 |
+
.workbench-rail { padding: 28px 20px; }
|
| 918 |
+
.topbar {
|
| 919 |
+
padding: 0 20px;
|
| 920 |
+
height: auto;
|
| 921 |
+
min-height: 64px;
|
| 922 |
+
flex-wrap: wrap;
|
| 923 |
+
gap: 6px 12px;
|
| 924 |
+
padding-top: 12px;
|
| 925 |
+
padding-bottom: 12px;
|
| 926 |
+
}
|
| 927 |
+
.topbar .wordmark { font-size: 10px; }
|
| 928 |
+
.topbar .topbar-meta { display: none; }
|
| 929 |
+
#status-bar { top: 12px; right: 20px; }
|
| 930 |
+
.footer { padding: 20px; flex-direction: column; gap: 12px; align-items: flex-start; }
|
| 931 |
+
.gradio-container { padding: 0 20px !important; }
|
| 932 |
+
}
|
| 933 |
+
|
| 934 |
+
@media (max-width: 420px) {
|
| 935 |
+
.hero .hero-title { font-size: 30px !important; }
|
| 936 |
+
.hero { padding: 40px 16px 28px; }
|
| 937 |
+
.workbench-input,
|
| 938 |
+
.workbench-dispatch,
|
| 939 |
+
.workbench-rail { padding: 20px 16px; }
|
| 940 |
+
}
|
| 941 |
+
|
| 942 |
+
@media (prefers-reduced-motion: reduce) {
|
| 943 |
+
*,
|
| 944 |
+
*:before,
|
| 945 |
+
*:after {
|
| 946 |
+
transition: none !important;
|
| 947 |
+
animation: none !important;
|
| 948 |
+
}
|
| 949 |
+
}
|
| 950 |
+
"""
|
| 951 |
+
|
| 952 |
+
|
| 953 |
+
def build_app(service: ArenaService | None = None) -> gr.Blocks:
|
| 954 |
+
arena = GradioArena(service)
|
| 955 |
+
|
| 956 |
+
with gr.Blocks(
|
| 957 |
+
title="Backyard Demo Builder",
|
| 958 |
+
fill_width=True,
|
| 959 |
+
elem_id="run-shell",
|
| 960 |
+
) as demo:
|
| 961 |
+
state = gr.State(UiState())
|
| 962 |
+
gr.HTML(
|
| 963 |
+
'<link rel="preconnect" href="https://fonts.googleapis.com">'
|
| 964 |
+
'<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>'
|
| 965 |
+
'<link href="https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700;800;900&family=IBM+Plex+Mono:wght@300;400;500;600;700&display=swap" rel="stylesheet">'
|
| 966 |
+
f"<style>{CSS}</style>"
|
| 967 |
+
)
|
| 968 |
+
|
| 969 |
+
status_bar = gr.HTML("", elem_id="status-bar")
|
| 970 |
+
|
| 971 |
+
gr.HTML(TOPBAR_HTML)
|
| 972 |
+
|
| 973 |
+
gr.HTML(HERO_HTML)
|
| 974 |
+
|
| 975 |
+
with gr.Row(elem_classes=["workbench"]):
|
| 976 |
+
with gr.Column(scale=2, min_width=320, elem_classes=["workbench-input"]):
|
| 977 |
+
with gr.Column(elem_classes=["field-stack"]):
|
| 978 |
+
demo_template = gr.Dropdown(
|
| 979 |
+
label="Demo Template",
|
| 980 |
+
choices=template_choices(),
|
| 981 |
+
value=template_by_label("real_estate_followup_crm").label,
|
| 982 |
+
interactive=True,
|
| 983 |
+
)
|
| 984 |
+
run_prompt = gr.Textbox(
|
| 985 |
+
label="Person + Problem",
|
| 986 |
+
lines=9,
|
| 987 |
+
placeholder="Describe the real person, their workflow, and the tiny demo to build.",
|
| 988 |
+
value=DEFAULT_BACKYARD_PROMPT,
|
| 989 |
+
)
|
| 990 |
+
with gr.Row(elem_classes=["field-pair"]):
|
| 991 |
+
run_criteria = gr.Textbox(
|
| 992 |
+
label="What Makes This Useful",
|
| 993 |
+
lines=4,
|
| 994 |
+
placeholder="One criterion per line",
|
| 995 |
+
value=DEFAULT_BACKYARD_CRITERIA,
|
| 996 |
+
)
|
| 997 |
+
run_tests = gr.Textbox(
|
| 998 |
+
label="Demo Checks",
|
| 999 |
+
lines=4,
|
| 1000 |
+
placeholder="python -m py_compile app.py",
|
| 1001 |
+
value=DEFAULT_BACKYARD_CHECKS,
|
| 1002 |
+
)
|
| 1003 |
+
with gr.Row(elem_classes=["field-pair"]):
|
| 1004 |
+
model_provider = gr.Dropdown(
|
| 1005 |
+
label="Model Provider",
|
| 1006 |
+
choices=["openrouter", "gemini", "nebius", "huggingface", "custom", "local"],
|
| 1007 |
+
value="openrouter",
|
| 1008 |
+
interactive=True,
|
| 1009 |
+
)
|
| 1010 |
+
model_id = gr.Dropdown(
|
| 1011 |
+
label="Model",
|
| 1012 |
+
choices=[],
|
| 1013 |
+
value=None,
|
| 1014 |
+
interactive=True,
|
| 1015 |
+
)
|
| 1016 |
+
with gr.Row(elem_classes=["field-pair"]):
|
| 1017 |
+
model_api_key = gr.Textbox(
|
| 1018 |
+
label="API Key",
|
| 1019 |
+
type="password",
|
| 1020 |
+
placeholder="Required for hosted providers. Not stored.",
|
| 1021 |
+
)
|
| 1022 |
+
model_base_url = gr.Textbox(
|
| 1023 |
+
label="API Endpoint",
|
| 1024 |
+
placeholder="Optional OpenAI-compatible /v1 endpoint",
|
| 1025 |
+
)
|
| 1026 |
+
with gr.Row(elem_classes=["field-pair"]):
|
| 1027 |
+
list_models_btn = gr.Button("Refresh models", variant="secondary")
|
| 1028 |
+
model_catalog_json = gr.JSON(label="Allowed Models", visible=False)
|
| 1029 |
+
with gr.Column(scale=1, min_width=320, elem_classes=["workbench-dispatch"]):
|
| 1030 |
+
gr.HTML('<p class="dispatch-meta">Dispatch</p>')
|
| 1031 |
+
with gr.Column(elem_classes=["dispatch-stack"]):
|
| 1032 |
+
run_btn = gr.Button("Build demo package", variant="primary", elem_id="start-run-btn")
|
| 1033 |
+
run_cancel_btn = gr.Button("Cancel build", variant="secondary", elem_id="cancel-run-btn")
|
| 1034 |
+
with gr.Column(elem_classes=["dispatch-stack"]):
|
| 1035 |
+
refresh_btn = gr.Button("Refresh", variant="secondary", elem_id="refresh-run-btn")
|
| 1036 |
+
token = gr.Textbox(label="Run Token", interactive=False, visible=False)
|
| 1037 |
+
with gr.Column(scale=1, min_width=320, elem_classes=["workbench-rail"]):
|
| 1038 |
+
gr.HTML(RAIL_HTML)
|
| 1039 |
+
with gr.Column(elem_classes=["rail-block"]):
|
| 1040 |
+
gr.HTML(
|
| 1041 |
+
"""
|
| 1042 |
+
<p class="dispatch-meta">Mom Test</p>
|
| 1043 |
+
<div class="rail-grid">
|
| 1044 |
+
<div class="rail-row"><span class="rail-label">Tester</span><span class="rail-value">Mom · real-estate agent</span></div>
|
| 1045 |
+
<div class="rail-row"><span class="rail-label">Proof</span><span class="rail-value">field_notes.md</span></div>
|
| 1046 |
+
<div class="rail-row"><span class="rail-label">Question</span><span class="rail-value">Scrap or scale?</span></div>
|
| 1047 |
+
</div>
|
| 1048 |
+
"""
|
| 1049 |
+
)
|
| 1050 |
+
with gr.Column(elem_classes=["rail-block", "rail-status-block"]):
|
| 1051 |
+
gr.HTML('<p class="dispatch-meta">Run state</p>')
|
| 1052 |
+
status_panel = gr.HTML("", elem_id="status-panel")
|
| 1053 |
+
|
| 1054 |
+
with gr.Column(elem_classes=["chat-panel"]):
|
| 1055 |
+
gr.HTML('<p class="dispatch-meta">Conversation</p>')
|
| 1056 |
+
chat_panel = gr.HTML("", elem_id="chat-panel")
|
| 1057 |
+
with gr.Row(elem_classes=["dispatch-row"]):
|
| 1058 |
+
run_message = gr.Textbox(
|
| 1059 |
+
label="Follow-up",
|
| 1060 |
+
lines=2,
|
| 1061 |
+
placeholder="Add context or ask for an update while this run is active.",
|
| 1062 |
+
scale=4,
|
| 1063 |
+
)
|
| 1064 |
+
send_message_btn = gr.Button("Send", variant="secondary", elem_id="send-message-btn", scale=1)
|
| 1065 |
+
gr.HTML('<p class="dispatch-meta">Real-person feedback</p>')
|
| 1066 |
+
with gr.Row(elem_classes=["field-pair"]):
|
| 1067 |
+
feedback_tester = gr.Textbox(
|
| 1068 |
+
label="Tester",
|
| 1069 |
+
value="Mom",
|
| 1070 |
+
lines=1,
|
| 1071 |
+
placeholder="Who tested the generated demo?",
|
| 1072 |
+
)
|
| 1073 |
+
feedback_decision = gr.Dropdown(
|
| 1074 |
+
label="Decision",
|
| 1075 |
+
choices=["scale", "scrap", "iterate"],
|
| 1076 |
+
value="iterate",
|
| 1077 |
+
interactive=True,
|
| 1078 |
+
)
|
| 1079 |
+
with gr.Row(elem_classes=["field-pair"]):
|
| 1080 |
+
feedback_quote = gr.Textbox(
|
| 1081 |
+
label="Direct Quote",
|
| 1082 |
+
lines=2,
|
| 1083 |
+
placeholder="One exact quote from the tester.",
|
| 1084 |
+
)
|
| 1085 |
+
feedback_notes = gr.Textbox(
|
| 1086 |
+
label="Missing Before Real App",
|
| 1087 |
+
lines=2,
|
| 1088 |
+
placeholder="What must change before scaling?",
|
| 1089 |
+
)
|
| 1090 |
+
feedback_btn = gr.Button("Record feedback", variant="secondary")
|
| 1091 |
+
|
| 1092 |
+
with gr.Row(elem_classes=["data-panel"]):
|
| 1093 |
+
with gr.Column():
|
| 1094 |
+
tasks = gr.Dataframe(
|
| 1095 |
+
headers=["task_id", "title", "status"],
|
| 1096 |
+
label="Tasks",
|
| 1097 |
+
interactive=False,
|
| 1098 |
+
)
|
| 1099 |
+
with gr.Column():
|
| 1100 |
+
events = gr.Dataframe(
|
| 1101 |
+
headers=["created_at", "message"],
|
| 1102 |
+
label="Events",
|
| 1103 |
+
interactive=False,
|
| 1104 |
+
)
|
| 1105 |
+
|
| 1106 |
+
gr.HTML('<div id="download-divider" hidden></div>')
|
| 1107 |
+
download_link = gr.HTML("", elem_id="download-area")
|
| 1108 |
+
|
| 1109 |
+
gr.HTML(FOOTER_HTML)
|
| 1110 |
+
|
| 1111 |
+
run_json = gr.JSON(label="Run Detail", visible=False)
|
| 1112 |
+
zerogpu_probe = gr.Button("ZeroGPU", visible=False)
|
| 1113 |
+
zerogpu_status = gr.Textbox(visible=False)
|
| 1114 |
+
|
| 1115 |
+
outputs = [state, status_bar, run_json, tasks, events, token, download_link, status_panel, chat_panel]
|
| 1116 |
+
zerogpu_probe.click(zerogpu_ready_marker, [], zerogpu_status)
|
| 1117 |
+
run_btn.click(
|
| 1118 |
+
arena.create_run,
|
| 1119 |
+
[run_prompt, run_criteria, run_tests, model_provider, model_id, model_api_key, model_base_url],
|
| 1120 |
+
outputs,
|
| 1121 |
+
)
|
| 1122 |
+
list_models_btn.click(
|
| 1123 |
+
arena.refresh_models,
|
| 1124 |
+
[model_provider, model_api_key, model_base_url],
|
| 1125 |
+
[model_id, model_catalog_json],
|
| 1126 |
+
)
|
| 1127 |
+
demo_template.change(
|
| 1128 |
+
arena.apply_template,
|
| 1129 |
+
[demo_template],
|
| 1130 |
+
[run_prompt, run_criteria, run_tests],
|
| 1131 |
+
)
|
| 1132 |
+
run_cancel_btn.click(arena.cancel_run, [state], outputs)
|
| 1133 |
+
send_message_btn.click(arena.send_message, [state, run_message], outputs)
|
| 1134 |
+
feedback_btn.click(
|
| 1135 |
+
arena.record_feedback,
|
| 1136 |
+
[state, feedback_tester, feedback_quote, feedback_decision, feedback_notes],
|
| 1137 |
+
outputs,
|
| 1138 |
+
)
|
| 1139 |
+
refresh_btn.click(arena.refresh, [state], outputs)
|
| 1140 |
+
|
| 1141 |
+
return demo
|
arena/gradio_markup.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Static HTML fragments for the Gradio Run shell."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
TOPBAR_HTML = """
|
| 7 |
+
<header class="topbar" role="banner">
|
| 8 |
+
<p class="wordmark"><span class="wordmark-dot" aria-hidden="true"></span>Backyard Demo Builder</p>
|
| 9 |
+
<p class="topbar-meta">Tiny demo · Real person · ≤32B model</p>
|
| 10 |
+
</header>
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
HERO_HTML = """
|
| 15 |
+
<section class="hero" aria-label="Backyard demo builder overview">
|
| 16 |
+
<div class="hero-main">
|
| 17 |
+
<h1 class="hero-title">
|
| 18 |
+
<span class="line">Build the tiny demo first.</span>
|
| 19 |
+
<span class="line">Scrap or scale after use.</span>
|
| 20 |
+
</h1>
|
| 21 |
+
<p class="hero-subtext">
|
| 22 |
+
Turn a real person's problem into a runnable Gradio skeleton, a handoff spec, and field notes before paying for full software.
|
| 23 |
+
</p>
|
| 24 |
+
</div>
|
| 25 |
+
<aside class="hero-side">
|
| 26 |
+
<ul class="hero-marks">
|
| 27 |
+
<li><span class="mark-num">01</span><span class="mark-text">Start with one real person and one real workflow.</span></li>
|
| 28 |
+
<li><span class="mark-num">02</span><span class="mark-text">Generate a cheap Gradio demo package.</span></li>
|
| 29 |
+
<li><span class="mark-num">03</span><span class="mark-text">Test it with the person before scaling.</span></li>
|
| 30 |
+
<li><span class="mark-num">04</span><span class="mark-text">Export field notes for the hackathon submission.</span></li>
|
| 31 |
+
</ul>
|
| 32 |
+
</aside>
|
| 33 |
+
</section>
|
| 34 |
+
"""
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
RAIL_HTML = """
|
| 38 |
+
<div class="rail-block">
|
| 39 |
+
<p class="dispatch-meta">Telemetry</p>
|
| 40 |
+
<div class="rail-grid">
|
| 41 |
+
<div class="rail-row"><span class="rail-label">Provider</span><span class="rail-value">Daytona</span></div>
|
| 42 |
+
<div class="rail-row"><span class="rail-label">Sandbox</span><span class="rail-value">Ephemeral</span></div>
|
| 43 |
+
<div class="rail-row"><span class="rail-label">Template</span><span class="rail-value">Real Estate CRM</span></div>
|
| 44 |
+
<div class="rail-row"><span class="rail-label">Constraint</span><span class="rail-value">≤32B model</span></div>
|
| 45 |
+
<div class="rail-row"><span class="rail-label">Evidence</span><span class="rail-value">Field notes</span></div>
|
| 46 |
+
</div>
|
| 47 |
+
</div>
|
| 48 |
+
<div class="rail-block">
|
| 49 |
+
<p class="dispatch-meta">Keyboard</p>
|
| 50 |
+
<div class="rail-grid">
|
| 51 |
+
<div class="rail-row"><span class="rail-label">Start</span><span class="rail-kbd">Click · Build demo</span></div>
|
| 52 |
+
<div class="rail-row"><span class="rail-label">Cancel</span><span class="rail-kbd">Click · Cancel build</span></div>
|
| 53 |
+
<div class="rail-row"><span class="rail-label">Refresh</span><span class="rail-kbd">Click · Refresh</span></div>
|
| 54 |
+
</div>
|
| 55 |
+
</div>
|
| 56 |
+
"""
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
FOOTER_HTML = """
|
| 60 |
+
<footer class="footer" role="contentinfo">
|
| 61 |
+
<span>Backyard Demo Builder</span>
|
| 62 |
+
<div class="footer-links">
|
| 63 |
+
<span>Real person</span>
|
| 64 |
+
<span>Small model</span>
|
| 65 |
+
<span>Demo first</span>
|
| 66 |
+
</div>
|
| 67 |
+
</footer>
|
| 68 |
+
"""
|
arena/gradio_presenter.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Presentation helpers for the prompt-first Gradio Run shell."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from dataclasses import dataclass
|
| 6 |
+
from html import escape
|
| 7 |
+
from typing import Any
|
| 8 |
+
|
| 9 |
+
from .gradio_run import run_event_rows, run_summary, run_task_rows
|
| 10 |
+
from .run_models import RunView
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
LIVE_STATUSES = ("planning", "working", "validating")
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@dataclass(frozen=True)
|
| 17 |
+
class UiState:
|
| 18 |
+
role: str = ""
|
| 19 |
+
token: str = ""
|
| 20 |
+
run_id: str = ""
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
RunOutputs = tuple[UiState, str, dict[str, Any], list[list[Any]], list[list[Any]], str, str, str, str]
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def run_outputs(state: UiState, run: RunView) -> RunOutputs:
|
| 27 |
+
summary = run_summary(run)
|
| 28 |
+
download_parts: list[str] = []
|
| 29 |
+
if summary.get("codebase_zip_url"):
|
| 30 |
+
download_parts.append(
|
| 31 |
+
'<a href="{}" target="_blank" class="download-button">'
|
| 32 |
+
'<span>Download demo package</span>'
|
| 33 |
+
'<span class="download-arrow" aria-hidden="true">GET</span>'
|
| 34 |
+
'</a>'
|
| 35 |
+
.format(summary["codebase_zip_url"])
|
| 36 |
+
)
|
| 37 |
+
download_parts.append(
|
| 38 |
+
f'<a href="/runs/{run.id}/field_notes.md" target="_blank" class="download-button">'
|
| 39 |
+
'<span>Download field notes</span>'
|
| 40 |
+
'<span class="download-arrow" aria-hidden="true">MD</span>'
|
| 41 |
+
'</a>'
|
| 42 |
+
)
|
| 43 |
+
download_parts.append(
|
| 44 |
+
f'<a href="/runs/{run.id}/submission.md" target="_blank" class="download-button">'
|
| 45 |
+
'<span>Download submission pack</span>'
|
| 46 |
+
'<span class="download-arrow" aria-hidden="true">MD</span>'
|
| 47 |
+
'</a>'
|
| 48 |
+
)
|
| 49 |
+
download_parts.append(
|
| 50 |
+
f'<a href="/runs/{run.id}/space_README.md" target="_blank" class="download-button">'
|
| 51 |
+
'<span>Download Space README</span>'
|
| 52 |
+
'<span class="download-arrow" aria-hidden="true">HF</span>'
|
| 53 |
+
'</a>'
|
| 54 |
+
)
|
| 55 |
+
download_parts.append(
|
| 56 |
+
f'<a href="/runs/{run.id}/trace.json" target="_blank" class="download-button">'
|
| 57 |
+
'<span>Download trace</span>'
|
| 58 |
+
'<span class="download-arrow" aria-hidden="true">JSON</span>'
|
| 59 |
+
'</a>'
|
| 60 |
+
)
|
| 61 |
+
download_html = "".join(download_parts)
|
| 62 |
+
|
| 63 |
+
status_panel = status_bar(summary)
|
| 64 |
+
if run.summary:
|
| 65 |
+
status_panel += f'<div class="run-summary">{escape(run.summary[:500])}</div>'
|
| 66 |
+
|
| 67 |
+
return (
|
| 68 |
+
state,
|
| 69 |
+
status_bar(summary),
|
| 70 |
+
summary,
|
| 71 |
+
run_task_rows(run),
|
| 72 |
+
run_event_rows(run),
|
| 73 |
+
"",
|
| 74 |
+
download_html,
|
| 75 |
+
status_panel,
|
| 76 |
+
chat_timeline(summary),
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def empty_outputs(state: UiState, message: str) -> RunOutputs:
|
| 81 |
+
notice_html = (
|
| 82 |
+
'<span class="run-status-chip validation-chip">Notice</span>'
|
| 83 |
+
f'<span class="run-metric">{escape(message)}</span>'
|
| 84 |
+
)
|
| 85 |
+
chat_html = f'<div class="chat-empty">{escape(message)}</div>'
|
| 86 |
+
return (state, notice_html, {}, [], [], "", "", notice_html, chat_html)
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def status_bar(run_summary: dict[str, Any]) -> str:
|
| 90 |
+
parts = [_status_badge(run_summary.get("status", "queued"))]
|
| 91 |
+
|
| 92 |
+
tasks = run_summary.get("tasks", [])
|
| 93 |
+
if tasks:
|
| 94 |
+
completed = sum(1 for task in tasks if task.get("status") == "completed")
|
| 95 |
+
parts.append(f'<span class="run-metric">{completed}/{len(tasks)} tasks</span>')
|
| 96 |
+
|
| 97 |
+
archive = run_summary.get("codebase_archive")
|
| 98 |
+
if archive:
|
| 99 |
+
size_kb = archive.get("size_bytes", 0) / 1024
|
| 100 |
+
parts.append(
|
| 101 |
+
f'<span class="run-metric">'
|
| 102 |
+
f'{archive.get("file_count", 0)} files ({size_kb:.1f} KB)</span>'
|
| 103 |
+
)
|
| 104 |
+
|
| 105 |
+
report = run_summary.get("validation_report")
|
| 106 |
+
if report:
|
| 107 |
+
label = "PASSED" if report.get("passed") else "FAILED"
|
| 108 |
+
cls = "run-status-chip validation-chip" + (" is-live" if not report.get("passed") else "")
|
| 109 |
+
parts.append(f'<span class="{cls}">{label}</span>')
|
| 110 |
+
|
| 111 |
+
return "".join(parts)
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def chat_timeline(run_summary: dict[str, Any]) -> str:
|
| 115 |
+
if not run_summary:
|
| 116 |
+
return '<div class="chat-empty">Start a run to open the conversation.</div>'
|
| 117 |
+
|
| 118 |
+
bubbles = [("user", "You", str(run_summary.get("prompt", "")))]
|
| 119 |
+
for event in run_summary.get("events", []):
|
| 120 |
+
message = str(event.get("message", ""))
|
| 121 |
+
role = "user" if message.startswith("User: ") else "assistant"
|
| 122 |
+
label = "You" if role == "user" else "Workbench"
|
| 123 |
+
bubbles.append((role, label, message.removeprefix("User: ")))
|
| 124 |
+
|
| 125 |
+
summary = str(run_summary.get("summary", ""))
|
| 126 |
+
if summary:
|
| 127 |
+
bubbles.append(("assistant", "Workbench", summary))
|
| 128 |
+
|
| 129 |
+
body = "".join(
|
| 130 |
+
f'<div class="chat-bubble is-{role}">'
|
| 131 |
+
f'<div class="chat-label">{escape(label)}</div>'
|
| 132 |
+
f'<div class="chat-text">{escape(text)[:1200]}</div>'
|
| 133 |
+
"</div>"
|
| 134 |
+
for role, label, text in bubbles
|
| 135 |
+
if text
|
| 136 |
+
)
|
| 137 |
+
return f'<div class="chat-timeline">{body}</div>'
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def _status_badge(status: str) -> str:
|
| 141 |
+
cls = "run-status-chip is-live" if status in LIVE_STATUSES else "run-status-chip"
|
| 142 |
+
return f'<span class="{cls}">{status}</span>'
|
arena/gradio_run.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Gradio helpers for prompt-first Runs."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from typing import Any
|
| 6 |
+
|
| 7 |
+
from .model_catalog import ModelProviderConfig
|
| 8 |
+
from .backyard_templates import REAL_ESTATE_FOLLOWUP
|
| 9 |
+
from .run_models import RunRequest, RunView
|
| 10 |
+
from .validation_models import Criteria
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
DEFAULT_BACKYARD_PROMPT = REAL_ESTATE_FOLLOWUP.prompt
|
| 14 |
+
DEFAULT_BACKYARD_CRITERIA = REAL_ESTATE_FOLLOWUP.criteria
|
| 15 |
+
DEFAULT_BACKYARD_CHECKS = REAL_ESTATE_FOLLOWUP.checks
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def run_request_from_text(
|
| 19 |
+
prompt: str,
|
| 20 |
+
criteria_text: str,
|
| 21 |
+
user_tests_text: str,
|
| 22 |
+
provider: str = "openrouter",
|
| 23 |
+
model: str | None = "",
|
| 24 |
+
api_key: str | None = "",
|
| 25 |
+
base_url: str | None = "",
|
| 26 |
+
) -> RunRequest:
|
| 27 |
+
model_provider = None
|
| 28 |
+
model = model or ""
|
| 29 |
+
api_key = api_key or ""
|
| 30 |
+
base_url = base_url or ""
|
| 31 |
+
if model.strip() or api_key.strip() or base_url.strip() or (provider and provider != "openrouter"):
|
| 32 |
+
model_provider = ModelProviderConfig(
|
| 33 |
+
provider=provider or "openrouter",
|
| 34 |
+
model=model.strip(),
|
| 35 |
+
api_key=api_key.strip() or None,
|
| 36 |
+
base_url=base_url.strip(),
|
| 37 |
+
)
|
| 38 |
+
return RunRequest(
|
| 39 |
+
prompt=prompt.strip(),
|
| 40 |
+
criteria=[Criteria(text=item) for item in split_lines(criteria_text)],
|
| 41 |
+
user_tests=split_lines(user_tests_text),
|
| 42 |
+
model_provider=model_provider,
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def run_summary(run: RunView) -> dict[str, Any]:
|
| 47 |
+
return {
|
| 48 |
+
"id": run.id,
|
| 49 |
+
"status": run.status,
|
| 50 |
+
"prompt": run.prompt,
|
| 51 |
+
"criteria": [item.model_dump() for item in run.criteria],
|
| 52 |
+
"user_tests": run.user_tests,
|
| 53 |
+
"tasks": [task.model_dump() for task in run.tasks],
|
| 54 |
+
"events": [event.model_dump(mode="json") for event in run.events],
|
| 55 |
+
"artifacts": [artifact.model_dump(mode="json") for artifact in run.artifacts],
|
| 56 |
+
"validation_checks": [check.model_dump() for check in run.validation_checks],
|
| 57 |
+
"validation_report": None if run.validation_report is None else run.validation_report.model_dump(mode="json"),
|
| 58 |
+
"codebase_archive": None if run.codebase_archive is None else run.codebase_archive.model_dump(),
|
| 59 |
+
"model_provider": None if run.model_provider is None else run.model_provider.model_dump(),
|
| 60 |
+
"codebase_zip_url": "" if run.codebase_archive is None else f"/runs/{run.id}/codebase.zip",
|
| 61 |
+
"summary": run.summary,
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def run_task_rows(run: RunView) -> list[list[Any]]:
|
| 66 |
+
return [[task.id, task.title, task.status] for task in run.tasks]
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def run_event_rows(run: RunView) -> list[list[Any]]:
|
| 70 |
+
return [[event.created_at.isoformat(), event.message] for event in run.events]
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def split_lines(text: str) -> list[str]:
|
| 74 |
+
return [line.strip() for line in text.replace(",", "\n").splitlines() if line.strip()]
|
arena/harness.py
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Agent Swarm Harness for exercising the Run pipeline."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import argparse
|
| 6 |
+
import json
|
| 7 |
+
import tempfile
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from typing import Any, Literal
|
| 10 |
+
from uuid import uuid4
|
| 11 |
+
|
| 12 |
+
from pydantic import BaseModel
|
| 13 |
+
|
| 14 |
+
from .agent import AgentSession, create_session
|
| 15 |
+
from .codebase_archive import LocalCodebaseArchiveStore
|
| 16 |
+
from .run_models import RunRequest, RunView
|
| 17 |
+
from .run_store import InMemoryRunStore, RunAccessView
|
| 18 |
+
from .service import ArenaService
|
| 19 |
+
from .swarm_runtime import SwarmRuntime
|
| 20 |
+
from .validation_models import Criteria
|
| 21 |
+
from .validator_executor import create_validator_executor
|
| 22 |
+
from .validator_graph import DeterministicRubricReviewer, ValidatorGraph
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
DEFAULT_PROMPT = "Build a tiny Python project with a README."
|
| 26 |
+
HarnessMode = Literal["swarm", "live"]
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class HarnessResult(BaseModel):
|
| 30 |
+
mode: HarnessMode
|
| 31 |
+
token: str
|
| 32 |
+
run: RunView
|
| 33 |
+
zip_path: str | None = None
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
class ScriptedSwarmAgent:
|
| 37 |
+
"""DeepAgent-compatible scripted agent for exercising SwarmRuntime locally."""
|
| 38 |
+
|
| 39 |
+
def __init__(self, backend: Any, workspace_dir: str) -> None:
|
| 40 |
+
self.backend = backend
|
| 41 |
+
self.workspace_dir = workspace_dir.rstrip("/")
|
| 42 |
+
self.invocations: list[dict[str, Any]] = []
|
| 43 |
+
|
| 44 |
+
def invoke(self, payload: dict[str, Any], config: dict[str, Any]) -> dict[str, Any]:
|
| 45 |
+
self.invocations.append({"payload": payload, "config": config})
|
| 46 |
+
run_id = str(config.get("configurable", {}).get("run_id", "unknown-run"))
|
| 47 |
+
run_context = _backend_read_text(self.backend, "/context/run.md")
|
| 48 |
+
self._write_codebase(run_id, run_context)
|
| 49 |
+
return {
|
| 50 |
+
"messages": [
|
| 51 |
+
{
|
| 52 |
+
"role": "assistant",
|
| 53 |
+
"content": (
|
| 54 |
+
"Scripted swarm built a harness Python codebase "
|
| 55 |
+
f"in {self.workspace_dir}."
|
| 56 |
+
),
|
| 57 |
+
}
|
| 58 |
+
]
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
def _write_codebase(self, run_id: str, run_context: str) -> None:
|
| 62 |
+
manifest = {
|
| 63 |
+
"run_id": run_id,
|
| 64 |
+
"workspace_dir": self.workspace_dir,
|
| 65 |
+
"agent_invocations": len(self.invocations),
|
| 66 |
+
}
|
| 67 |
+
self.backend.write(
|
| 68 |
+
f"{self.workspace_dir}/README.md",
|
| 69 |
+
(
|
| 70 |
+
"# Harness Codebase\n\n"
|
| 71 |
+
"Generated by the scripted Agent Swarm Harness through SwarmRuntime.\n\n"
|
| 72 |
+
"## Run Context\n\n"
|
| 73 |
+
"```markdown\n"
|
| 74 |
+
f"{run_context.strip()}\n"
|
| 75 |
+
"```\n"
|
| 76 |
+
),
|
| 77 |
+
)
|
| 78 |
+
self.backend.write(
|
| 79 |
+
f"{self.workspace_dir}/harness_result.json",
|
| 80 |
+
json.dumps(manifest, indent=2, sort_keys=True),
|
| 81 |
+
)
|
| 82 |
+
self.backend.write(
|
| 83 |
+
f"{self.workspace_dir}/pyproject.toml",
|
| 84 |
+
"[project]\nname = \"harness-codebase\"\nversion = \"0.1.0\"\n",
|
| 85 |
+
)
|
| 86 |
+
self.backend.write(
|
| 87 |
+
f"{self.workspace_dir}/app.py",
|
| 88 |
+
"def main() -> str:\n return \"agent swarm harness\"\n",
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def create_harness_service(*, archive_root: Path | None = None) -> ArenaService:
|
| 93 |
+
archive_store = LocalCodebaseArchiveStore(root_dir=archive_root)
|
| 94 |
+
executor = create_validator_executor(archive_store=archive_store, provider="local")
|
| 95 |
+
validator = ValidatorGraph(
|
| 96 |
+
executor=executor,
|
| 97 |
+
rubric_reviewer=DeterministicRubricReviewer(),
|
| 98 |
+
rubric_enabled=True,
|
| 99 |
+
)
|
| 100 |
+
return ArenaService(
|
| 101 |
+
run_store=InMemoryRunStore(),
|
| 102 |
+
swarm_runtime=SwarmRuntime(
|
| 103 |
+
session_factory=_scripted_session_factory(),
|
| 104 |
+
archive_store=archive_store,
|
| 105 |
+
),
|
| 106 |
+
archive_store=archive_store,
|
| 107 |
+
validator_graph=validator,
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def create_live_harness_service(*, archive_root: Path | None = None) -> ArenaService:
|
| 112 |
+
archive_store = LocalCodebaseArchiveStore(root_dir=archive_root)
|
| 113 |
+
executor = create_validator_executor(archive_store=archive_store, provider="local")
|
| 114 |
+
validator = ValidatorGraph(
|
| 115 |
+
executor=executor,
|
| 116 |
+
rubric_reviewer=DeterministicRubricReviewer(),
|
| 117 |
+
rubric_enabled=True,
|
| 118 |
+
)
|
| 119 |
+
return ArenaService(
|
| 120 |
+
run_store=InMemoryRunStore(),
|
| 121 |
+
swarm_runtime=SwarmRuntime(
|
| 122 |
+
session_factory=create_session,
|
| 123 |
+
archive_store=archive_store,
|
| 124 |
+
),
|
| 125 |
+
archive_store=archive_store,
|
| 126 |
+
validator_graph=validator,
|
| 127 |
+
)
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def run_harness(
|
| 131 |
+
*,
|
| 132 |
+
prompt: str = DEFAULT_PROMPT,
|
| 133 |
+
criteria: list[str] | None = None,
|
| 134 |
+
user_tests: list[str] | None = None,
|
| 135 |
+
archive_root: Path | None = None,
|
| 136 |
+
output_dir: Path | None = None,
|
| 137 |
+
mode: HarnessMode = "swarm",
|
| 138 |
+
) -> HarnessResult:
|
| 139 |
+
service = _service_for_mode(mode, archive_root=archive_root)
|
| 140 |
+
request = RunRequest(
|
| 141 |
+
prompt=prompt,
|
| 142 |
+
criteria=[Criteria(text=item) for item in criteria or []],
|
| 143 |
+
user_tests=user_tests or [],
|
| 144 |
+
)
|
| 145 |
+
access = service.create_run(request)
|
| 146 |
+
zip_path = _write_zip(service, access, output_dir) if output_dir is not None else None
|
| 147 |
+
return HarnessResult(
|
| 148 |
+
mode=mode,
|
| 149 |
+
token=access.token,
|
| 150 |
+
run=service.get_run(access.run.id),
|
| 151 |
+
zip_path=str(zip_path) if zip_path is not None else None,
|
| 152 |
+
)
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def main(argv: list[str] | None = None) -> int:
|
| 156 |
+
parser = argparse.ArgumentParser(description="Run the Agent Swarm Harness")
|
| 157 |
+
parser.add_argument(
|
| 158 |
+
"--mode",
|
| 159 |
+
choices=["swarm", "live"],
|
| 160 |
+
default="swarm",
|
| 161 |
+
help=(
|
| 162 |
+
"swarm uses SwarmRuntime with a scripted local agent; "
|
| 163 |
+
"live uses the real DeepAgent session factory."
|
| 164 |
+
),
|
| 165 |
+
)
|
| 166 |
+
parser.add_argument("--prompt", default=DEFAULT_PROMPT)
|
| 167 |
+
parser.add_argument("--criteria", action="append", default=[])
|
| 168 |
+
parser.add_argument("--test", action="append", default=[], dest="user_tests")
|
| 169 |
+
parser.add_argument("--output-dir", type=Path)
|
| 170 |
+
args = parser.parse_args(argv)
|
| 171 |
+
|
| 172 |
+
with tempfile.TemporaryDirectory(prefix="agent-harness-archives-") as tmp:
|
| 173 |
+
result = run_harness(
|
| 174 |
+
prompt=args.prompt,
|
| 175 |
+
criteria=args.criteria,
|
| 176 |
+
user_tests=args.user_tests,
|
| 177 |
+
archive_root=Path(tmp),
|
| 178 |
+
output_dir=args.output_dir,
|
| 179 |
+
mode=args.mode,
|
| 180 |
+
)
|
| 181 |
+
print(json.dumps(result.model_dump(mode="json"), sort_keys=True, default=str))
|
| 182 |
+
return 0
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
def _write_zip(service: ArenaService, access: RunAccessView, output_dir: Path) -> Path:
|
| 186 |
+
output_dir.mkdir(parents=True, exist_ok=True)
|
| 187 |
+
payload = service.get_run_codebase_zip(access.run.id)
|
| 188 |
+
destination = output_dir / payload.filename
|
| 189 |
+
destination.write_bytes(payload.data)
|
| 190 |
+
return destination
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
def _service_for_mode(mode: HarnessMode, *, archive_root: Path | None) -> ArenaService:
|
| 194 |
+
if mode == "live":
|
| 195 |
+
return create_live_harness_service(archive_root=archive_root)
|
| 196 |
+
return create_harness_service(archive_root=archive_root)
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
def _scripted_session_factory():
|
| 200 |
+
def factory(thread_id: str | None = None) -> AgentSession:
|
| 201 |
+
from deepagents.backends import CompositeBackend, FilesystemBackend, LocalShellBackend
|
| 202 |
+
|
| 203 |
+
thread_id_value = thread_id or uuid4().hex
|
| 204 |
+
sandbox_root = Path(tempfile.mkdtemp(prefix="agent-swarm-harness-"))
|
| 205 |
+
context_root = Path(tempfile.mkdtemp(prefix="agent-swarm-harness-context-"))
|
| 206 |
+
workspace_dir = "/workspace"
|
| 207 |
+
(sandbox_root / "workspace").mkdir(parents=True, exist_ok=True)
|
| 208 |
+
(context_root / "context").mkdir(parents=True, exist_ok=True)
|
| 209 |
+
|
| 210 |
+
executable_backend = LocalShellBackend(
|
| 211 |
+
root_dir=sandbox_root,
|
| 212 |
+
virtual_mode=True,
|
| 213 |
+
inherit_env=False,
|
| 214 |
+
)
|
| 215 |
+
executable_backend._workspace_dir = workspace_dir
|
| 216 |
+
context_backend = FilesystemBackend(root_dir=context_root, virtual_mode=True)
|
| 217 |
+
backend = CompositeBackend(
|
| 218 |
+
default=executable_backend,
|
| 219 |
+
routes={"/context/": context_backend},
|
| 220 |
+
)
|
| 221 |
+
backend._workspace_dir = workspace_dir
|
| 222 |
+
executable_backend.execute(f"mkdir -p {workspace_dir}")
|
| 223 |
+
return AgentSession(
|
| 224 |
+
thread_id=thread_id_value,
|
| 225 |
+
provider="harness",
|
| 226 |
+
backend=backend,
|
| 227 |
+
agent=ScriptedSwarmAgent(backend, workspace_dir),
|
| 228 |
+
workspace_dir=workspace_dir,
|
| 229 |
+
context_root=context_root,
|
| 230 |
+
)
|
| 231 |
+
|
| 232 |
+
return factory
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
def _backend_read_text(backend: Any, path: str) -> str:
|
| 236 |
+
result = backend.read(path)
|
| 237 |
+
file_data = getattr(result, "file_data", None) or {}
|
| 238 |
+
return str(file_data.get("content", ""))
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
if __name__ == "__main__":
|
| 242 |
+
raise SystemExit(main())
|
arena/model_catalog.py
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Provider model catalogs normalized for Run setup."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import hashlib
|
| 6 |
+
import json
|
| 7 |
+
import time
|
| 8 |
+
import urllib.parse
|
| 9 |
+
import urllib.request
|
| 10 |
+
from dataclasses import dataclass
|
| 11 |
+
from typing import Any
|
| 12 |
+
|
| 13 |
+
from pydantic import BaseModel, Field, SecretStr, field_validator
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
DEFAULT_OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
|
| 17 |
+
DEFAULT_NEBIUS_BASE_URL = "https://api.tokenfactory.nebius.com/v1"
|
| 18 |
+
DEFAULT_HF_ROUTER_BASE_URL = "https://router.huggingface.co/v1"
|
| 19 |
+
DEFAULT_GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"
|
| 20 |
+
DEFAULT_LOCAL_MODEL = "unsloth/gemma-4-12B-it-qat-GGUF"
|
| 21 |
+
MAX_MODEL_PARAMETERS_B = 32.0
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class ModelProviderSelection(BaseModel):
|
| 25 |
+
provider: str = "openrouter"
|
| 26 |
+
model: str = ""
|
| 27 |
+
base_url: str = ""
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class ModelProviderConfig(ModelProviderSelection):
|
| 31 |
+
api_key: SecretStr | None = None
|
| 32 |
+
|
| 33 |
+
@field_validator("provider")
|
| 34 |
+
@classmethod
|
| 35 |
+
def normalize_provider(cls, value: str) -> str:
|
| 36 |
+
return value.strip().lower().replace("_", "-")
|
| 37 |
+
|
| 38 |
+
def public_selection(self) -> ModelProviderSelection | None:
|
| 39 |
+
if not self.provider and not self.model and not self.base_url:
|
| 40 |
+
return None
|
| 41 |
+
return ModelProviderSelection(
|
| 42 |
+
provider=self.provider,
|
| 43 |
+
model=self.model.strip(),
|
| 44 |
+
base_url=self.base_url.strip(),
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
def key_value(self) -> str:
|
| 48 |
+
return self.api_key.get_secret_value().strip() if self.api_key else ""
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
class ProviderModel(BaseModel):
|
| 52 |
+
id: str = Field(min_length=1)
|
| 53 |
+
label: str = ""
|
| 54 |
+
provider: str
|
| 55 |
+
context_length: int | None = None
|
| 56 |
+
supports_tools: bool | None = None
|
| 57 |
+
supports_structured_output: bool | None = None
|
| 58 |
+
input_price: float | None = None
|
| 59 |
+
output_price: float | None = None
|
| 60 |
+
parameter_count_b: float | None = None
|
| 61 |
+
eligible: bool = True
|
| 62 |
+
status: str = "unknown"
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
@dataclass
|
| 66 |
+
class _CacheEntry:
|
| 67 |
+
created_at: float
|
| 68 |
+
models: list[ProviderModel]
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
class ModelCatalog:
|
| 72 |
+
def __init__(self, *, ttl_seconds: float = 600, timeout_seconds: float = 10) -> None:
|
| 73 |
+
self._ttl_seconds = ttl_seconds
|
| 74 |
+
self._timeout_seconds = timeout_seconds
|
| 75 |
+
self._cache: dict[str, _CacheEntry] = {}
|
| 76 |
+
|
| 77 |
+
def list_models(self, config: ModelProviderConfig | None = None) -> list[ProviderModel]:
|
| 78 |
+
config = config or ModelProviderConfig()
|
| 79 |
+
key = self._cache_key(config)
|
| 80 |
+
cached = self._cache.get(key)
|
| 81 |
+
if cached and time.time() - cached.created_at < self._ttl_seconds:
|
| 82 |
+
return cached.models
|
| 83 |
+
models = self._fetch(config)
|
| 84 |
+
self._cache[key] = _CacheEntry(created_at=time.time(), models=models)
|
| 85 |
+
return models
|
| 86 |
+
|
| 87 |
+
def refresh_models(self, config: ModelProviderConfig | None = None) -> list[ProviderModel]:
|
| 88 |
+
config = config or ModelProviderConfig()
|
| 89 |
+
self.clear(config)
|
| 90 |
+
if config.provider == "local":
|
| 91 |
+
_clear_local_runtime_cache()
|
| 92 |
+
return self.list_models(config)
|
| 93 |
+
|
| 94 |
+
def clear(self, config: ModelProviderConfig | None = None) -> None:
|
| 95 |
+
if config is None:
|
| 96 |
+
self._cache.clear()
|
| 97 |
+
return
|
| 98 |
+
self._cache.pop(self._cache_key(config), None)
|
| 99 |
+
|
| 100 |
+
def _fetch(self, config: ModelProviderConfig) -> list[ProviderModel]:
|
| 101 |
+
provider = config.provider
|
| 102 |
+
if provider == "openrouter":
|
| 103 |
+
return self._openai_compatible_models(
|
| 104 |
+
provider=provider,
|
| 105 |
+
base_url=config.base_url or DEFAULT_OPENROUTER_BASE_URL,
|
| 106 |
+
api_key=config.key_value(),
|
| 107 |
+
)
|
| 108 |
+
if provider == "nebius":
|
| 109 |
+
return self._openai_compatible_models(
|
| 110 |
+
provider=provider,
|
| 111 |
+
base_url=config.base_url or DEFAULT_NEBIUS_BASE_URL,
|
| 112 |
+
api_key=config.key_value(),
|
| 113 |
+
)
|
| 114 |
+
if provider in {"huggingface", "hf"}:
|
| 115 |
+
return self._openai_compatible_models(
|
| 116 |
+
provider="huggingface",
|
| 117 |
+
base_url=config.base_url or DEFAULT_HF_ROUTER_BASE_URL,
|
| 118 |
+
api_key=config.key_value(),
|
| 119 |
+
)
|
| 120 |
+
if provider == "gemini":
|
| 121 |
+
return self._gemini_models(config)
|
| 122 |
+
if provider == "local":
|
| 123 |
+
return self._local_models(config)
|
| 124 |
+
if provider in {"custom", "openai-compatible"}:
|
| 125 |
+
return self._custom_models(config)
|
| 126 |
+
raise ValueError(f"Unsupported model provider: {provider}")
|
| 127 |
+
|
| 128 |
+
def _local_models(self, config: ModelProviderConfig) -> list[ProviderModel]:
|
| 129 |
+
if config.base_url:
|
| 130 |
+
return self._custom_models(config)
|
| 131 |
+
model_id = config.model.strip() or DEFAULT_LOCAL_MODEL
|
| 132 |
+
return [
|
| 133 |
+
_with_parameter_eligibility(
|
| 134 |
+
ProviderModel(
|
| 135 |
+
id=model_id,
|
| 136 |
+
label=model_id,
|
| 137 |
+
provider="local",
|
| 138 |
+
status="available",
|
| 139 |
+
)
|
| 140 |
+
)
|
| 141 |
+
]
|
| 142 |
+
|
| 143 |
+
def _custom_models(self, config: ModelProviderConfig) -> list[ProviderModel]:
|
| 144 |
+
if not config.base_url:
|
| 145 |
+
return []
|
| 146 |
+
try:
|
| 147 |
+
models = self._openai_compatible_models(
|
| 148 |
+
provider=config.provider,
|
| 149 |
+
base_url=config.base_url,
|
| 150 |
+
api_key=config.key_value(),
|
| 151 |
+
)
|
| 152 |
+
except Exception:
|
| 153 |
+
models = []
|
| 154 |
+
if models:
|
| 155 |
+
return models
|
| 156 |
+
return []
|
| 157 |
+
|
| 158 |
+
def _openai_compatible_models(self, *, provider: str, base_url: str, api_key: str) -> list[ProviderModel]:
|
| 159 |
+
payload = self._get_json(_join_url(base_url, "models"), api_key=api_key)
|
| 160 |
+
rows = payload.get("data", payload if isinstance(payload, list) else [])
|
| 161 |
+
return [_provider_model_from_openai_row(provider, row) for row in rows if isinstance(row, dict) and row.get("id")]
|
| 162 |
+
|
| 163 |
+
def _gemini_models(self, config: ModelProviderConfig) -> list[ProviderModel]:
|
| 164 |
+
base_url = config.base_url or DEFAULT_GEMINI_BASE_URL
|
| 165 |
+
query = {"pageSize": "1000"}
|
| 166 |
+
if config.key_value():
|
| 167 |
+
query["key"] = config.key_value()
|
| 168 |
+
url = _join_url(base_url, "models") + "?" + urllib.parse.urlencode(query)
|
| 169 |
+
payload = self._get_json(url, api_key="" if config.key_value() else "")
|
| 170 |
+
rows = payload.get("models", [])
|
| 171 |
+
models: list[ProviderModel] = []
|
| 172 |
+
for row in rows:
|
| 173 |
+
if not isinstance(row, dict):
|
| 174 |
+
continue
|
| 175 |
+
actions = row.get("supportedGenerationMethods") or row.get("supported_actions") or []
|
| 176 |
+
if actions and "generateContent" not in actions:
|
| 177 |
+
continue
|
| 178 |
+
model_id = str(row.get("name", "")).removeprefix("models/")
|
| 179 |
+
if not model_id:
|
| 180 |
+
continue
|
| 181 |
+
models.append(
|
| 182 |
+
_with_parameter_eligibility(
|
| 183 |
+
ProviderModel(
|
| 184 |
+
id=model_id,
|
| 185 |
+
label=str(row.get("displayName") or model_id),
|
| 186 |
+
provider="gemini",
|
| 187 |
+
context_length=row.get("inputTokenLimit"),
|
| 188 |
+
status="available",
|
| 189 |
+
),
|
| 190 |
+
row.get("description"),
|
| 191 |
+
)
|
| 192 |
+
)
|
| 193 |
+
return models
|
| 194 |
+
|
| 195 |
+
def _get_json(self, url: str, *, api_key: str) -> dict[str, Any]:
|
| 196 |
+
headers = {"Accept": "application/json"}
|
| 197 |
+
if api_key:
|
| 198 |
+
headers["Authorization"] = f"Bearer {api_key}"
|
| 199 |
+
req = urllib.request.Request(url, headers=headers)
|
| 200 |
+
with urllib.request.urlopen(req, timeout=self._timeout_seconds) as response:
|
| 201 |
+
return json.loads(response.read().decode("utf-8"))
|
| 202 |
+
|
| 203 |
+
def _cache_key(self, config: ModelProviderConfig) -> str:
|
| 204 |
+
key_hash = hashlib.sha256(config.key_value().encode("utf-8")).hexdigest()[:16] if config.key_value() else "nokey"
|
| 205 |
+
model_key = config.model.strip() if config.provider == "local" and not config.base_url.strip() else ""
|
| 206 |
+
return "|".join([config.provider, config.base_url.strip(), key_hash, model_key])
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
def _provider_model_from_openai_row(provider: str, row: dict[str, Any]) -> ProviderModel:
|
| 210 |
+
provider_rows = row.get("providers") or []
|
| 211 |
+
live_provider = next((item for item in provider_rows if item.get("status") == "live"), None)
|
| 212 |
+
metadata = live_provider if isinstance(live_provider, dict) else row
|
| 213 |
+
pricing = metadata.get("pricing") or row.get("pricing") or {}
|
| 214 |
+
return _with_parameter_eligibility(
|
| 215 |
+
ProviderModel(
|
| 216 |
+
id=str(row["id"]),
|
| 217 |
+
label=str(row.get("name") or row["id"]),
|
| 218 |
+
provider=provider,
|
| 219 |
+
context_length=metadata.get("context_length") or row.get("context_length"),
|
| 220 |
+
supports_tools=metadata.get("supports_tools"),
|
| 221 |
+
supports_structured_output=metadata.get("supports_structured_output"),
|
| 222 |
+
input_price=_float_or_none(pricing.get("input") if isinstance(pricing, dict) else None),
|
| 223 |
+
output_price=_float_or_none(pricing.get("output") if isinstance(pricing, dict) else None),
|
| 224 |
+
status=str(metadata.get("status") or "available"),
|
| 225 |
+
),
|
| 226 |
+
row.get("canonical_slug"),
|
| 227 |
+
row.get("hugging_face_id"),
|
| 228 |
+
row.get("description"),
|
| 229 |
+
metadata.get("description") if isinstance(metadata, dict) else None,
|
| 230 |
+
)
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
def eligible_models(models: list[ProviderModel]) -> list[ProviderModel]:
|
| 234 |
+
return [model for model in models if model.eligible]
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
def _clear_local_runtime_cache() -> None:
|
| 238 |
+
try:
|
| 239 |
+
from .local_model import clear_local_model_cache
|
| 240 |
+
except Exception:
|
| 241 |
+
return
|
| 242 |
+
clear_local_model_cache()
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
def model_id_is_eligible(model_id: str) -> bool:
|
| 246 |
+
parameter_count_b = _parameter_count_b(model_id)
|
| 247 |
+
return parameter_count_b is not None and parameter_count_b <= MAX_MODEL_PARAMETERS_B
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
def model_id_parameter_count_b(model_id: str) -> float | None:
|
| 251 |
+
return _parameter_count_b(model_id)
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
def _with_parameter_eligibility(model: ProviderModel, *metadata_texts: Any) -> ProviderModel:
|
| 255 |
+
parameter_count_b = _first_parameter_count_b(model.id, model.label, *metadata_texts)
|
| 256 |
+
if parameter_count_b is None:
|
| 257 |
+
return model.model_copy(update={"parameter_count_b": None, "eligible": False, "status": "unknown_parameters"})
|
| 258 |
+
return model.model_copy(
|
| 259 |
+
update={
|
| 260 |
+
"parameter_count_b": parameter_count_b,
|
| 261 |
+
"eligible": parameter_count_b <= MAX_MODEL_PARAMETERS_B,
|
| 262 |
+
"status": "available" if parameter_count_b <= MAX_MODEL_PARAMETERS_B else "too_large",
|
| 263 |
+
}
|
| 264 |
+
)
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
def _first_parameter_count_b(*texts: Any) -> float | None:
|
| 268 |
+
for text in texts:
|
| 269 |
+
if text is None:
|
| 270 |
+
continue
|
| 271 |
+
count = _parameter_count_b(str(text))
|
| 272 |
+
if count is not None:
|
| 273 |
+
return count
|
| 274 |
+
return None
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
def _parameter_count_b(text: str) -> float | None:
|
| 278 |
+
import re
|
| 279 |
+
|
| 280 |
+
lowered = text.lower()
|
| 281 |
+
# Matches 7b, 8x7b, 32-billion, 0.5b. MoE names use active-ish sum as conservative upper bound.
|
| 282 |
+
total = 0.0
|
| 283 |
+
found = False
|
| 284 |
+
for multiplier, value in re.findall(r"(?:(\d+)\s*x\s*)?(\d+(?:\.\d+)?)\s*(?:b|bn|billion)\b", lowered):
|
| 285 |
+
count = float(value) * (int(multiplier) if multiplier else 1)
|
| 286 |
+
total += count
|
| 287 |
+
found = True
|
| 288 |
+
if found:
|
| 289 |
+
return total
|
| 290 |
+
# Common size shorthand in model IDs: 12B-it often appears as 12b-it after lower().
|
| 291 |
+
match = re.search(r"(?<!\d)(\d+(?:\.\d+)?)[-_ ]?b(?:[-_./ ]|$)", lowered)
|
| 292 |
+
return float(match.group(1)) if match else None
|
| 293 |
+
|
| 294 |
+
|
| 295 |
+
def _float_or_none(value: Any) -> float | None:
|
| 296 |
+
if value is None:
|
| 297 |
+
return None
|
| 298 |
+
try:
|
| 299 |
+
return float(value)
|
| 300 |
+
except (TypeError, ValueError):
|
| 301 |
+
return None
|
| 302 |
+
|
| 303 |
+
|
| 304 |
+
def _join_url(base_url: str, path: str) -> str:
|
| 305 |
+
return base_url.rstrip("/") + "/" + path.lstrip("/")
|
| 306 |
+
|
| 307 |
+
|
| 308 |
+
model_catalog = ModelCatalog()
|
arena/model_provider.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Chat model provider factory for Swarm sessions."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import os
|
| 6 |
+
from typing import Any
|
| 7 |
+
|
| 8 |
+
from langchain.chat_models import init_chat_model
|
| 9 |
+
|
| 10 |
+
from .model_catalog import (
|
| 11 |
+
DEFAULT_GEMINI_BASE_URL,
|
| 12 |
+
DEFAULT_HF_ROUTER_BASE_URL,
|
| 13 |
+
DEFAULT_LOCAL_MODEL,
|
| 14 |
+
DEFAULT_NEBIUS_BASE_URL,
|
| 15 |
+
DEFAULT_OPENROUTER_BASE_URL,
|
| 16 |
+
ModelProviderConfig,
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
DEFAULT_MODEL = "openai/o4-mini"
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def normalize_openrouter_model(model: str) -> str:
|
| 24 |
+
normalized = model.strip()
|
| 25 |
+
for prefix in ("openrouter:", "openrouter/"):
|
| 26 |
+
if normalized.startswith(prefix):
|
| 27 |
+
return normalized.removeprefix(prefix)
|
| 28 |
+
return normalized
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def create_chat_model(config: ModelProviderConfig | None = None) -> Any:
|
| 32 |
+
is_env_config = config is None
|
| 33 |
+
config = config or _config_from_env()
|
| 34 |
+
provider = config.provider
|
| 35 |
+
if not is_env_config and _hosted_provider(provider) and not config.key_value():
|
| 36 |
+
raise ValueError(_missing_key_message(provider))
|
| 37 |
+
if provider == "openrouter":
|
| 38 |
+
return _openrouter_model(config)
|
| 39 |
+
if provider == "gemini":
|
| 40 |
+
return _gemini_model(config)
|
| 41 |
+
if provider == "local" and not config.base_url.strip():
|
| 42 |
+
return _cached_local_model(config)
|
| 43 |
+
if provider in {"custom", "openai-compatible", "local", "nebius", "huggingface", "hf"}:
|
| 44 |
+
return _openai_compatible_model(config)
|
| 45 |
+
raise ValueError(f"Unsupported model provider: {provider}")
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _config_from_env() -> ModelProviderConfig:
|
| 49 |
+
provider = os.getenv("DEEPAGENT_MODEL_PROVIDER", "openrouter")
|
| 50 |
+
return ModelProviderConfig(
|
| 51 |
+
provider=provider,
|
| 52 |
+
model=os.getenv("DEEPAGENT_MODEL", DEFAULT_MODEL),
|
| 53 |
+
api_key=_env_key_for_provider(provider),
|
| 54 |
+
base_url=os.getenv("DEEPAGENT_MODEL_BASE_URL", ""),
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def _openrouter_model(config: ModelProviderConfig) -> Any:
|
| 59 |
+
model = config.model.strip() or DEFAULT_MODEL
|
| 60 |
+
model = normalize_openrouter_model(model)
|
| 61 |
+
api_key = config.key_value()
|
| 62 |
+
kwargs: dict[str, Any] = {"temperature": 0.2, "max_tokens": 4096}
|
| 63 |
+
if api_key:
|
| 64 |
+
kwargs["api_key"] = api_key
|
| 65 |
+
if config.base_url and config.base_url != DEFAULT_OPENROUTER_BASE_URL:
|
| 66 |
+
kwargs["base_url"] = config.base_url
|
| 67 |
+
return init_chat_model(f"openrouter:{model}", **kwargs)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def _openai_compatible_model(config: ModelProviderConfig) -> Any:
|
| 71 |
+
provider = config.provider
|
| 72 |
+
model = config.model.strip() or DEFAULT_MODEL
|
| 73 |
+
base_url = config.base_url.strip() or _default_base_url(provider)
|
| 74 |
+
api_key = config.key_value()
|
| 75 |
+
from langchain_openai import ChatOpenAI
|
| 76 |
+
|
| 77 |
+
return ChatOpenAI(
|
| 78 |
+
model=model,
|
| 79 |
+
api_key=api_key or _placeholder_api_key(provider),
|
| 80 |
+
base_url=base_url,
|
| 81 |
+
temperature=0.2,
|
| 82 |
+
max_tokens=4096,
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def _cached_local_model(config: ModelProviderConfig) -> Any:
|
| 87 |
+
from .local_model import CachedLocalChatModel
|
| 88 |
+
|
| 89 |
+
return CachedLocalChatModel(
|
| 90 |
+
model_id=config.model.strip() or DEFAULT_LOCAL_MODEL,
|
| 91 |
+
temperature=0.2,
|
| 92 |
+
max_new_tokens=int(os.getenv("LOCAL_MODEL_MAX_NEW_TOKENS", "2048")),
|
| 93 |
+
cache_ttl_seconds=int(os.getenv("LOCAL_MODEL_CACHE_TTL_SECONDS", str(30 * 60))),
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def _gemini_model(config: ModelProviderConfig) -> Any:
|
| 98 |
+
try:
|
| 99 |
+
from langchain_google_genai import ChatGoogleGenerativeAI
|
| 100 |
+
except ImportError as exc:
|
| 101 |
+
raise RuntimeError(
|
| 102 |
+
"Gemini provider requires langchain-google-genai. Install dependencies from requirements.txt."
|
| 103 |
+
) from exc
|
| 104 |
+
model = config.model.strip() or os.getenv("GEMINI_MODEL", "gemini-2.5-flash")
|
| 105 |
+
api_key = config.key_value()
|
| 106 |
+
kwargs: dict[str, Any] = {"model": model, "temperature": 0.2, "max_output_tokens": 4096}
|
| 107 |
+
if api_key:
|
| 108 |
+
kwargs["api_key"] = api_key
|
| 109 |
+
if config.base_url and config.base_url != DEFAULT_GEMINI_BASE_URL:
|
| 110 |
+
kwargs["client_options"] = {"api_endpoint": config.base_url}
|
| 111 |
+
return ChatGoogleGenerativeAI(**kwargs)
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def _default_base_url(provider: str) -> str:
|
| 115 |
+
if provider == "openrouter":
|
| 116 |
+
return DEFAULT_OPENROUTER_BASE_URL
|
| 117 |
+
if provider == "nebius":
|
| 118 |
+
return DEFAULT_NEBIUS_BASE_URL
|
| 119 |
+
if provider in {"huggingface", "hf"}:
|
| 120 |
+
return DEFAULT_HF_ROUTER_BASE_URL
|
| 121 |
+
if provider == "local":
|
| 122 |
+
return os.getenv("LOCAL_MODEL_BASE_URL", "http://127.0.0.1:8000/v1")
|
| 123 |
+
return ""
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def _placeholder_api_key(provider: str) -> str:
|
| 127 |
+
if provider in {"local", "custom", "openai-compatible"}:
|
| 128 |
+
return "not-needed"
|
| 129 |
+
return ""
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def _hosted_provider(provider: str) -> bool:
|
| 133 |
+
return provider in {"openrouter", "gemini", "nebius", "huggingface", "hf"}
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def _missing_key_message(provider: str) -> str:
|
| 137 |
+
if provider == "openrouter":
|
| 138 |
+
return "OPENROUTER_API_KEY must be set"
|
| 139 |
+
if provider == "gemini":
|
| 140 |
+
return "GEMINI_API_KEY must be set"
|
| 141 |
+
if provider == "nebius":
|
| 142 |
+
return "NEBIUS_API_KEY must be set"
|
| 143 |
+
if provider in {"huggingface", "hf"}:
|
| 144 |
+
return "HF_TOKEN or HUGGINGFACE_API_KEY must be set"
|
| 145 |
+
return "Model provider API key must be set"
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def _env_key_for_provider(provider: str) -> str:
|
| 149 |
+
provider = provider.lower().replace("_", "-")
|
| 150 |
+
if provider == "openrouter":
|
| 151 |
+
return os.getenv("OPENROUTER_API_KEY", "")
|
| 152 |
+
if provider == "nebius":
|
| 153 |
+
return os.getenv("NEBIUS_API_KEY", "")
|
| 154 |
+
if provider in {"huggingface", "hf"}:
|
| 155 |
+
return os.getenv("HF_TOKEN", "") or os.getenv("HUGGINGFACE_API_KEY", "")
|
| 156 |
+
if provider == "gemini":
|
| 157 |
+
return os.getenv("GEMINI_API_KEY", "") or os.getenv("GOOGLE_API_KEY", "")
|
| 158 |
+
if provider == "local":
|
| 159 |
+
return os.getenv("LOCAL_MODEL_API_KEY", "")
|
| 160 |
+
return os.getenv("CUSTOM_MODEL_API_KEY", "")
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def default_model_for_provider(provider: str) -> str:
|
| 164 |
+
if provider == "local":
|
| 165 |
+
return os.getenv("LOCAL_MODEL_ID", DEFAULT_LOCAL_MODEL)
|
| 166 |
+
if provider == "gemini":
|
| 167 |
+
return os.getenv("GEMINI_MODEL", "gemini-2.5-flash")
|
| 168 |
+
return os.getenv("DEEPAGENT_MODEL", DEFAULT_MODEL)
|
arena/models.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Domain records for the agent swarm workbench."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from datetime import datetime, timezone
|
| 6 |
+
from typing import Literal
|
| 7 |
+
|
| 8 |
+
from pydantic import BaseModel, Field, field_validator, model_validator
|
| 9 |
+
|
| 10 |
+
from .model_catalog import ModelProviderConfig, ModelProviderSelection
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
RunStatus = Literal["queued", "planning", "working", "validating", "completed", "failed", "interrupted"]
|
| 14 |
+
TaskStatus = Literal["planned", "active", "blocked", "completed", "failed", "interrupted"]
|
| 15 |
+
ValidationCheckSource = Literal["generated", "user", "stagehand"]
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def utc_now() -> datetime:
|
| 19 |
+
return datetime.now(timezone.utc)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class Criteria(BaseModel):
|
| 23 |
+
text: str = Field(min_length=1)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class ValidationCheck(BaseModel):
|
| 27 |
+
id: str = Field(min_length=1)
|
| 28 |
+
name: str = Field(min_length=1)
|
| 29 |
+
command: str = Field(min_length=1)
|
| 30 |
+
source: ValidationCheckSource = "generated"
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class RunRequest(BaseModel):
|
| 34 |
+
prompt: str = Field(min_length=1)
|
| 35 |
+
criteria: list[Criteria] = Field(default_factory=list)
|
| 36 |
+
user_tests: list[str] = Field(default_factory=list)
|
| 37 |
+
model_provider: ModelProviderConfig | None = None
|
| 38 |
+
|
| 39 |
+
@field_validator("user_tests")
|
| 40 |
+
@classmethod
|
| 41 |
+
def require_non_empty_user_tests(cls, value: list[str]) -> list[str]:
|
| 42 |
+
if any(not test.strip() for test in value):
|
| 43 |
+
raise ValueError("user tests must be non-empty")
|
| 44 |
+
return value
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
class RunTask(BaseModel):
|
| 48 |
+
id: str = Field(min_length=1)
|
| 49 |
+
title: str = Field(min_length=1)
|
| 50 |
+
status: TaskStatus = "planned"
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
class RunEvent(BaseModel):
|
| 54 |
+
id: str = Field(min_length=1)
|
| 55 |
+
message: str = Field(min_length=1)
|
| 56 |
+
created_at: datetime = Field(default_factory=utc_now)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
class RunArtifact(BaseModel):
|
| 60 |
+
id: str = Field(min_length=1)
|
| 61 |
+
name: str = Field(min_length=1)
|
| 62 |
+
archive_pointer: str = Field(min_length=1)
|
| 63 |
+
created_at: datetime = Field(default_factory=utc_now)
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
class ValidationCheckResult(BaseModel):
|
| 67 |
+
check_id: str = Field(min_length=1)
|
| 68 |
+
passed: bool
|
| 69 |
+
output: str = ""
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
class ValidationReport(BaseModel):
|
| 73 |
+
run_id: str = Field(min_length=1)
|
| 74 |
+
passed: bool
|
| 75 |
+
results: list[ValidationCheckResult] = Field(default_factory=list)
|
| 76 |
+
summary: str = ""
|
| 77 |
+
risks: list[str] = Field(default_factory=list)
|
| 78 |
+
rubric_score: float | None = None
|
| 79 |
+
rubric_feedback: str = ""
|
| 80 |
+
created_at: datetime = Field(default_factory=utc_now)
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
class CodebaseArchive(BaseModel):
|
| 84 |
+
pointer: str = Field(min_length=1)
|
| 85 |
+
file_count: int = Field(ge=0)
|
| 86 |
+
size_bytes: int = Field(ge=0)
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
class RunView(BaseModel):
|
| 90 |
+
id: str
|
| 91 |
+
prompt: str
|
| 92 |
+
status: RunStatus
|
| 93 |
+
criteria: list[Criteria]
|
| 94 |
+
user_tests: list[str]
|
| 95 |
+
tasks: list[RunTask]
|
| 96 |
+
events: list[RunEvent]
|
| 97 |
+
artifacts: list[RunArtifact]
|
| 98 |
+
validation_checks: list[ValidationCheck]
|
| 99 |
+
validation_report: ValidationReport | None = None
|
| 100 |
+
codebase_archive: CodebaseArchive | None = None
|
| 101 |
+
model_provider: ModelProviderSelection | None = None
|
| 102 |
+
summary: str = ""
|
| 103 |
+
created_at: datetime
|
| 104 |
+
updated_at: datetime
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
class Run(BaseModel):
|
| 108 |
+
id: str = Field(min_length=1)
|
| 109 |
+
prompt: str = Field(min_length=1)
|
| 110 |
+
rejoin_token: str = Field(min_length=24)
|
| 111 |
+
status: RunStatus = "queued"
|
| 112 |
+
criteria: list[Criteria] = Field(default_factory=list)
|
| 113 |
+
user_tests: list[str] = Field(default_factory=list)
|
| 114 |
+
tasks: list[RunTask] = Field(default_factory=list)
|
| 115 |
+
events: list[RunEvent] = Field(default_factory=list)
|
| 116 |
+
artifacts: list[RunArtifact] = Field(default_factory=list)
|
| 117 |
+
validation_checks: list[ValidationCheck] = Field(default_factory=list)
|
| 118 |
+
validation_report: ValidationReport | None = None
|
| 119 |
+
codebase_archive: CodebaseArchive | None = None
|
| 120 |
+
model_provider: ModelProviderSelection | None = None
|
| 121 |
+
summary: str = ""
|
| 122 |
+
created_at: datetime = Field(default_factory=utc_now)
|
| 123 |
+
updated_at: datetime = Field(default_factory=utc_now)
|
| 124 |
+
|
| 125 |
+
@model_validator(mode="after")
|
| 126 |
+
def validate_completion_has_report(self) -> "Run":
|
| 127 |
+
if self.status == "completed" and self.validation_report is None:
|
| 128 |
+
raise ValueError("completed run requires validation report")
|
| 129 |
+
if self.status == "completed" and self.codebase_archive is None:
|
| 130 |
+
raise ValueError("completed run requires codebase archive")
|
| 131 |
+
if self.validation_report is not None and self.validation_report.run_id != self.id:
|
| 132 |
+
raise ValueError("validation report run id must match run id")
|
| 133 |
+
return self
|
| 134 |
+
|
| 135 |
+
def to_view(self) -> RunView:
|
| 136 |
+
return RunView(
|
| 137 |
+
id=self.id,
|
| 138 |
+
prompt=self.prompt,
|
| 139 |
+
status=self.status,
|
| 140 |
+
criteria=self.criteria,
|
| 141 |
+
user_tests=self.user_tests,
|
| 142 |
+
tasks=self.tasks,
|
| 143 |
+
events=sorted(self.events, key=lambda event: event.created_at),
|
| 144 |
+
artifacts=self.artifacts,
|
| 145 |
+
validation_checks=self.validation_checks,
|
| 146 |
+
validation_report=self.validation_report,
|
| 147 |
+
codebase_archive=self.codebase_archive,
|
| 148 |
+
model_provider=self.model_provider,
|
| 149 |
+
summary=self.summary,
|
| 150 |
+
created_at=self.created_at,
|
| 151 |
+
updated_at=self.updated_at,
|
| 152 |
+
)
|
arena/paths.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared filesystem paths for the Arena package."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
PACKAGE_ROOT = Path(__file__).resolve().parent
|
| 9 |
+
REPO_ROOT = PACKAGE_ROOT.parent
|
| 10 |
+
SKILLS_ROOT = REPO_ROOT / "skills"
|
arena/redis_client.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Redis client primitives shared by Run and Codebase storage seams.
|
| 2 |
+
|
| 3 |
+
These primitives are domain-neutral so the prompt-first stores can depend on
|
| 4 |
+
them without dragging legacy Match code along. ``arena.match_store`` re-exports
|
| 5 |
+
these names for backward compatibility while legacy modules are still in tree.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import json
|
| 11 |
+
from dataclasses import dataclass
|
| 12 |
+
from typing import Protocol
|
| 13 |
+
from urllib import request
|
| 14 |
+
from urllib.error import HTTPError, URLError
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class RedisStoreError(ValueError):
|
| 18 |
+
pass
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class RedisClient(Protocol):
|
| 22 |
+
def execute(self, *args: str):
|
| 23 |
+
...
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
@dataclass
|
| 27 |
+
class UpstashRedisRestClient:
|
| 28 |
+
url: str
|
| 29 |
+
token: str
|
| 30 |
+
timeout_seconds: int = 10
|
| 31 |
+
|
| 32 |
+
def execute(self, *args: str):
|
| 33 |
+
payload = json.dumps(list(args)).encode("utf-8")
|
| 34 |
+
req = request.Request(
|
| 35 |
+
self.url.rstrip("/"),
|
| 36 |
+
data=payload,
|
| 37 |
+
method="POST",
|
| 38 |
+
headers={
|
| 39 |
+
"Authorization": f"Bearer {self.token}",
|
| 40 |
+
"Content-Type": "application/json",
|
| 41 |
+
},
|
| 42 |
+
)
|
| 43 |
+
try:
|
| 44 |
+
with request.urlopen(req, timeout=self.timeout_seconds) as response:
|
| 45 |
+
body = response.read().decode("utf-8")
|
| 46 |
+
except HTTPError as exc:
|
| 47 |
+
raise RedisStoreError(f"upstash redis command failed: HTTP {exc.code}") from exc
|
| 48 |
+
except URLError as exc:
|
| 49 |
+
raise RedisStoreError("upstash redis command failed") from exc
|
| 50 |
+
|
| 51 |
+
try:
|
| 52 |
+
data = json.loads(body)
|
| 53 |
+
except json.JSONDecodeError as exc:
|
| 54 |
+
raise RedisStoreError("upstash redis returned invalid json") from exc
|
| 55 |
+
if "error" in data:
|
| 56 |
+
raise RedisStoreError(str(data["error"]))
|
| 57 |
+
return data.get("result")
|
arena/run_flow.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Prompt-first Run lifecycle conductor."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import queue
|
| 6 |
+
import threading
|
| 7 |
+
from dataclasses import dataclass
|
| 8 |
+
from typing import Any
|
| 9 |
+
|
| 10 |
+
from .codebase_archive import CodebaseArchiveStore, archive_to_zip_bytes
|
| 11 |
+
from .event_bus import EventBus
|
| 12 |
+
from .run_models import (
|
| 13 |
+
Run,
|
| 14 |
+
RunRequest,
|
| 15 |
+
RunView,
|
| 16 |
+
)
|
| 17 |
+
from .validation_models import (
|
| 18 |
+
ValidationReport,
|
| 19 |
+
)
|
| 20 |
+
from .run_journal import InvalidRunStatusTransition, RunJournal
|
| 21 |
+
from .run_store import RunAccessView, RunStore
|
| 22 |
+
from .service_payloads import ZipPayload
|
| 23 |
+
from .swarm_runtime import SwarmRuntime
|
| 24 |
+
from .validator_graph import ValidatorGraph
|
| 25 |
+
from .validator_plan import ValidatorPlan
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class RunFlowError(ValueError):
|
| 29 |
+
pass
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class RunArchiveNotFound(RunFlowError):
|
| 33 |
+
pass
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
@dataclass
|
| 37 |
+
class RunFlow:
|
| 38 |
+
run_store: RunStore
|
| 39 |
+
swarm_runtime: SwarmRuntime
|
| 40 |
+
validator_graph: ValidatorGraph
|
| 41 |
+
archive_store: CodebaseArchiveStore
|
| 42 |
+
event_bus: EventBus | None = None
|
| 43 |
+
journal: RunJournal | None = None
|
| 44 |
+
|
| 45 |
+
def __post_init__(self) -> None:
|
| 46 |
+
if self.event_bus is None:
|
| 47 |
+
self.event_bus = EventBus()
|
| 48 |
+
if self.journal is None:
|
| 49 |
+
self.journal = RunJournal(run_store=self.run_store, event_bus=self.event_bus)
|
| 50 |
+
|
| 51 |
+
def create_run(self, request: RunRequest) -> RunAccessView:
|
| 52 |
+
access = self._create_and_seed_run(request)
|
| 53 |
+
self.journal.transition(access.run.id, "working")
|
| 54 |
+
self._execute_swarm_and_validate(access.run.id, request.user_tests)
|
| 55 |
+
return RunAccessView(token=access.token, run=self.journal.get_run(access.run.id))
|
| 56 |
+
|
| 57 |
+
def start_run(self, request: RunRequest) -> RunAccessView:
|
| 58 |
+
access = self._create_and_seed_run(request)
|
| 59 |
+
self.journal.transition(access.run.id, "planning")
|
| 60 |
+
self.journal.publish(access.run.id, {"kind": "run_started", "run_id": access.run.id})
|
| 61 |
+
threading.Thread(
|
| 62 |
+
target=self._execute_background,
|
| 63 |
+
args=(access.run.id, request.user_tests),
|
| 64 |
+
name=f"run-{access.run.id}",
|
| 65 |
+
daemon=True,
|
| 66 |
+
).start()
|
| 67 |
+
return access
|
| 68 |
+
|
| 69 |
+
def rejoin(self, token: str) -> RunAccessView:
|
| 70 |
+
return self.journal.rejoin(token)
|
| 71 |
+
|
| 72 |
+
def get_run(self, run_id: str) -> RunView:
|
| 73 |
+
return self.journal.get_run(run_id)
|
| 74 |
+
|
| 75 |
+
def cancel_run(self, run_id: str) -> RunView:
|
| 76 |
+
self.swarm_runtime.close_run(run_id)
|
| 77 |
+
return self.journal.cancel_run(run_id)
|
| 78 |
+
|
| 79 |
+
def add_run_message(self, run_id: str, message: str) -> RunView:
|
| 80 |
+
self.swarm_runtime.touch_run(run_id)
|
| 81 |
+
return self.journal.record_user_message(run_id, message)
|
| 82 |
+
|
| 83 |
+
def subscribe_events(self, run_id: str) -> queue.Queue[dict[str, Any] | None]:
|
| 84 |
+
return self.journal.subscribe_events(run_id)
|
| 85 |
+
|
| 86 |
+
def get_codebase_zip(self, run_id: str) -> ZipPayload:
|
| 87 |
+
run = self.journal.get_run(run_id)
|
| 88 |
+
if run.codebase_archive is None:
|
| 89 |
+
raise RunArchiveNotFound("run has no codebase archive")
|
| 90 |
+
archive = self.archive_store.load(run.codebase_archive.pointer)
|
| 91 |
+
return ZipPayload(
|
| 92 |
+
filename=f"{run.id}.zip",
|
| 93 |
+
data=archive_to_zip_bytes(archive),
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
def validate_run(self, run_id: str) -> ValidationReport:
|
| 97 |
+
run = self.journal.get_run(run_id)
|
| 98 |
+
if run.validation_report is not None:
|
| 99 |
+
if run.status != "completed":
|
| 100 |
+
self.journal.transition(run.id, "completed")
|
| 101 |
+
return run.validation_report
|
| 102 |
+
if run.codebase_archive is None:
|
| 103 |
+
raise RunArchiveNotFound("run has no codebase archive")
|
| 104 |
+
context = self.journal.build_validator_context(run.id)
|
| 105 |
+
return self._validate_completed_run(
|
| 106 |
+
run.id,
|
| 107 |
+
plan=ValidatorPlan.from_checks(run.validation_checks, context=context),
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
def _execute_background(self, run_id: str, user_tests: list[str]) -> None:
|
| 111 |
+
try:
|
| 112 |
+
self.journal.transition(run_id, "working")
|
| 113 |
+
self._execute_swarm_and_validate(run_id, user_tests)
|
| 114 |
+
if not self.journal.is_interrupted(run_id):
|
| 115 |
+
self.journal.publish(run_id, {"kind": "swarm_complete", "run_id": run_id})
|
| 116 |
+
except Exception as exc:
|
| 117 |
+
import traceback
|
| 118 |
+
tb = traceback.format_exc()
|
| 119 |
+
msg = f"{exc}\n{tb[-1000:]}"
|
| 120 |
+
print(f"[RUN FAILED {run_id}] {msg}", flush=True)
|
| 121 |
+
self.swarm_runtime.close_run(run_id)
|
| 122 |
+
if not self.journal.is_interrupted(run_id):
|
| 123 |
+
self.journal.transition(run_id, "failed")
|
| 124 |
+
self.journal.publish(run_id, {"kind": "error", "run_id": run_id, "message": str(exc)})
|
| 125 |
+
self.journal.close_stream(run_id)
|
| 126 |
+
|
| 127 |
+
def _create_and_seed_run(self, request: RunRequest) -> RunAccessView:
|
| 128 |
+
access = self.journal.create_run(request)
|
| 129 |
+
try:
|
| 130 |
+
self.swarm_runtime.start_run(_run_from_access(access), model_provider=request.model_provider)
|
| 131 |
+
except TypeError:
|
| 132 |
+
self.swarm_runtime.start_run(_run_from_access(access))
|
| 133 |
+
return access
|
| 134 |
+
|
| 135 |
+
def _execute_swarm_and_validate(self, run_id: str, user_tests: list[str]) -> ValidationReport:
|
| 136 |
+
result = self.swarm_runtime.execute_run(run_id, user_tests=user_tests)
|
| 137 |
+
if self.journal.is_interrupted(run_id):
|
| 138 |
+
self.journal.close_stream(run_id)
|
| 139 |
+
return ValidationReport(
|
| 140 |
+
run_id=run_id,
|
| 141 |
+
passed=False,
|
| 142 |
+
summary="Run interrupted before validation.",
|
| 143 |
+
risks=["Run was cancelled before validation completed."],
|
| 144 |
+
)
|
| 145 |
+
self.journal.save_swarm_result(run_id, result)
|
| 146 |
+
context = self.journal.build_validator_context(run_id)
|
| 147 |
+
plan = ValidatorPlan.from_user_tests(user_tests, context=context)
|
| 148 |
+
self.journal.save_validation_checks(run_id, plan.checks)
|
| 149 |
+
return self._validate_completed_run(run_id, plan=plan)
|
| 150 |
+
|
| 151 |
+
def _validate_completed_run(self, run_id: str, *, plan: ValidatorPlan | None = None) -> ValidationReport:
|
| 152 |
+
run = self.journal.get_run(run_id)
|
| 153 |
+
if run.codebase_archive is None:
|
| 154 |
+
raise RunArchiveNotFound("run has no codebase archive")
|
| 155 |
+
if plan is None:
|
| 156 |
+
context = self.journal.build_validator_context(run.id)
|
| 157 |
+
plan = ValidatorPlan.from_checks(run.validation_checks, context=context)
|
| 158 |
+
self.journal.begin_validation(run.id)
|
| 159 |
+
report = self.validator_graph.validate(
|
| 160 |
+
run_id=run.id,
|
| 161 |
+
codebase_archive=run.codebase_archive,
|
| 162 |
+
plan=plan,
|
| 163 |
+
)
|
| 164 |
+
self.journal.complete_validation(run.id, report)
|
| 165 |
+
return report
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def _run_from_access(access: RunAccessView) -> Run:
|
| 169 |
+
return Run(
|
| 170 |
+
id=access.run.id,
|
| 171 |
+
prompt=access.run.prompt,
|
| 172 |
+
criteria=access.run.criteria,
|
| 173 |
+
user_tests=access.run.user_tests,
|
| 174 |
+
model_provider=access.run.model_provider,
|
| 175 |
+
rejoin_token=access.token,
|
| 176 |
+
status=access.run.status,
|
| 177 |
+
)
|
arena/run_journal.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Run journal module for status, event, task, and stream persistence."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import queue
|
| 6 |
+
from dataclasses import dataclass
|
| 7 |
+
from typing import Any
|
| 8 |
+
from uuid import uuid4
|
| 9 |
+
|
| 10 |
+
from .event_bus import EventBus
|
| 11 |
+
from .run_models import (
|
| 12 |
+
CodebaseArchive,
|
| 13 |
+
RunEvent,
|
| 14 |
+
RunRequest,
|
| 15 |
+
RunStatus,
|
| 16 |
+
RunTask,
|
| 17 |
+
RunView,
|
| 18 |
+
)
|
| 19 |
+
from .run_store import RunAccessView, RunStore
|
| 20 |
+
from .validation_models import ValidationCheck, ValidationReport
|
| 21 |
+
from .validator_plan import ValidatorContext
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class RunJournalError(ValueError):
|
| 25 |
+
pass
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class InvalidRunStatusTransition(RunJournalError):
|
| 29 |
+
pass
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
TERMINAL_STATUSES: frozenset[RunStatus] = frozenset({"completed", "failed", "interrupted"})
|
| 33 |
+
|
| 34 |
+
STATUS_TRANSITIONS: dict[RunStatus, frozenset[RunStatus]] = {
|
| 35 |
+
"queued": frozenset({"planning", "working", "validating", "interrupted", "failed"}),
|
| 36 |
+
"planning": frozenset({"working", "validating", "completed", "interrupted", "failed"}),
|
| 37 |
+
"working": frozenset({"validating", "completed", "interrupted", "failed"}),
|
| 38 |
+
"validating": frozenset({"completed", "interrupted", "failed"}),
|
| 39 |
+
"completed": frozenset({"interrupted"}),
|
| 40 |
+
"failed": frozenset({"interrupted"}),
|
| 41 |
+
"interrupted": frozenset(),
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
@dataclass
|
| 46 |
+
class RunJournal:
|
| 47 |
+
"""Owns persisted Run mutation and stream publication rules."""
|
| 48 |
+
|
| 49 |
+
run_store: RunStore
|
| 50 |
+
event_bus: EventBus
|
| 51 |
+
|
| 52 |
+
def create_run(self, request: RunRequest) -> RunAccessView:
|
| 53 |
+
access = self.run_store.create_run(request)
|
| 54 |
+
self.event_bus.add_listener(access.run.id, self._persist_runtime_event)
|
| 55 |
+
return access
|
| 56 |
+
|
| 57 |
+
def rejoin(self, token: str) -> RunAccessView:
|
| 58 |
+
return self.run_store.rejoin(token)
|
| 59 |
+
|
| 60 |
+
def get_run(self, run_id: str) -> RunView:
|
| 61 |
+
return self.run_store.get_run_view(run_id)
|
| 62 |
+
|
| 63 |
+
def subscribe_events(self, run_id: str) -> queue.Queue[dict[str, Any] | None]:
|
| 64 |
+
return self.event_bus.subscribe(run_id)
|
| 65 |
+
|
| 66 |
+
def publish(self, run_id: str, event: dict[str, Any]) -> None:
|
| 67 |
+
self.event_bus.publish(run_id, event)
|
| 68 |
+
|
| 69 |
+
def close_stream(self, run_id: str) -> None:
|
| 70 |
+
self.event_bus.close(run_id)
|
| 71 |
+
|
| 72 |
+
def transition(self, run_id: str, target: RunStatus) -> RunView:
|
| 73 |
+
current = self.get_run(run_id).status
|
| 74 |
+
allowed = STATUS_TRANSITIONS.get(current, frozenset())
|
| 75 |
+
if target not in allowed:
|
| 76 |
+
raise InvalidRunStatusTransition(
|
| 77 |
+
f"cannot transition run {run_id} from {current!r} to {target!r}"
|
| 78 |
+
)
|
| 79 |
+
return self.run_store.save_status(run_id, target)
|
| 80 |
+
|
| 81 |
+
def is_interrupted(self, run_id: str) -> bool:
|
| 82 |
+
return self.get_run(run_id).status == "interrupted"
|
| 83 |
+
|
| 84 |
+
def cancel_run(self, run_id: str) -> RunView:
|
| 85 |
+
view = self.get_run(run_id)
|
| 86 |
+
if view.status in TERMINAL_STATUSES:
|
| 87 |
+
return view
|
| 88 |
+
try:
|
| 89 |
+
self.run_store.mark_active_tasks_interrupted(run_id)
|
| 90 |
+
except Exception:
|
| 91 |
+
pass
|
| 92 |
+
self.transition(run_id, "interrupted")
|
| 93 |
+
self.close_stream(run_id)
|
| 94 |
+
return self.get_run(run_id)
|
| 95 |
+
|
| 96 |
+
def record_user_message(self, run_id: str, message: str) -> RunView:
|
| 97 |
+
event = RunEvent(id=f"event_{uuid4().hex}", message=f"User: {message.strip()}")
|
| 98 |
+
view = self.run_store.append_event(run_id, event)
|
| 99 |
+
self.publish(run_id, {"kind": "event", "event": event.model_dump(mode="json")})
|
| 100 |
+
return view
|
| 101 |
+
|
| 102 |
+
def save_swarm_result(self, run_id: str, result: Any) -> None:
|
| 103 |
+
self.run_store.save_tasks(run_id, result.tasks)
|
| 104 |
+
for event in result.events:
|
| 105 |
+
self.run_store.append_event(run_id, event)
|
| 106 |
+
self.run_store.save_codebase_archive(run_id, result.codebase_archive)
|
| 107 |
+
self.run_store.save_summary(run_id, result.summary)
|
| 108 |
+
|
| 109 |
+
def save_codebase_archive(self, run_id: str, archive: CodebaseArchive) -> RunView:
|
| 110 |
+
return self.run_store.save_codebase_archive(run_id, archive)
|
| 111 |
+
|
| 112 |
+
def save_validation_checks(self, run_id: str, checks: list[ValidationCheck]) -> RunView:
|
| 113 |
+
return self.run_store.save_validation_checks(run_id, checks)
|
| 114 |
+
|
| 115 |
+
def save_validation_report(self, run_id: str, report: ValidationReport) -> RunView:
|
| 116 |
+
return self.run_store.save_validation_report(run_id, report)
|
| 117 |
+
|
| 118 |
+
def build_validator_context(self, run_id: str) -> ValidatorContext:
|
| 119 |
+
run = self.get_run(run_id)
|
| 120 |
+
return ValidatorContext(prompt=run.prompt, criteria=tuple(run.criteria))
|
| 121 |
+
|
| 122 |
+
def begin_validation(self, run_id: str) -> None:
|
| 123 |
+
self.transition(run_id, "validating")
|
| 124 |
+
self.publish(run_id, {"kind": "validating", "run_id": run_id, "status": "validating"})
|
| 125 |
+
|
| 126 |
+
def complete_validation(self, run_id: str, report: ValidationReport) -> None:
|
| 127 |
+
self.save_validation_report(run_id, report)
|
| 128 |
+
self.transition(run_id, "completed")
|
| 129 |
+
self.publish(run_id, {"kind": "validated", "run_id": run_id, "status": "completed"})
|
| 130 |
+
self.close_stream(run_id)
|
| 131 |
+
|
| 132 |
+
def _persist_runtime_event(self, run_id: str, event: dict[str, Any]) -> None:
|
| 133 |
+
try:
|
| 134 |
+
kind = event.get("kind")
|
| 135 |
+
if kind in {"task_created", "task_updated"}:
|
| 136 |
+
task = RunTask.model_validate(event.get("task"))
|
| 137 |
+
self._upsert_task(run_id, task)
|
| 138 |
+
elif kind == "event":
|
| 139 |
+
run_event = RunEvent.model_validate(event.get("event"))
|
| 140 |
+
self.run_store.append_event(run_id, run_event)
|
| 141 |
+
except Exception:
|
| 142 |
+
pass
|
| 143 |
+
|
| 144 |
+
def _upsert_task(self, run_id: str, task: RunTask) -> None:
|
| 145 |
+
current = self.get_run(run_id).tasks
|
| 146 |
+
tasks = [item for item in current if item.id != task.id]
|
| 147 |
+
tasks.append(task)
|
| 148 |
+
self.run_store.save_tasks(run_id, tasks)
|
arena/run_models.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Prompt-first Run domain model import surface.
|
| 2 |
+
|
| 3 |
+
New Run/Swarm modules should import from here instead of the shared model file.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
from .models import (
|
| 9 |
+
CodebaseArchive,
|
| 10 |
+
ModelProviderConfig,
|
| 11 |
+
ModelProviderSelection,
|
| 12 |
+
Run,
|
| 13 |
+
RunArtifact,
|
| 14 |
+
RunEvent,
|
| 15 |
+
RunRequest,
|
| 16 |
+
RunStatus,
|
| 17 |
+
RunTask,
|
| 18 |
+
RunView,
|
| 19 |
+
TaskStatus,
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
__all__ = [
|
| 23 |
+
"CodebaseArchive",
|
| 24 |
+
"ModelProviderConfig",
|
| 25 |
+
"ModelProviderSelection",
|
| 26 |
+
"Run",
|
| 27 |
+
"RunArtifact",
|
| 28 |
+
"RunEvent",
|
| 29 |
+
"RunRequest",
|
| 30 |
+
"RunStatus",
|
| 31 |
+
"RunTask",
|
| 32 |
+
"RunView",
|
| 33 |
+
"TaskStatus",
|
| 34 |
+
]
|
arena/run_store.py
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Run store seam for prompt-first agent swarm work."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import os
|
| 6 |
+
import secrets
|
| 7 |
+
from typing import Protocol
|
| 8 |
+
from uuid import uuid4
|
| 9 |
+
|
| 10 |
+
from pydantic import BaseModel
|
| 11 |
+
|
| 12 |
+
from .models import utc_now
|
| 13 |
+
from .redis_client import RedisClient, UpstashRedisRestClient
|
| 14 |
+
from .run_models import (
|
| 15 |
+
CodebaseArchive,
|
| 16 |
+
Run,
|
| 17 |
+
RunEvent,
|
| 18 |
+
RunRequest,
|
| 19 |
+
RunStatus,
|
| 20 |
+
RunTask,
|
| 21 |
+
RunView,
|
| 22 |
+
)
|
| 23 |
+
from .validation_models import (
|
| 24 |
+
ValidationCheck,
|
| 25 |
+
ValidationReport,
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class RunStoreError(ValueError):
|
| 30 |
+
pass
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class RunNotFound(RunStoreError):
|
| 34 |
+
pass
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class InvalidRunToken(RunStoreError):
|
| 38 |
+
pass
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
class RunAccessView(BaseModel):
|
| 42 |
+
token: str
|
| 43 |
+
run: RunView
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
class RunStore(Protocol):
|
| 47 |
+
def create_run(self, request: RunRequest) -> RunAccessView:
|
| 48 |
+
...
|
| 49 |
+
|
| 50 |
+
def rejoin(self, token: str) -> RunAccessView:
|
| 51 |
+
...
|
| 52 |
+
|
| 53 |
+
def get_run_view(self, run_id: str) -> RunView:
|
| 54 |
+
...
|
| 55 |
+
|
| 56 |
+
def save_tasks(self, run_id: str, tasks: list[RunTask]) -> RunView:
|
| 57 |
+
...
|
| 58 |
+
|
| 59 |
+
def save_status(self, run_id: str, status: RunStatus) -> RunView:
|
| 60 |
+
...
|
| 61 |
+
|
| 62 |
+
def save_summary(self, run_id: str, summary: str) -> RunView:
|
| 63 |
+
...
|
| 64 |
+
|
| 65 |
+
def append_event(self, run_id: str, event: RunEvent) -> RunView:
|
| 66 |
+
...
|
| 67 |
+
|
| 68 |
+
def save_codebase_archive(self, run_id: str, archive: CodebaseArchive) -> RunView:
|
| 69 |
+
...
|
| 70 |
+
|
| 71 |
+
def save_validation_checks(self, run_id: str, checks: list[ValidationCheck]) -> RunView:
|
| 72 |
+
...
|
| 73 |
+
|
| 74 |
+
def save_validation_report(self, run_id: str, report: ValidationReport) -> RunView:
|
| 75 |
+
...
|
| 76 |
+
|
| 77 |
+
def mark_active_tasks_interrupted(self, run_id: str) -> RunView:
|
| 78 |
+
...
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
class BaseRunStore:
|
| 82 |
+
def _new_id(self, prefix: str) -> str:
|
| 83 |
+
return f"{prefix}_{uuid4().hex}"
|
| 84 |
+
|
| 85 |
+
def _new_token(self) -> str:
|
| 86 |
+
return secrets.token_urlsafe(32)
|
| 87 |
+
|
| 88 |
+
def _touch(self, run: Run) -> None:
|
| 89 |
+
run.updated_at = utc_now()
|
| 90 |
+
|
| 91 |
+
def _append_event_once(self, run: Run, event: RunEvent) -> None:
|
| 92 |
+
if not any(existing.id == event.id for existing in run.events):
|
| 93 |
+
run.events.append(event)
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
class InMemoryRunStore(BaseRunStore):
|
| 97 |
+
def __init__(self) -> None:
|
| 98 |
+
self._runs_by_id: dict[str, Run] = {}
|
| 99 |
+
self._run_tokens: dict[str, str] = {}
|
| 100 |
+
|
| 101 |
+
def create_run(self, request: RunRequest) -> RunAccessView:
|
| 102 |
+
token = self._new_token()
|
| 103 |
+
run = Run(
|
| 104 |
+
id=self._new_id("run"),
|
| 105 |
+
prompt=request.prompt,
|
| 106 |
+
criteria=request.criteria,
|
| 107 |
+
user_tests=request.user_tests,
|
| 108 |
+
rejoin_token=token,
|
| 109 |
+
model_provider=request.model_provider.public_selection() if request.model_provider else None,
|
| 110 |
+
)
|
| 111 |
+
self._runs_by_id[run.id] = run
|
| 112 |
+
self._run_tokens[token] = run.id
|
| 113 |
+
return RunAccessView(token=token, run=run.to_view())
|
| 114 |
+
|
| 115 |
+
def rejoin(self, token: str) -> RunAccessView:
|
| 116 |
+
run_id = self._run_tokens.get(token)
|
| 117 |
+
if run_id is None:
|
| 118 |
+
raise InvalidRunToken("token does not match any run")
|
| 119 |
+
return RunAccessView(token=token, run=self.get_run_view(run_id))
|
| 120 |
+
|
| 121 |
+
def get_run_view(self, run_id: str) -> RunView:
|
| 122 |
+
return self._get(run_id).to_view()
|
| 123 |
+
|
| 124 |
+
def save_tasks(self, run_id: str, tasks: list[RunTask]) -> RunView:
|
| 125 |
+
run = self._get(run_id)
|
| 126 |
+
run.tasks = tasks
|
| 127 |
+
self._touch(run)
|
| 128 |
+
return run.to_view()
|
| 129 |
+
|
| 130 |
+
def save_status(self, run_id: str, status: RunStatus) -> RunView:
|
| 131 |
+
run = self._get(run_id)
|
| 132 |
+
run.status = status
|
| 133 |
+
self._touch(run)
|
| 134 |
+
return run.to_view()
|
| 135 |
+
|
| 136 |
+
def save_summary(self, run_id: str, summary: str) -> RunView:
|
| 137 |
+
run = self._get(run_id)
|
| 138 |
+
run.summary = summary
|
| 139 |
+
self._touch(run)
|
| 140 |
+
return run.to_view()
|
| 141 |
+
|
| 142 |
+
def append_event(self, run_id: str, event: RunEvent) -> RunView:
|
| 143 |
+
run = self._get(run_id)
|
| 144 |
+
self._append_event_once(run, event)
|
| 145 |
+
self._touch(run)
|
| 146 |
+
return run.to_view()
|
| 147 |
+
|
| 148 |
+
def save_codebase_archive(self, run_id: str, archive: CodebaseArchive) -> RunView:
|
| 149 |
+
run = self._get(run_id)
|
| 150 |
+
run.codebase_archive = archive
|
| 151 |
+
self._touch(run)
|
| 152 |
+
return run.to_view()
|
| 153 |
+
|
| 154 |
+
def save_validation_checks(self, run_id: str, checks: list[ValidationCheck]) -> RunView:
|
| 155 |
+
run = self._get(run_id)
|
| 156 |
+
run.validation_checks = checks
|
| 157 |
+
self._touch(run)
|
| 158 |
+
return run.to_view()
|
| 159 |
+
|
| 160 |
+
def save_validation_report(self, run_id: str, report: ValidationReport) -> RunView:
|
| 161 |
+
run = self._get(run_id)
|
| 162 |
+
if report.run_id != run.id:
|
| 163 |
+
raise ValueError("validation report run id must match run id")
|
| 164 |
+
run.validation_report = report
|
| 165 |
+
self._touch(run)
|
| 166 |
+
return run.to_view()
|
| 167 |
+
|
| 168 |
+
def mark_active_tasks_interrupted(self, run_id: str) -> RunView:
|
| 169 |
+
run = self._get(run_id)
|
| 170 |
+
run.tasks = [
|
| 171 |
+
task.model_copy(update={"status": "interrupted"}) if task.status == "active" else task
|
| 172 |
+
for task in run.tasks
|
| 173 |
+
]
|
| 174 |
+
self._touch(run)
|
| 175 |
+
return run.to_view()
|
| 176 |
+
|
| 177 |
+
def _get(self, run_id: str) -> Run:
|
| 178 |
+
run = self._runs_by_id.get(run_id)
|
| 179 |
+
if run is None:
|
| 180 |
+
raise RunNotFound("run not found")
|
| 181 |
+
return run
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
class RedisRunStore(BaseRunStore):
|
| 185 |
+
"""Redis-backed RunStore using Upstash REST-compatible commands."""
|
| 186 |
+
|
| 187 |
+
def __init__(self, *, client: RedisClient, key_prefix: str = "arena") -> None:
|
| 188 |
+
self._client = client
|
| 189 |
+
self._key_prefix = key_prefix.rstrip(":")
|
| 190 |
+
|
| 191 |
+
def create_run(self, request: RunRequest) -> RunAccessView:
|
| 192 |
+
token = self._new_token()
|
| 193 |
+
run = Run(
|
| 194 |
+
id=self._new_id("run"),
|
| 195 |
+
prompt=request.prompt,
|
| 196 |
+
criteria=request.criteria,
|
| 197 |
+
user_tests=request.user_tests,
|
| 198 |
+
rejoin_token=token,
|
| 199 |
+
model_provider=request.model_provider.public_selection() if request.model_provider else None,
|
| 200 |
+
)
|
| 201 |
+
self._save_run(run)
|
| 202 |
+
self._set(self._token_key(token), run.id)
|
| 203 |
+
return RunAccessView(token=token, run=run.to_view())
|
| 204 |
+
|
| 205 |
+
def rejoin(self, token: str) -> RunAccessView:
|
| 206 |
+
run_id = self._get_value(self._token_key(token))
|
| 207 |
+
if run_id is None:
|
| 208 |
+
raise InvalidRunToken("token does not match any run")
|
| 209 |
+
return RunAccessView(token=token, run=self.get_run_view(run_id))
|
| 210 |
+
|
| 211 |
+
def get_run_view(self, run_id: str) -> RunView:
|
| 212 |
+
return self._get(run_id).to_view()
|
| 213 |
+
|
| 214 |
+
def save_tasks(self, run_id: str, tasks: list[RunTask]) -> RunView:
|
| 215 |
+
run = self._get(run_id)
|
| 216 |
+
run.tasks = tasks
|
| 217 |
+
self._touch(run)
|
| 218 |
+
self._save_run(run)
|
| 219 |
+
return run.to_view()
|
| 220 |
+
|
| 221 |
+
def save_status(self, run_id: str, status: RunStatus) -> RunView:
|
| 222 |
+
run = self._get(run_id)
|
| 223 |
+
run.status = status
|
| 224 |
+
self._touch(run)
|
| 225 |
+
self._save_run(run)
|
| 226 |
+
return run.to_view()
|
| 227 |
+
|
| 228 |
+
def save_summary(self, run_id: str, summary: str) -> RunView:
|
| 229 |
+
run = self._get(run_id)
|
| 230 |
+
run.summary = summary
|
| 231 |
+
self._touch(run)
|
| 232 |
+
self._save_run(run)
|
| 233 |
+
return run.to_view()
|
| 234 |
+
|
| 235 |
+
def append_event(self, run_id: str, event: RunEvent) -> RunView:
|
| 236 |
+
run = self._get(run_id)
|
| 237 |
+
self._append_event_once(run, event)
|
| 238 |
+
self._touch(run)
|
| 239 |
+
self._save_run(run)
|
| 240 |
+
return run.to_view()
|
| 241 |
+
|
| 242 |
+
def save_codebase_archive(self, run_id: str, archive: CodebaseArchive) -> RunView:
|
| 243 |
+
run = self._get(run_id)
|
| 244 |
+
run.codebase_archive = archive
|
| 245 |
+
self._touch(run)
|
| 246 |
+
self._save_run(run)
|
| 247 |
+
return run.to_view()
|
| 248 |
+
|
| 249 |
+
def save_validation_checks(self, run_id: str, checks: list[ValidationCheck]) -> RunView:
|
| 250 |
+
run = self._get(run_id)
|
| 251 |
+
run.validation_checks = checks
|
| 252 |
+
self._touch(run)
|
| 253 |
+
self._save_run(run)
|
| 254 |
+
return run.to_view()
|
| 255 |
+
|
| 256 |
+
def save_validation_report(self, run_id: str, report: ValidationReport) -> RunView:
|
| 257 |
+
run = self._get(run_id)
|
| 258 |
+
if report.run_id != run.id:
|
| 259 |
+
raise ValueError("validation report run id must match run id")
|
| 260 |
+
run.validation_report = report
|
| 261 |
+
self._touch(run)
|
| 262 |
+
self._save_run(run)
|
| 263 |
+
return run.to_view()
|
| 264 |
+
|
| 265 |
+
def mark_active_tasks_interrupted(self, run_id: str) -> RunView:
|
| 266 |
+
run = self._get(run_id)
|
| 267 |
+
run.tasks = [
|
| 268 |
+
task.model_copy(update={"status": "interrupted"}) if task.status == "active" else task
|
| 269 |
+
for task in run.tasks
|
| 270 |
+
]
|
| 271 |
+
self._touch(run)
|
| 272 |
+
self._save_run(run)
|
| 273 |
+
return run.to_view()
|
| 274 |
+
|
| 275 |
+
def _get(self, run_id: str) -> Run:
|
| 276 |
+
payload = self._get_value(self._run_key(run_id))
|
| 277 |
+
if payload is None:
|
| 278 |
+
raise RunNotFound("run not found")
|
| 279 |
+
return Run.model_validate_json(payload)
|
| 280 |
+
|
| 281 |
+
def _save_run(self, run: Run) -> None:
|
| 282 |
+
self._set(self._run_key(run.id), run.model_dump_json())
|
| 283 |
+
|
| 284 |
+
def _get_value(self, key: str) -> str | None:
|
| 285 |
+
value = self._client.execute("GET", key)
|
| 286 |
+
if value is None:
|
| 287 |
+
return None
|
| 288 |
+
return str(value)
|
| 289 |
+
|
| 290 |
+
def _set(self, key: str, value: str) -> None:
|
| 291 |
+
self._client.execute("SET", key, value)
|
| 292 |
+
|
| 293 |
+
def _run_key(self, run_id: str) -> str:
|
| 294 |
+
return f"{self._key_prefix}:run:{run_id}"
|
| 295 |
+
|
| 296 |
+
def _token_key(self, token: str) -> str:
|
| 297 |
+
return f"{self._key_prefix}:run-token:{token}"
|
| 298 |
+
|
| 299 |
+
|
| 300 |
+
def create_run_store() -> RunStore:
|
| 301 |
+
provider = os.getenv("RUN_STORE_PROVIDER", "memory").lower()
|
| 302 |
+
if provider == "memory":
|
| 303 |
+
return InMemoryRunStore()
|
| 304 |
+
if provider == "redis":
|
| 305 |
+
url = os.getenv("UPSTASH_REDIS_REST_URL", "")
|
| 306 |
+
token = os.getenv("UPSTASH_REDIS_REST_TOKEN", "")
|
| 307 |
+
if not url or not token:
|
| 308 |
+
raise ValueError(
|
| 309 |
+
"UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN are required "
|
| 310 |
+
"when RUN_STORE_PROVIDER=redis"
|
| 311 |
+
)
|
| 312 |
+
return RedisRunStore(client=UpstashRedisRestClient(url=url, token=token))
|
| 313 |
+
raise ValueError(f"unsupported run store provider: {provider}")
|
arena/sandbox_lease.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Idle leases for costly sandbox resources."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import os
|
| 6 |
+
import threading
|
| 7 |
+
from dataclasses import dataclass
|
| 8 |
+
from typing import Callable
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
DEFAULT_SANDBOX_TTL_SECONDS = 30 * 60
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
CloseSandbox = Callable[[], None]
|
| 15 |
+
ExpireSandbox = Callable[[], None]
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
@dataclass
|
| 19 |
+
class SandboxLease:
|
| 20 |
+
lease_id: str
|
| 21 |
+
close: CloseSandbox
|
| 22 |
+
on_expire: ExpireSandbox | None = None
|
| 23 |
+
timer: threading.Timer | None = None
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class SandboxLeaseManager:
|
| 27 |
+
"""Owns idle TTL, touch, and close behaviour for sandbox resources."""
|
| 28 |
+
|
| 29 |
+
def __init__(self, *, ttl_seconds: float | None = None) -> None:
|
| 30 |
+
self.ttl_seconds = ttl_seconds if ttl_seconds is not None else sandbox_ttl_seconds_from_env()
|
| 31 |
+
self._leases: dict[str, SandboxLease] = {}
|
| 32 |
+
self._lock = threading.RLock()
|
| 33 |
+
|
| 34 |
+
def start(
|
| 35 |
+
self,
|
| 36 |
+
lease_id: str,
|
| 37 |
+
close: CloseSandbox,
|
| 38 |
+
*,
|
| 39 |
+
on_expire: ExpireSandbox | None = None,
|
| 40 |
+
) -> None:
|
| 41 |
+
self.close(lease_id)
|
| 42 |
+
lease = SandboxLease(lease_id=lease_id, close=close, on_expire=on_expire)
|
| 43 |
+
with self._lock:
|
| 44 |
+
self._leases[lease_id] = lease
|
| 45 |
+
lease.timer = self._schedule(lease_id)
|
| 46 |
+
|
| 47 |
+
def touch(self, lease_id: str) -> bool:
|
| 48 |
+
with self._lock:
|
| 49 |
+
lease = self._leases.get(lease_id)
|
| 50 |
+
if lease is None:
|
| 51 |
+
return False
|
| 52 |
+
if lease.timer is not None:
|
| 53 |
+
lease.timer.cancel()
|
| 54 |
+
lease.timer = self._schedule(lease_id)
|
| 55 |
+
return True
|
| 56 |
+
|
| 57 |
+
def close(self, lease_id: str) -> bool:
|
| 58 |
+
with self._lock:
|
| 59 |
+
lease = self._leases.pop(lease_id, None)
|
| 60 |
+
if lease is None:
|
| 61 |
+
return False
|
| 62 |
+
if lease.timer is not None:
|
| 63 |
+
lease.timer.cancel()
|
| 64 |
+
lease.close()
|
| 65 |
+
return True
|
| 66 |
+
|
| 67 |
+
def _expire(self, lease_id: str) -> None:
|
| 68 |
+
with self._lock:
|
| 69 |
+
lease = self._leases.pop(lease_id, None)
|
| 70 |
+
if lease is None:
|
| 71 |
+
return
|
| 72 |
+
lease.close()
|
| 73 |
+
if lease.on_expire is not None:
|
| 74 |
+
lease.on_expire()
|
| 75 |
+
|
| 76 |
+
def _schedule(self, lease_id: str) -> threading.Timer | None:
|
| 77 |
+
if self.ttl_seconds <= 0:
|
| 78 |
+
return None
|
| 79 |
+
timer = threading.Timer(self.ttl_seconds, self._expire, args=(lease_id,))
|
| 80 |
+
timer.daemon = True
|
| 81 |
+
timer.start()
|
| 82 |
+
return timer
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def sandbox_ttl_seconds_from_env() -> float:
|
| 86 |
+
raw = os.getenv("DEEPAGENT_SANDBOX_TTL_SECONDS", "").strip()
|
| 87 |
+
if not raw:
|
| 88 |
+
return float(DEFAULT_SANDBOX_TTL_SECONDS)
|
| 89 |
+
try:
|
| 90 |
+
return max(0.0, float(raw))
|
| 91 |
+
except ValueError:
|
| 92 |
+
return float(DEFAULT_SANDBOX_TTL_SECONDS)
|
arena/service.py
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Run workbench interface for HTTP, Gradio, and tests."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import os
|
| 6 |
+
import json
|
| 7 |
+
from typing import Callable
|
| 8 |
+
|
| 9 |
+
import dotenv
|
| 10 |
+
|
| 11 |
+
from .agent import AgentSession, create_session
|
| 12 |
+
from .codebase_archive import CodebaseArchiveStore, create_codebase_archive_store
|
| 13 |
+
from .event_bus import EventBus
|
| 14 |
+
from .model_catalog import ModelProviderConfig, ProviderModel, model_catalog, model_id_is_eligible, model_id_parameter_count_b
|
| 15 |
+
from .run_models import RunRequest, RunView
|
| 16 |
+
from .validation_models import ValidationReport
|
| 17 |
+
from .run_flow import RunArchiveNotFound, RunFlow
|
| 18 |
+
from .run_store import RunAccessView, RunStore, create_run_store
|
| 19 |
+
from .service_payloads import TextPayload, ZipPayload
|
| 20 |
+
from .skill_catalog import SkillInfo, discover_skills
|
| 21 |
+
from .swarm_runtime import SwarmRuntime
|
| 22 |
+
from .thread_inspector import FileAccessFailed
|
| 23 |
+
from .validator_executor import create_validator_executor
|
| 24 |
+
from .validator_graph import ValidatorGraph, OpenRouterRubricReviewer
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class ArenaServiceError(Exception):
|
| 28 |
+
pass
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _maybe_llm_rubric_reviewer():
|
| 32 |
+
dotenv.load_dotenv()
|
| 33 |
+
if os.environ.get("OPENROUTER_API_KEY"):
|
| 34 |
+
return OpenRouterRubricReviewer()
|
| 35 |
+
return None
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
SessionFactory = Callable[..., AgentSession]
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
class ArenaService:
|
| 42 |
+
"""Stable API boundary for the prompt-first Run workbench.
|
| 43 |
+
|
| 44 |
+
The Thread inspection surface lives in ``ThreadInspector``; debug routes and
|
| 45 |
+
tests should depend on ``ThreadInspector`` directly instead of reaching
|
| 46 |
+
into this class.
|
| 47 |
+
"""
|
| 48 |
+
|
| 49 |
+
def __init__(
|
| 50 |
+
self,
|
| 51 |
+
*,
|
| 52 |
+
session_factory: SessionFactory = create_session,
|
| 53 |
+
run_store: RunStore | None = None,
|
| 54 |
+
swarm_runtime: SwarmRuntime | None = None,
|
| 55 |
+
archive_store: CodebaseArchiveStore | None = None,
|
| 56 |
+
validator_graph: ValidatorGraph | None = None,
|
| 57 |
+
run_flow: RunFlow | None = None,
|
| 58 |
+
) -> None:
|
| 59 |
+
self._session_factory = session_factory
|
| 60 |
+
self._archive_store = archive_store or create_codebase_archive_store()
|
| 61 |
+
self._run_store = run_store or create_run_store()
|
| 62 |
+
event_bus = EventBus()
|
| 63 |
+
self._event_bus = event_bus
|
| 64 |
+
self._swarm_runtime = swarm_runtime or SwarmRuntime(
|
| 65 |
+
session_factory=self._attempt_session,
|
| 66 |
+
archive_store=self._archive_store,
|
| 67 |
+
event_bus=event_bus,
|
| 68 |
+
)
|
| 69 |
+
rubric = _maybe_llm_rubric_reviewer()
|
| 70 |
+
self._validator_graph = validator_graph or ValidatorGraph(
|
| 71 |
+
executor=create_validator_executor(self._archive_store),
|
| 72 |
+
rubric_reviewer=rubric,
|
| 73 |
+
)
|
| 74 |
+
self._run_flow = run_flow or RunFlow(
|
| 75 |
+
run_store=self._run_store,
|
| 76 |
+
swarm_runtime=self._swarm_runtime,
|
| 77 |
+
validator_graph=self._validator_graph,
|
| 78 |
+
archive_store=self._archive_store,
|
| 79 |
+
event_bus=event_bus,
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
def health(self) -> dict[str, str]:
|
| 83 |
+
return {"status": "ok"}
|
| 84 |
+
|
| 85 |
+
def list_skills(self) -> list[SkillInfo]:
|
| 86 |
+
return discover_skills()
|
| 87 |
+
|
| 88 |
+
def list_provider_models(self, config: ModelProviderConfig | None = None) -> list[ProviderModel]:
|
| 89 |
+
return model_catalog.list_models(config)
|
| 90 |
+
|
| 91 |
+
def refresh_provider_models(self, config: ModelProviderConfig | None = None) -> list[ProviderModel]:
|
| 92 |
+
return model_catalog.refresh_models(config)
|
| 93 |
+
|
| 94 |
+
def create_run(self, request: RunRequest) -> RunAccessView:
|
| 95 |
+
self._validate_model_provider(request.model_provider)
|
| 96 |
+
return self._run_flow.create_run(request)
|
| 97 |
+
|
| 98 |
+
def start_run(self, request: RunRequest) -> RunAccessView:
|
| 99 |
+
self._validate_model_provider(request.model_provider)
|
| 100 |
+
return self._run_flow.start_run(request)
|
| 101 |
+
|
| 102 |
+
def cancel_run(self, run_id: str) -> RunView:
|
| 103 |
+
return self._run_flow.cancel_run(run_id)
|
| 104 |
+
|
| 105 |
+
def add_run_message(self, run_id: str, message: str) -> RunView:
|
| 106 |
+
return self._run_flow.add_run_message(run_id, message)
|
| 107 |
+
|
| 108 |
+
def subscribe_events(self, run_id: str):
|
| 109 |
+
return self._run_flow.subscribe_events(run_id)
|
| 110 |
+
|
| 111 |
+
def rejoin_run(self, token: str) -> RunAccessView:
|
| 112 |
+
return self._run_flow.rejoin(token)
|
| 113 |
+
|
| 114 |
+
def get_run(self, run_id: str) -> RunView:
|
| 115 |
+
return self._run_flow.get_run(run_id)
|
| 116 |
+
|
| 117 |
+
def get_run_codebase_zip(self, run_id: str) -> ZipPayload:
|
| 118 |
+
try:
|
| 119 |
+
return self._run_flow.get_codebase_zip(run_id)
|
| 120 |
+
except RunArchiveNotFound as exc:
|
| 121 |
+
raise FileAccessFailed("run has no codebase archive") from exc
|
| 122 |
+
|
| 123 |
+
def get_run_field_notes(self, run_id: str) -> TextPayload:
|
| 124 |
+
run = self.get_run(run_id)
|
| 125 |
+
return TextPayload(
|
| 126 |
+
filename=f"{run.id}-field-notes.md",
|
| 127 |
+
content_type="text/markdown; charset=utf-8",
|
| 128 |
+
data=_field_notes_markdown(run),
|
| 129 |
+
)
|
| 130 |
+
|
| 131 |
+
def get_run_trace_json(self, run_id: str) -> TextPayload:
|
| 132 |
+
run = self.get_run(run_id)
|
| 133 |
+
return TextPayload(
|
| 134 |
+
filename=f"{run.id}-trace.json",
|
| 135 |
+
content_type="application/json; charset=utf-8",
|
| 136 |
+
data=json.dumps(run.model_dump(mode="json"), indent=2, ensure_ascii=False),
|
| 137 |
+
)
|
| 138 |
+
|
| 139 |
+
def get_run_submission_markdown(self, run_id: str) -> TextPayload:
|
| 140 |
+
run = self.get_run(run_id)
|
| 141 |
+
return TextPayload(
|
| 142 |
+
filename=f"{run.id}-submission.md",
|
| 143 |
+
content_type="text/markdown; charset=utf-8",
|
| 144 |
+
data=_submission_markdown(run),
|
| 145 |
+
)
|
| 146 |
+
|
| 147 |
+
def get_run_space_readme(self, run_id: str) -> TextPayload:
|
| 148 |
+
run = self.get_run(run_id)
|
| 149 |
+
return TextPayload(
|
| 150 |
+
filename=f"{run.id}-space-README.md",
|
| 151 |
+
content_type="text/markdown; charset=utf-8",
|
| 152 |
+
data=_space_readme_markdown(run),
|
| 153 |
+
)
|
| 154 |
+
|
| 155 |
+
def validate_run(self, run_id: str) -> ValidationReport:
|
| 156 |
+
try:
|
| 157 |
+
return self._run_flow.validate_run(run_id)
|
| 158 |
+
except RunArchiveNotFound as exc:
|
| 159 |
+
raise FileAccessFailed("run has no codebase archive") from exc
|
| 160 |
+
|
| 161 |
+
def _attempt_session(self, thread_id: str | None, model_provider: ModelProviderConfig | None = None) -> AgentSession:
|
| 162 |
+
try:
|
| 163 |
+
return self._session_factory(thread_id, model_provider)
|
| 164 |
+
except TypeError:
|
| 165 |
+
try:
|
| 166 |
+
return self._session_factory(thread_id)
|
| 167 |
+
except TypeError:
|
| 168 |
+
return self._session_factory()
|
| 169 |
+
|
| 170 |
+
def _validate_model_provider(self, config: ModelProviderConfig | None) -> None:
|
| 171 |
+
if config is None:
|
| 172 |
+
return
|
| 173 |
+
model = config.model.strip()
|
| 174 |
+
if not model:
|
| 175 |
+
raise ArenaServiceError("Choose a fetched model before starting the Run")
|
| 176 |
+
if not model_id_is_eligible(model):
|
| 177 |
+
count = model_id_parameter_count_b(model)
|
| 178 |
+
if count is None:
|
| 179 |
+
raise ArenaServiceError("Model size is unknown; choose a fetched model with <=32B parameters")
|
| 180 |
+
raise ArenaServiceError(f"Model has {count:g}B parameters; requirement is <=32B")
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
def _field_notes_markdown(run: RunView) -> str:
|
| 184 |
+
events = "\n".join(
|
| 185 |
+
f"- {event.created_at.isoformat()}: {event.message}"
|
| 186 |
+
for event in run.events
|
| 187 |
+
) or "- No events recorded."
|
| 188 |
+
tasks = "\n".join(f"- {task.title}: {task.status}" for task in run.tasks) or "- No tasks recorded."
|
| 189 |
+
checks = "\n".join(
|
| 190 |
+
f"- {check.name}: `{check.command}` ({check.source})"
|
| 191 |
+
for check in run.validation_checks
|
| 192 |
+
) or "- No validation checks recorded."
|
| 193 |
+
provider = "server default"
|
| 194 |
+
if run.model_provider is not None:
|
| 195 |
+
provider = f"{run.model_provider.provider} / {run.model_provider.model or 'provider default'}"
|
| 196 |
+
return f"""# Field Notes: Backyard Demo Builder Run
|
| 197 |
+
|
| 198 |
+
## Real Person
|
| 199 |
+
|
| 200 |
+
- Tester: Mom
|
| 201 |
+
- Role: Real-estate agent
|
| 202 |
+
- Problem: follow up with buyer/renter leads without committing to expensive app development first.
|
| 203 |
+
|
| 204 |
+
## Demo Hypothesis
|
| 205 |
+
|
| 206 |
+
If a tiny Gradio demo can show lead reminders and message drafts quickly, the real-estate agent can decide whether to scrap or scale the workflow before paying for full software.
|
| 207 |
+
|
| 208 |
+
## Run
|
| 209 |
+
|
| 210 |
+
- Run ID: `{run.id}`
|
| 211 |
+
- Status: `{run.status}`
|
| 212 |
+
- Model provider: {provider}
|
| 213 |
+
- Prompt: {run.prompt}
|
| 214 |
+
|
| 215 |
+
## Tasks
|
| 216 |
+
|
| 217 |
+
{tasks}
|
| 218 |
+
|
| 219 |
+
## Events
|
| 220 |
+
|
| 221 |
+
{events}
|
| 222 |
+
|
| 223 |
+
## Validation Checks
|
| 224 |
+
|
| 225 |
+
{checks}
|
| 226 |
+
|
| 227 |
+
## Mom Test Questions
|
| 228 |
+
|
| 229 |
+
- Would you use this with real leads?
|
| 230 |
+
- Which reminder is most useful?
|
| 231 |
+
- Which follow-up message would you send as-is?
|
| 232 |
+
- What is missing before this becomes a real app?
|
| 233 |
+
- Scrap or scale?
|
| 234 |
+
|
| 235 |
+
## Quote Placeholder
|
| 236 |
+
|
| 237 |
+
> Add one direct quote after the real test.
|
| 238 |
+
"""
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
def _submission_markdown(run: RunView) -> str:
|
| 242 |
+
provider = "server default"
|
| 243 |
+
model_requirement = "Choose one fetched model with a known size <=32B before the final public run."
|
| 244 |
+
if run.model_provider is not None:
|
| 245 |
+
provider = f"{run.model_provider.provider} / {run.model_provider.model or 'provider default'}"
|
| 246 |
+
if run.model_provider.model:
|
| 247 |
+
size = model_id_parameter_count_b(run.model_provider.model)
|
| 248 |
+
if size is not None:
|
| 249 |
+
model_requirement = f"{run.model_provider.model} parsed as {size:g}B parameters, within <=32B."
|
| 250 |
+
|
| 251 |
+
archive = "No demo package yet."
|
| 252 |
+
if run.codebase_archive is not None:
|
| 253 |
+
archive = f"{run.codebase_archive.file_count} files, {run.codebase_archive.size_bytes} bytes."
|
| 254 |
+
|
| 255 |
+
report = "Not validated yet."
|
| 256 |
+
if run.validation_report is not None:
|
| 257 |
+
report = "Passed" if run.validation_report.passed else "Failed"
|
| 258 |
+
|
| 259 |
+
return f"""# Backyard Demo Builder Submission Pack
|
| 260 |
+
|
| 261 |
+
## Hackathon
|
| 262 |
+
|
| 263 |
+
- Track: Build Small Hackathon Chapter 1 - Backyard AI
|
| 264 |
+
- Real person: Mom, real-estate agent
|
| 265 |
+
- Problem: Cheaply test a lead follow-up reminder demo before paying to build a full CRM.
|
| 266 |
+
- Run ID: `{run.id}`
|
| 267 |
+
- Run status: `{run.status}`
|
| 268 |
+
|
| 269 |
+
## Small Model Requirement
|
| 270 |
+
|
| 271 |
+
- Provider/model: {provider}
|
| 272 |
+
- Proof: {model_requirement}
|
| 273 |
+
|
| 274 |
+
## Built Demo
|
| 275 |
+
|
| 276 |
+
- Prompt: {run.prompt}
|
| 277 |
+
- Package: {archive}
|
| 278 |
+
- Validation: {report}
|
| 279 |
+
- Download package: `/runs/{run.id}/codebase.zip`
|
| 280 |
+
- Field notes: `/runs/{run.id}/field_notes.md`
|
| 281 |
+
- Trace: `/runs/{run.id}/trace.json`
|
| 282 |
+
|
| 283 |
+
## Demo Video Script
|
| 284 |
+
|
| 285 |
+
1. Show the real problem: lead follow-up gets messy and expensive software is risky.
|
| 286 |
+
2. Start Backyard Demo Builder with the real-estate follow-up template.
|
| 287 |
+
3. Build the tiny Gradio CRM demo.
|
| 288 |
+
4. Open the generated demo and add sample leads.
|
| 289 |
+
5. Show next-action reminders and follow-up message drafts.
|
| 290 |
+
6. Record Mom Test feedback: useful reminder, message quality, scrap-or-scale decision.
|
| 291 |
+
7. Close with what would be scaled after approval.
|
| 292 |
+
|
| 293 |
+
## Social Post Draft
|
| 294 |
+
|
| 295 |
+
Built Backyard Demo Builder for Chapter 1 Backyard AI. It turns one real workflow from my mom, a real-estate agent, into a tiny Gradio demo package so she can test lead reminders before we spend time building a full app. Small model, real user, fast scrap-or-scale loop.
|
| 296 |
+
|
| 297 |
+
## Bonus Checklist
|
| 298 |
+
|
| 299 |
+
- Off the Grid: Works as a small local Gradio demo package.
|
| 300 |
+
- Well-Tuned: Template narrows the SLM prompt to one real workflow.
|
| 301 |
+
- Sharing is Caring: Submission pack includes reusable script and README.
|
| 302 |
+
- Field Notes: `/runs/{run.id}/field_notes.md`
|
| 303 |
+
- Llama Champion: Eligible if the selected fetched model is a Llama-family <=32B model.
|
| 304 |
+
|
| 305 |
+
## Final Checklist
|
| 306 |
+
|
| 307 |
+
- Space runs on port 7860.
|
| 308 |
+
- README explains the real person and problem.
|
| 309 |
+
- Demo video shows the generated app, not only this builder.
|
| 310 |
+
- Field notes include one direct quote from Mom.
|
| 311 |
+
- No raw API key stored in Run data, Redis, logs, trace, or archive.
|
| 312 |
+
"""
|
| 313 |
+
|
| 314 |
+
|
| 315 |
+
def _space_readme_markdown(run: RunView) -> str:
|
| 316 |
+
title = "Backyard Demo Builder"
|
| 317 |
+
return f"""---
|
| 318 |
+
title: {title}
|
| 319 |
+
emoji: W
|
| 320 |
+
colorFrom: gray
|
| 321 |
+
colorTo: zinc
|
| 322 |
+
sdk: gradio
|
| 323 |
+
sdk_version: 6.0.0
|
| 324 |
+
app_file: app.py
|
| 325 |
+
pinned: false
|
| 326 |
+
---
|
| 327 |
+
|
| 328 |
+
# {title}
|
| 329 |
+
|
| 330 |
+
Chapter 1 Backyard AI demo builder for quick scrap-or-scale product tests.
|
| 331 |
+
|
| 332 |
+
This Space builds tiny Gradio demo packages from one real-person workflow. The default template is a real-estate follow-up reminder demo for Mom, a real-estate agent.
|
| 333 |
+
|
| 334 |
+
## Run
|
| 335 |
+
|
| 336 |
+
```bash
|
| 337 |
+
python app.py
|
| 338 |
+
```
|
| 339 |
+
|
| 340 |
+
Hugging Face Spaces serves Gradio on port `7860`.
|
| 341 |
+
|
| 342 |
+
## Current Evidence
|
| 343 |
+
|
| 344 |
+
- Source run: `{run.id}`
|
| 345 |
+
- Status: `{run.status}`
|
| 346 |
+
- Field notes: `/runs/{run.id}/field_notes.md`
|
| 347 |
+
- Submission pack: `/runs/{run.id}/submission.md`
|
| 348 |
+
"""
|
arena/service_payloads.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared service payload models."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from pydantic import BaseModel
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class ZipPayload(BaseModel):
|
| 9 |
+
filename: str
|
| 10 |
+
content_type: str = "application/zip"
|
| 11 |
+
data: bytes
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class TextPayload(BaseModel):
|
| 15 |
+
filename: str
|
| 16 |
+
content_type: str = "text/plain; charset=utf-8"
|
| 17 |
+
data: str
|
arena/skill_catalog.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Discovery for repository-bundled DeepAgents skills."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
from pydantic import BaseModel
|
| 8 |
+
|
| 9 |
+
from .paths import SKILLS_ROOT
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class SkillInfo(BaseModel):
|
| 13 |
+
name: str
|
| 14 |
+
description: str
|
| 15 |
+
path: str
|
| 16 |
+
virtual_path: str
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def discover_skills(root: Path = SKILLS_ROOT) -> list[SkillInfo]:
|
| 20 |
+
"""Return skills mounted into DeepAgents at /skills/."""
|
| 21 |
+
if not root.exists():
|
| 22 |
+
return []
|
| 23 |
+
|
| 24 |
+
skills = []
|
| 25 |
+
for skill_file in sorted(root.glob("*/SKILL.md")):
|
| 26 |
+
metadata = _frontmatter(skill_file)
|
| 27 |
+
name = metadata.get("name") or skill_file.parent.name
|
| 28 |
+
skills.append(
|
| 29 |
+
SkillInfo(
|
| 30 |
+
name=name,
|
| 31 |
+
description=metadata.get("description", ""),
|
| 32 |
+
path=str(skill_file.relative_to(root.parent)),
|
| 33 |
+
virtual_path=f"/skills/{skill_file.parent.name}/SKILL.md",
|
| 34 |
+
)
|
| 35 |
+
)
|
| 36 |
+
return skills
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _frontmatter(path: Path) -> dict[str, str]:
|
| 40 |
+
text = path.read_text(encoding="utf-8")
|
| 41 |
+
if not text.startswith("---\n"):
|
| 42 |
+
return {}
|
| 43 |
+
|
| 44 |
+
end = text.find("\n---", 4)
|
| 45 |
+
if end == -1:
|
| 46 |
+
return {}
|
| 47 |
+
|
| 48 |
+
metadata: dict[str, str] = {}
|
| 49 |
+
for line in text[4:end].splitlines():
|
| 50 |
+
if ":" not in line:
|
| 51 |
+
continue
|
| 52 |
+
key, value = line.split(":", 1)
|
| 53 |
+
metadata[key.strip()] = value.strip().strip("\"'")
|
| 54 |
+
return metadata
|
arena/smoke.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Small smoke entrypoints for project task targets."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import argparse
|
| 6 |
+
import json
|
| 7 |
+
import os
|
| 8 |
+
import tempfile
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
|
| 11 |
+
from deepagents.backends import LocalShellBackend
|
| 12 |
+
|
| 13 |
+
from .agent import create_session
|
| 14 |
+
from .codebase_archive import LocalCodebaseArchiveStore
|
| 15 |
+
from .codebase_handoff import CodebaseHandoff
|
| 16 |
+
from .models import CodebaseArchive, ValidationCheck
|
| 17 |
+
from .validator_executor import create_validator_executor
|
| 18 |
+
from .validator_graph import ValidatorGraph
|
| 19 |
+
from .validator_plan import ValidatorPlan
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def smoke_agent() -> None:
|
| 23 |
+
os.environ.setdefault("OPENROUTER_API_KEY", "sk-or-test")
|
| 24 |
+
os.environ.setdefault("DEEPAGENT_SANDBOX_PROVIDER", "local")
|
| 25 |
+
session = create_session("smoke")
|
| 26 |
+
try:
|
| 27 |
+
print(session.thread_id, session.provider, type(session.backend).__name__)
|
| 28 |
+
finally:
|
| 29 |
+
session.close()
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def smoke_validator() -> None:
|
| 33 |
+
with tempfile.TemporaryDirectory(prefix="agent-swarm-validator-smoke-") as tmp:
|
| 34 |
+
root = Path(tmp)
|
| 35 |
+
sandbox_root = root / "sandbox"
|
| 36 |
+
backend = LocalShellBackend(root_dir=sandbox_root, virtual_mode=True, inherit_env=False)
|
| 37 |
+
backend.write("/workspace/answer.txt", "ok")
|
| 38 |
+
snapshot_root = root / "archives"
|
| 39 |
+
archive_store = LocalCodebaseArchiveStore(root_dir=snapshot_root)
|
| 40 |
+
handoff = CodebaseHandoff(archive_store=archive_store, root_dir=snapshot_root)
|
| 41 |
+
pointer = handoff.snapshot_backend_workspace(backend, "smoke-run")
|
| 42 |
+
executor = create_validator_executor(archive_store=archive_store)
|
| 43 |
+
executor.root_dir = snapshot_root
|
| 44 |
+
try:
|
| 45 |
+
report = ValidatorGraph(executor=executor).validate(
|
| 46 |
+
run_id="smoke-run",
|
| 47 |
+
codebase_archive=CodebaseArchive(pointer=pointer, file_count=1, size_bytes=2),
|
| 48 |
+
plan=ValidatorPlan.from_checks([
|
| 49 |
+
ValidationCheck(
|
| 50 |
+
id="check-1",
|
| 51 |
+
name="file",
|
| 52 |
+
command="test -f answer.txt",
|
| 53 |
+
source="generated",
|
| 54 |
+
),
|
| 55 |
+
]),
|
| 56 |
+
)
|
| 57 |
+
finally:
|
| 58 |
+
executor.close()
|
| 59 |
+
print(json.dumps(report.model_dump(mode="json"), sort_keys=True, default=str))
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def main() -> None:
|
| 63 |
+
parser = argparse.ArgumentParser()
|
| 64 |
+
parser.add_argument("target", choices=["agent", "validator"])
|
| 65 |
+
args = parser.parse_args()
|
| 66 |
+
if args.target == "agent":
|
| 67 |
+
smoke_agent()
|
| 68 |
+
elif args.target == "validator":
|
| 69 |
+
smoke_validator()
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
if __name__ == "__main__":
|
| 73 |
+
main()
|
arena/stagehand_validator.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Stagehand visual validation — opens built web apps in remote Browserbase browsers."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import os
|
| 6 |
+
import time
|
| 7 |
+
from typing import Any
|
| 8 |
+
|
| 9 |
+
from playwright.sync_api import sync_playwright
|
| 10 |
+
from stagehand import Stagehand
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class StagehandValidationError(Exception):
|
| 14 |
+
pass
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def run_stagehand_check(
|
| 18 |
+
url: str,
|
| 19 |
+
instruction: str,
|
| 20 |
+
timeout_seconds: int = 30,
|
| 21 |
+
) -> tuple[bool, str]:
|
| 22 |
+
"""Open a URL in a Browserbase remote browser and validate behavior.
|
| 23 |
+
|
| 24 |
+
Uses Stagehand to navigate to a running web app and perform natural-language
|
| 25 |
+
validation (click, observe, extract).
|
| 26 |
+
|
| 27 |
+
Args:
|
| 28 |
+
url: The URL of the running web app to validate.
|
| 29 |
+
instruction: Natural-language instruction describing what to verify.
|
| 30 |
+
timeout_seconds: Maximum time to wait for the check.
|
| 31 |
+
|
| 32 |
+
Returns:
|
| 33 |
+
A tuple of (passed: bool, output: str) with the check result.
|
| 34 |
+
"""
|
| 35 |
+
browserbase_api_key = os.environ.get("BROWSERBASE_API_KEY", "")
|
| 36 |
+
model_api_key = os.environ.get("MODEL_API_KEY", os.environ.get("OPENROUTER_API_KEY", ""))
|
| 37 |
+
|
| 38 |
+
if not browserbase_api_key:
|
| 39 |
+
return False, "BROWSERBASE_API_KEY is not configured"
|
| 40 |
+
|
| 41 |
+
if not model_api_key:
|
| 42 |
+
return False, "MODEL_API_KEY or OPENROUTER_API_KEY is not configured"
|
| 43 |
+
|
| 44 |
+
try:
|
| 45 |
+
with Stagehand(
|
| 46 |
+
server="remote",
|
| 47 |
+
browserbase_api_key=browserbase_api_key,
|
| 48 |
+
model_api_key=model_api_key,
|
| 49 |
+
) as client:
|
| 50 |
+
session = client.sessions.start(
|
| 51 |
+
model_name="openrouter/anthropic/claude-sonnet-4-6",
|
| 52 |
+
browser={"type": "browserbase"},
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
cdp_url = session.data.cdp_url
|
| 56 |
+
if not cdp_url:
|
| 57 |
+
client.sessions.end(session.id)
|
| 58 |
+
return False, "No CDP URL returned from Browserbase session"
|
| 59 |
+
|
| 60 |
+
output_parts: list[str] = []
|
| 61 |
+
try:
|
| 62 |
+
with sync_playwright() as p:
|
| 63 |
+
browser_conn = p.chromium.connect_over_cdp(cdp_url)
|
| 64 |
+
context = browser_conn.contexts[0] if browser_conn.contexts else browser_conn.new_context()
|
| 65 |
+
page = context.pages[0] if context.pages else context.new_page()
|
| 66 |
+
|
| 67 |
+
client.sessions.navigate(session.id, url=url)
|
| 68 |
+
page.wait_for_load_state("domcontentloaded")
|
| 69 |
+
time.sleep(2)
|
| 70 |
+
|
| 71 |
+
observe_stream = client.sessions.observe(
|
| 72 |
+
session.id,
|
| 73 |
+
instruction=instruction,
|
| 74 |
+
stream_response=True,
|
| 75 |
+
x_stream_response="true",
|
| 76 |
+
)
|
| 77 |
+
for event in observe_stream:
|
| 78 |
+
data = _event_data(event)
|
| 79 |
+
if data:
|
| 80 |
+
output_parts.append(data)
|
| 81 |
+
|
| 82 |
+
passed = "error" not in " ".join(output_parts).lower()
|
| 83 |
+
return passed, "\n".join(output_parts) or "Observed page successfully."
|
| 84 |
+
|
| 85 |
+
finally:
|
| 86 |
+
browser_conn.close() if "browser_conn" in dir() else None
|
| 87 |
+
|
| 88 |
+
client.sessions.end(session.id)
|
| 89 |
+
|
| 90 |
+
except Exception as exc:
|
| 91 |
+
return False, f"Stagehand validation error: {exc}"
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def run_stagehand_static_check(
|
| 95 |
+
url: str,
|
| 96 |
+
checks: list[str],
|
| 97 |
+
) -> tuple[bool, str]:
|
| 98 |
+
"""Run multiple Stagehand checks against a single page session.
|
| 99 |
+
|
| 100 |
+
Opens one Browserbase session and runs each check instruction sequentially.
|
| 101 |
+
More efficient than opening a new session per check.
|
| 102 |
+
|
| 103 |
+
Args:
|
| 104 |
+
url: The URL to validate.
|
| 105 |
+
checks: List of natural-language instructions to verify.
|
| 106 |
+
|
| 107 |
+
Returns:
|
| 108 |
+
A tuple of (passed: bool, output: str).
|
| 109 |
+
"""
|
| 110 |
+
browserbase_api_key = os.environ.get("BROWSERBASE_API_KEY", "")
|
| 111 |
+
model_api_key = os.environ.get("MODEL_API_KEY", os.environ.get("OPENROUTER_API_KEY", ""))
|
| 112 |
+
|
| 113 |
+
if not browserbase_api_key:
|
| 114 |
+
return False, "BROWSERBASE_API_KEY is not configured"
|
| 115 |
+
if not model_api_key:
|
| 116 |
+
return False, "MODEL_API_KEY or OPENROUTER_API_KEY is not configured"
|
| 117 |
+
if not checks:
|
| 118 |
+
return True, "No checks to run"
|
| 119 |
+
|
| 120 |
+
try:
|
| 121 |
+
with Stagehand(
|
| 122 |
+
server="remote",
|
| 123 |
+
browserbase_api_key=browserbase_api_key,
|
| 124 |
+
model_api_key=model_api_key,
|
| 125 |
+
) as client:
|
| 126 |
+
session = client.sessions.start(
|
| 127 |
+
model_name="openrouter/anthropic/claude-sonnet-4-6",
|
| 128 |
+
browser={"type": "browserbase"},
|
| 129 |
+
)
|
| 130 |
+
|
| 131 |
+
cdp_url = session.data.cdp_url
|
| 132 |
+
if not cdp_url:
|
| 133 |
+
client.sessions.end(session.id)
|
| 134 |
+
return False, "No CDP URL from Browserbase"
|
| 135 |
+
|
| 136 |
+
all_outputs: list[str] = []
|
| 137 |
+
try:
|
| 138 |
+
with sync_playwright() as p:
|
| 139 |
+
browser_conn = p.chromium.connect_over_cdp(cdp_url)
|
| 140 |
+
context = browser_conn.contexts[0] if browser_conn.contexts else browser_conn.new_context()
|
| 141 |
+
page = context.pages[0] if context.pages else context.new_page()
|
| 142 |
+
|
| 143 |
+
client.sessions.navigate(session.id, url=url)
|
| 144 |
+
page.wait_for_load_state("domcontentloaded")
|
| 145 |
+
time.sleep(2)
|
| 146 |
+
|
| 147 |
+
for i, check in enumerate(checks):
|
| 148 |
+
observe_stream = client.sessions.observe(
|
| 149 |
+
session.id,
|
| 150 |
+
instruction=check,
|
| 151 |
+
stream_response=True,
|
| 152 |
+
x_stream_response="true",
|
| 153 |
+
)
|
| 154 |
+
for event in observe_stream:
|
| 155 |
+
data = _event_data(event)
|
| 156 |
+
if data:
|
| 157 |
+
all_outputs.append(f"[Check {i+1}] {data}")
|
| 158 |
+
|
| 159 |
+
output_text = "\n".join(all_outputs) if all_outputs else "All checks observed."
|
| 160 |
+
passed = "error" not in output_text.lower()
|
| 161 |
+
return passed, output_text
|
| 162 |
+
|
| 163 |
+
finally:
|
| 164 |
+
browser_conn.close() if "browser_conn" in dir() else None
|
| 165 |
+
|
| 166 |
+
client.sessions.end(session.id)
|
| 167 |
+
|
| 168 |
+
except Exception as exc:
|
| 169 |
+
return False, f"Stagehand validation error: {exc}"
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def _event_data(event: Any) -> str | None:
|
| 173 |
+
if hasattr(event, "data") and event.data:
|
| 174 |
+
data = event.data
|
| 175 |
+
if hasattr(data, "message"):
|
| 176 |
+
return str(data.message)
|
| 177 |
+
if isinstance(data, dict):
|
| 178 |
+
return str(data.get("message", str(data)))
|
| 179 |
+
return str(data)
|
| 180 |
+
return None
|
arena/swarm_runtime.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Prompt-first DeepAgent Swarm runtime."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import threading
|
| 6 |
+
from typing import Any, Callable
|
| 7 |
+
|
| 8 |
+
from .agent import AgentSession, create_session
|
| 9 |
+
from .codebase_archive import CodebaseArchiveStore, create_codebase_archive_store
|
| 10 |
+
from .event_bus import EventBus
|
| 11 |
+
from .codebase_handoff import CodebaseHandoff
|
| 12 |
+
from .model_catalog import ModelProviderConfig
|
| 13 |
+
from .run_models import Run
|
| 14 |
+
from .sandbox_lease import SandboxLeaseManager
|
| 15 |
+
from .swarm_session import SwarmRunResult, SwarmRunSession, SwarmRunState
|
| 16 |
+
|
| 17 |
+
SessionFactory = Callable[..., AgentSession]
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class SwarmRuntime:
|
| 21 |
+
"""Starts and drives one DeepAgent Swarm for a prompt-first Run."""
|
| 22 |
+
|
| 23 |
+
def __init__(
|
| 24 |
+
self,
|
| 25 |
+
session_factory: SessionFactory = create_session,
|
| 26 |
+
archive_store: CodebaseArchiveStore | None = None,
|
| 27 |
+
event_bus: EventBus | None = None,
|
| 28 |
+
sandbox_ttl_seconds: float | None = None,
|
| 29 |
+
sandbox_leases: SandboxLeaseManager | None = None,
|
| 30 |
+
) -> None:
|
| 31 |
+
self._session_factory = session_factory
|
| 32 |
+
self._archive_store = archive_store or create_codebase_archive_store()
|
| 33 |
+
self._codebase_handoff = CodebaseHandoff(archive_store=self._archive_store)
|
| 34 |
+
self._event_bus = event_bus or EventBus()
|
| 35 |
+
self._runs: dict[str, SwarmRunSession] = {}
|
| 36 |
+
self._lock = threading.RLock()
|
| 37 |
+
self._sandbox_leases = sandbox_leases or SandboxLeaseManager(ttl_seconds=sandbox_ttl_seconds)
|
| 38 |
+
|
| 39 |
+
def start_run(self, run: Run, *, model_provider: ModelProviderConfig | None = None) -> SwarmRunState:
|
| 40 |
+
session = self._create_session(run.id, model_provider)
|
| 41 |
+
run_session = SwarmRunSession(
|
| 42 |
+
run=run,
|
| 43 |
+
session=session,
|
| 44 |
+
codebase_handoff=self._codebase_handoff,
|
| 45 |
+
archive_store=self._archive_store,
|
| 46 |
+
emit=lambda kind, data, run_id=run.id: self._emit(run_id, kind, data),
|
| 47 |
+
)
|
| 48 |
+
with self._lock:
|
| 49 |
+
self._runs[run.id] = run_session
|
| 50 |
+
self._sandbox_leases.start(
|
| 51 |
+
run.id,
|
| 52 |
+
run_session.state.session.close,
|
| 53 |
+
on_expire=lambda run_id=run.id: self._forget_run(run_id),
|
| 54 |
+
)
|
| 55 |
+
return run_session.state
|
| 56 |
+
|
| 57 |
+
def execute_run(self, run_id: str, *, user_tests: list[str] | None = None, max_retries: int = 3) -> SwarmRunResult:
|
| 58 |
+
try:
|
| 59 |
+
return self._require_run(run_id).execute(user_tests=user_tests, max_retries=max_retries)
|
| 60 |
+
finally:
|
| 61 |
+
self.close_run(run_id)
|
| 62 |
+
|
| 63 |
+
def close_run(self, run_id: str) -> None:
|
| 64 |
+
with self._lock:
|
| 65 |
+
state = self._runs.pop(run_id, None)
|
| 66 |
+
if state is None:
|
| 67 |
+
return
|
| 68 |
+
self._sandbox_leases.close(run_id)
|
| 69 |
+
|
| 70 |
+
def touch_run(self, run_id: str) -> bool:
|
| 71 |
+
with self._lock:
|
| 72 |
+
state = self._runs.get(run_id)
|
| 73 |
+
if state is None:
|
| 74 |
+
return False
|
| 75 |
+
return self._sandbox_leases.touch(run_id)
|
| 76 |
+
|
| 77 |
+
def _emit(self, run_id: str, kind: str, data: dict[str, Any]) -> None:
|
| 78 |
+
self._event_bus.publish(run_id, {"kind": kind, **data})
|
| 79 |
+
|
| 80 |
+
def _create_session(self, run_id: str, model_provider: ModelProviderConfig | None) -> AgentSession:
|
| 81 |
+
try:
|
| 82 |
+
return self._session_factory(run_id, model_provider)
|
| 83 |
+
except TypeError:
|
| 84 |
+
return self._session_factory(run_id)
|
| 85 |
+
|
| 86 |
+
def _require_run(self, run_id: str) -> SwarmRunSession:
|
| 87 |
+
with self._lock:
|
| 88 |
+
run_session = self._runs.get(run_id)
|
| 89 |
+
if run_session is None:
|
| 90 |
+
raise KeyError(f"unknown run: {run_id}")
|
| 91 |
+
return run_session
|
| 92 |
+
|
| 93 |
+
def _forget_run(self, run_id: str) -> None:
|
| 94 |
+
with self._lock:
|
| 95 |
+
self._runs.pop(run_id, None)
|
arena/swarm_session.py
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""One live Swarm session for a prompt-first Run."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import functools
|
| 6 |
+
import os
|
| 7 |
+
import re
|
| 8 |
+
import shlex
|
| 9 |
+
from dataclasses import dataclass
|
| 10 |
+
from typing import Any, Callable
|
| 11 |
+
from uuid import uuid4
|
| 12 |
+
|
| 13 |
+
from .agent import AgentSession
|
| 14 |
+
from .codebase_archive import CodebaseArchiveStore
|
| 15 |
+
from .codebase_handoff import CodebaseHandoff
|
| 16 |
+
from .run_models import CodebaseArchive, Run, RunEvent, RunTask
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
CONTEXT_DIR = "/context"
|
| 20 |
+
DEFAULT_WORKSPACE_DIR = "/workspace"
|
| 21 |
+
|
| 22 |
+
RunEventEmitter = Callable[[str, dict[str, Any]], None]
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
@dataclass
|
| 26 |
+
class SwarmRunState:
|
| 27 |
+
run_id: str
|
| 28 |
+
thread_id: str
|
| 29 |
+
session: AgentSession
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
@dataclass(frozen=True)
|
| 33 |
+
class SwarmRunResult:
|
| 34 |
+
summary: str
|
| 35 |
+
tasks: list[RunTask]
|
| 36 |
+
events: list[RunEvent]
|
| 37 |
+
codebase_archive: CodebaseArchive
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
class SwarmRunSession:
|
| 41 |
+
"""Drives the live AgentSession and snapshots its Workspace."""
|
| 42 |
+
|
| 43 |
+
def __init__(
|
| 44 |
+
self,
|
| 45 |
+
*,
|
| 46 |
+
run: Run,
|
| 47 |
+
session: AgentSession,
|
| 48 |
+
codebase_handoff: CodebaseHandoff,
|
| 49 |
+
archive_store: CodebaseArchiveStore,
|
| 50 |
+
emit: RunEventEmitter,
|
| 51 |
+
) -> None:
|
| 52 |
+
normalize_backend_paths(session.backend, session.workspace_dir)
|
| 53 |
+
self.state = SwarmRunState(run_id=run.id, thread_id=session.thread_id, session=session)
|
| 54 |
+
self._codebase_handoff = codebase_handoff
|
| 55 |
+
self._archive_store = archive_store
|
| 56 |
+
self._emit = emit
|
| 57 |
+
self._seed_context(run)
|
| 58 |
+
|
| 59 |
+
def execute(self, *, user_tests: list[str] | None = None, max_retries: int = 3) -> SwarmRunResult:
|
| 60 |
+
run_id = self.state.run_id
|
| 61 |
+
task = RunTask(id=f"task_{uuid4().hex}", title="Build codebase", status="active")
|
| 62 |
+
start_event = RunEvent(id=f"event_{uuid4().hex}", message="Coordinator started work")
|
| 63 |
+
self._emit("task_created", {"task": task.model_dump()})
|
| 64 |
+
self._emit("event", {"event": start_event.model_dump(mode="json")})
|
| 65 |
+
|
| 66 |
+
workspace_dir = self.state.session.workspace_dir
|
| 67 |
+
events: list[RunEvent] = [start_event]
|
| 68 |
+
user_tests = user_tests or []
|
| 69 |
+
self.state.session.messages.append(
|
| 70 |
+
{
|
| 71 |
+
"role": "user",
|
| 72 |
+
"content": (
|
| 73 |
+
"Read /context/run.md, plan the work, delegate to subagents when useful, "
|
| 74 |
+
f"build the demo package in {workspace_dir}, run relevant checks, and summarize changed files. "
|
| 75 |
+
"For Backyard Demo Builder runs, produce a Space-ready Gradio demo plus README.md, "
|
| 76 |
+
"handoff_spec.md, and field_notes.md unless the Run context says otherwise."
|
| 77 |
+
),
|
| 78 |
+
}
|
| 79 |
+
)
|
| 80 |
+
self._emit("status", {"status": "working"})
|
| 81 |
+
|
| 82 |
+
self._invoke_agent()
|
| 83 |
+
summary = self._run_tests_and_retry(workspace_dir, user_tests, max_retries)
|
| 84 |
+
|
| 85 |
+
archive_pointer = self._codebase_handoff.snapshot_backend_workspace(
|
| 86 |
+
self.state.session.backend,
|
| 87 |
+
run_id,
|
| 88 |
+
workspace_dir=workspace_dir,
|
| 89 |
+
)
|
| 90 |
+
archive = self._archive_store.load(archive_pointer)
|
| 91 |
+
completed_task = task.model_copy(update={"status": "completed"})
|
| 92 |
+
completed_event = RunEvent(id=f"event_{uuid4().hex}", message=summary or "Coordinator finished work")
|
| 93 |
+
self._emit("task_updated", {"task": completed_task.model_dump()})
|
| 94 |
+
self._emit("event", {"event": completed_event.model_dump(mode="json")})
|
| 95 |
+
self._emit(
|
| 96 |
+
"archive",
|
| 97 |
+
{
|
| 98 |
+
"pointer": archive_pointer,
|
| 99 |
+
"file_count": len(archive.files),
|
| 100 |
+
"size_bytes": sum(file.size_bytes for file in archive.files),
|
| 101 |
+
},
|
| 102 |
+
)
|
| 103 |
+
self._emit("status", {"status": "validating"})
|
| 104 |
+
events.append(completed_event)
|
| 105 |
+
return SwarmRunResult(
|
| 106 |
+
summary=summary,
|
| 107 |
+
tasks=[completed_task],
|
| 108 |
+
events=events,
|
| 109 |
+
codebase_archive=CodebaseArchive(
|
| 110 |
+
pointer=archive_pointer,
|
| 111 |
+
file_count=len(archive.files),
|
| 112 |
+
size_bytes=sum(file.size_bytes for file in archive.files),
|
| 113 |
+
),
|
| 114 |
+
)
|
| 115 |
+
|
| 116 |
+
def _run_tests_and_retry(self, workspace_dir: str, user_tests: list[str], max_retries: int) -> str:
|
| 117 |
+
for attempt in range(1, max_retries + 1):
|
| 118 |
+
failed_tests = self._run_user_tests(workspace_dir, user_tests)
|
| 119 |
+
if not failed_tests:
|
| 120 |
+
return self._invoke_agent() or "Build complete."
|
| 121 |
+
|
| 122 |
+
if attempt < max_retries:
|
| 123 |
+
self.state.session.messages.append(
|
| 124 |
+
{
|
| 125 |
+
"role": "user",
|
| 126 |
+
"content": (
|
| 127 |
+
f"Fix attempt {attempt}/{max_retries}. The following user tests failed:\n\n"
|
| 128 |
+
+ "\n".join(f" {test}\n {output}" for test, output in failed_tests)
|
| 129 |
+
+ "\n\nFix the issues and re-run the tests."
|
| 130 |
+
),
|
| 131 |
+
}
|
| 132 |
+
)
|
| 133 |
+
self._emit(
|
| 134 |
+
"event",
|
| 135 |
+
{
|
| 136 |
+
"event": RunEvent(
|
| 137 |
+
id=f"event_{uuid4().hex}",
|
| 138 |
+
message=f"Fix attempt {attempt}/{max_retries}: {len(failed_tests)} test(s) failed",
|
| 139 |
+
).model_dump(mode="json"),
|
| 140 |
+
},
|
| 141 |
+
)
|
| 142 |
+
self._invoke_agent()
|
| 143 |
+
|
| 144 |
+
return self._invoke_agent() or "Build finished after retries."
|
| 145 |
+
|
| 146 |
+
def _run_user_tests(self, workspace_dir: str, user_tests: list[str]) -> list[tuple[str, str]]:
|
| 147 |
+
execute = getattr(self.state.session.backend, "execute", None)
|
| 148 |
+
if not user_tests or not callable(execute):
|
| 149 |
+
return []
|
| 150 |
+
failed: list[tuple[str, str]] = []
|
| 151 |
+
for test in user_tests:
|
| 152 |
+
try:
|
| 153 |
+
result = execute(f"cd {shlex.quote(workspace_dir)} && {test}")
|
| 154 |
+
exit_code = getattr(result, "exit_code", None)
|
| 155 |
+
output = getattr(result, "output", "") or ""
|
| 156 |
+
if exit_code is not None and exit_code != 0:
|
| 157 |
+
failed.append((test, output[:500]))
|
| 158 |
+
except Exception:
|
| 159 |
+
pass
|
| 160 |
+
return failed
|
| 161 |
+
|
| 162 |
+
def _invoke_agent(self) -> str:
|
| 163 |
+
result = self.state.session.agent.invoke(
|
| 164 |
+
{"messages": self.state.session.messages},
|
| 165 |
+
config={"configurable": {"thread_id": self.state.thread_id, "run_id": self.state.run_id}},
|
| 166 |
+
)
|
| 167 |
+
content = last_message_text(result)
|
| 168 |
+
if content:
|
| 169 |
+
self.state.session.messages.append({"role": "assistant", "content": content})
|
| 170 |
+
return content
|
| 171 |
+
|
| 172 |
+
def _seed_context(self, run: Run) -> None:
|
| 173 |
+
self._write_file(f"{CONTEXT_DIR}/run.md", run_markdown(run, self.state.session.workspace_dir))
|
| 174 |
+
self._ensure_workspace(self.state.session.workspace_dir)
|
| 175 |
+
|
| 176 |
+
def _ensure_workspace(self, workspace_dir: str) -> None:
|
| 177 |
+
execute = getattr(self.state.session.backend, "execute", None)
|
| 178 |
+
if callable(execute):
|
| 179 |
+
result = execute(f"mkdir -p {workspace_dir}")
|
| 180 |
+
if getattr(result, "exit_code", 0) == 0:
|
| 181 |
+
return
|
| 182 |
+
self._write_file(f"{workspace_dir}/.keep", "")
|
| 183 |
+
|
| 184 |
+
def _write_file(self, path: str, content: str) -> None:
|
| 185 |
+
result = self.state.session.backend.write(path, content)
|
| 186 |
+
if getattr(result, "error", None):
|
| 187 |
+
raise RuntimeError(f"could not write sandbox file: {path}")
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
def normalize_backend_paths(backend: Any, workspace_dir: str) -> None:
|
| 191 |
+
"""Wrap backend writes to redirect host-looking paths into the virtual Workspace."""
|
| 192 |
+
original_write = backend.write
|
| 193 |
+
original_edit = getattr(backend, "edit_file", None)
|
| 194 |
+
|
| 195 |
+
bad_prefixes: list[str] = []
|
| 196 |
+
home = os.path.expanduser("~")
|
| 197 |
+
if home and workspace_dir:
|
| 198 |
+
bad_prefixes.append(os.path.join(home, workspace_dir.lstrip("/")))
|
| 199 |
+
|
| 200 |
+
@functools.wraps(original_write)
|
| 201 |
+
def safe_write(path: str, content: str, *args: Any, **kwargs: Any) -> Any:
|
| 202 |
+
path = _normalize_path_prefix(path, bad_prefixes, workspace_dir)
|
| 203 |
+
path = normalize_host_path_to_virtual(path, workspace_dir)
|
| 204 |
+
return original_write(path, content, *args, **kwargs)
|
| 205 |
+
|
| 206 |
+
backend.write = safe_write
|
| 207 |
+
|
| 208 |
+
if original_edit is not None:
|
| 209 |
+
|
| 210 |
+
@functools.wraps(original_edit)
|
| 211 |
+
def safe_edit(path: str, *args: Any, **kwargs: Any) -> Any:
|
| 212 |
+
path = _normalize_path_prefix(path, bad_prefixes, workspace_dir)
|
| 213 |
+
path = normalize_host_path_to_virtual(path, workspace_dir)
|
| 214 |
+
return original_edit(path, *args, **kwargs)
|
| 215 |
+
|
| 216 |
+
backend.edit_file = safe_edit
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
def _normalize_path_prefix(path: str, bad_prefixes: list[str], workspace_dir: str) -> str:
|
| 220 |
+
for bad_prefix in bad_prefixes:
|
| 221 |
+
if path.startswith(bad_prefix):
|
| 222 |
+
return workspace_dir + path[len(bad_prefix):]
|
| 223 |
+
return path
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
def normalize_host_path_to_virtual(path: str, virtual_workspace: str) -> str:
|
| 227 |
+
if not path.startswith("/") or virtual_workspace.startswith(path):
|
| 228 |
+
return path
|
| 229 |
+
workspace_pattern = re.escape(virtual_workspace.rstrip("/")) + r"(?=/|$)"
|
| 230 |
+
match = re.search(workspace_pattern, path)
|
| 231 |
+
if match is None:
|
| 232 |
+
return path
|
| 233 |
+
return path[match.start():]
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
def run_markdown(run: Run, workspace_dir: str = DEFAULT_WORKSPACE_DIR) -> str:
|
| 237 |
+
criteria = "\n".join(f"- {item.text}" for item in run.criteria) or "- None"
|
| 238 |
+
user_tests = "\n".join(f"- `{test}`" for test in run.user_tests) or "- None"
|
| 239 |
+
model_provider = "- None"
|
| 240 |
+
if run.model_provider is not None:
|
| 241 |
+
model_provider = (
|
| 242 |
+
f"- Provider: {run.model_provider.provider}\n"
|
| 243 |
+
f"- Model: {run.model_provider.model or 'provider default'}\n"
|
| 244 |
+
f"- Endpoint: {run.model_provider.base_url or 'provider default'}"
|
| 245 |
+
)
|
| 246 |
+
return (
|
| 247 |
+
f"# Run {run.id}\n\n"
|
| 248 |
+
"## User Prompt\n\n"
|
| 249 |
+
f"{run.prompt}\n\n"
|
| 250 |
+
"## Model Provider\n\n"
|
| 251 |
+
f"{model_provider}\n\n"
|
| 252 |
+
"## Criteria\n\n"
|
| 253 |
+
f"{criteria}\n\n"
|
| 254 |
+
"## User Tests\n\n"
|
| 255 |
+
f"{user_tests}\n\n"
|
| 256 |
+
"## Workspace\n\n"
|
| 257 |
+
f"Build the demo package in `{workspace_dir}`.\n\n"
|
| 258 |
+
"## Required Demo Package Files\n\n"
|
| 259 |
+
"- `app.py`: runnable Gradio demo app\n"
|
| 260 |
+
"- `README.md`: setup and demo purpose\n"
|
| 261 |
+
"- `handoff_spec.md`: what to build if the real person approves\n"
|
| 262 |
+
"- `field_notes.md`: real-person test plan, feedback questions, demo script notes\n"
|
| 263 |
+
)
|
| 264 |
+
|
| 265 |
+
|
| 266 |
+
def content_text(value: Any) -> str:
|
| 267 |
+
if isinstance(value, str):
|
| 268 |
+
return value
|
| 269 |
+
if isinstance(value, list):
|
| 270 |
+
return "\n".join(
|
| 271 |
+
str(item.get("text", item)) if isinstance(item, dict) else str(item) for item in value
|
| 272 |
+
)
|
| 273 |
+
return str(value or "")
|
| 274 |
+
|
| 275 |
+
|
| 276 |
+
def last_message_text(result: dict[str, Any]) -> str:
|
| 277 |
+
messages = result.get("messages", [])
|
| 278 |
+
if not messages:
|
| 279 |
+
return ""
|
| 280 |
+
last = messages[-1]
|
| 281 |
+
content = getattr(last, "content", None)
|
| 282 |
+
if content is None and isinstance(last, dict):
|
| 283 |
+
content = last.get("content")
|
| 284 |
+
return content_text(content)
|
arena/thread_inspector.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Manual Thread inspection for ad-hoc AgentSession debugging."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from typing import Any, Callable
|
| 6 |
+
|
| 7 |
+
from pydantic import BaseModel
|
| 8 |
+
|
| 9 |
+
from .agent import AgentSession, create_session
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class ThreadInspectorError(Exception):
|
| 13 |
+
pass
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class ThreadNotFound(ThreadInspectorError):
|
| 17 |
+
pass
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class AgentRunFailed(ThreadInspectorError):
|
| 21 |
+
pass
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class FileAccessFailed(ThreadInspectorError):
|
| 25 |
+
pass
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class ThreadResponse(BaseModel):
|
| 29 |
+
thread_id: str
|
| 30 |
+
sandbox_provider: str
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class MessageResponse(BaseModel):
|
| 34 |
+
thread_id: str
|
| 35 |
+
content: str
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class FileEntry(BaseModel):
|
| 39 |
+
path: str
|
| 40 |
+
type: str
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
SessionFactory = Callable[..., AgentSession]
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
class ThreadInspector:
|
| 47 |
+
"""Owns the ad-hoc AgentSession interface used by debug routes."""
|
| 48 |
+
|
| 49 |
+
def __init__(self, *, session_factory: SessionFactory = create_session) -> None:
|
| 50 |
+
self._session_factory = session_factory
|
| 51 |
+
self._sessions: dict[str, AgentSession] = {}
|
| 52 |
+
|
| 53 |
+
@property
|
| 54 |
+
def sessions(self) -> dict[str, AgentSession]:
|
| 55 |
+
return self._sessions
|
| 56 |
+
|
| 57 |
+
def create_thread(self) -> ThreadResponse:
|
| 58 |
+
session = self._session_factory()
|
| 59 |
+
self._sessions[session.thread_id] = session
|
| 60 |
+
return ThreadResponse(thread_id=session.thread_id, sandbox_provider=session.provider)
|
| 61 |
+
|
| 62 |
+
def send_message(self, thread_id: str, content: str) -> MessageResponse:
|
| 63 |
+
session = self._session(thread_id)
|
| 64 |
+
session.messages.append({"role": "user", "content": content})
|
| 65 |
+
try:
|
| 66 |
+
result = session.agent.invoke(
|
| 67 |
+
{"messages": session.messages},
|
| 68 |
+
config={"configurable": {"thread_id": thread_id}},
|
| 69 |
+
)
|
| 70 |
+
except Exception as exc:
|
| 71 |
+
raise AgentRunFailed("agent run failed") from exc
|
| 72 |
+
|
| 73 |
+
response = _last_message_text(result)
|
| 74 |
+
session.messages.append({"role": "assistant", "content": response})
|
| 75 |
+
return MessageResponse(thread_id=thread_id, content=response)
|
| 76 |
+
|
| 77 |
+
def list_files(self, thread_id: str, path: str = "/") -> list[FileEntry]:
|
| 78 |
+
session = self._session(thread_id)
|
| 79 |
+
result = session.backend.ls(path)
|
| 80 |
+
if getattr(result, "error", None):
|
| 81 |
+
raise FileAccessFailed("could not list files")
|
| 82 |
+
entries = getattr(result, "entries", [])
|
| 83 |
+
return [_file_entry(entry) for entry in entries]
|
| 84 |
+
|
| 85 |
+
def read_file(self, thread_id: str, path: str) -> dict[str, str]:
|
| 86 |
+
session = self._session(thread_id)
|
| 87 |
+
result = session.backend.read(path)
|
| 88 |
+
if getattr(result, "error", None):
|
| 89 |
+
raise FileAccessFailed("could not read file")
|
| 90 |
+
file_data = getattr(result, "file_data", {})
|
| 91 |
+
return {"path": path, "content": _file_content(file_data)}
|
| 92 |
+
|
| 93 |
+
def delete_thread(self, thread_id: str) -> dict[str, bool]:
|
| 94 |
+
session = self._sessions.pop(thread_id, None)
|
| 95 |
+
if session is None:
|
| 96 |
+
raise ThreadNotFound("thread not found")
|
| 97 |
+
session.close()
|
| 98 |
+
return {"deleted": True}
|
| 99 |
+
|
| 100 |
+
def _session(self, thread_id: str) -> AgentSession:
|
| 101 |
+
session = self._sessions.get(thread_id)
|
| 102 |
+
if session is None:
|
| 103 |
+
raise ThreadNotFound("thread not found")
|
| 104 |
+
return session
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def _file_entry(entry: Any) -> FileEntry:
|
| 108 |
+
path = entry.get("path", "") if isinstance(entry, dict) else getattr(entry, "path", "")
|
| 109 |
+
is_dir = entry.get("is_dir") if isinstance(entry, dict) else getattr(entry, "is_dir", False)
|
| 110 |
+
return FileEntry(path=str(path), type="directory" if is_dir else "file")
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def _file_content(file_data: Any) -> str:
|
| 114 |
+
if isinstance(file_data, dict):
|
| 115 |
+
return str(file_data.get("content", ""))
|
| 116 |
+
return str(getattr(file_data, "content", ""))
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def _content(value: Any) -> str:
|
| 120 |
+
if isinstance(value, str):
|
| 121 |
+
return value
|
| 122 |
+
if isinstance(value, list):
|
| 123 |
+
return "\n".join(
|
| 124 |
+
str(item.get("text", item)) if isinstance(item, dict) else str(item) for item in value
|
| 125 |
+
)
|
| 126 |
+
return str(value or "")
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def _last_message_text(result: dict[str, Any]) -> str:
|
| 130 |
+
messages = result.get("messages", [])
|
| 131 |
+
if not messages:
|
| 132 |
+
return ""
|
| 133 |
+
last = messages[-1]
|
| 134 |
+
content = getattr(last, "content", None)
|
| 135 |
+
if content is None and isinstance(last, dict):
|
| 136 |
+
content = last.get("content")
|
| 137 |
+
return _content(content)
|
arena/validation_models.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Validator domain model import surface.
|
| 2 |
+
|
| 3 |
+
New Validator modules should import from here instead of the shared model file.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
from .models import (
|
| 9 |
+
Criteria,
|
| 10 |
+
ValidationCheck,
|
| 11 |
+
ValidationCheckResult,
|
| 12 |
+
ValidationCheckSource,
|
| 13 |
+
ValidationReport,
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
__all__ = [
|
| 17 |
+
"Criteria",
|
| 18 |
+
"ValidationCheck",
|
| 19 |
+
"ValidationCheckResult",
|
| 20 |
+
"ValidationCheckSource",
|
| 21 |
+
"ValidationReport",
|
| 22 |
+
]
|