Kiy-K commited on
Commit
31c8075
·
verified ·
1 Parent(s): e94963c

Restore full Space app with bucket model support

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
.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: Agent Swarm Workbench
3
- emoji: 🐠
4
- colorFrom: green
5
  colorTo: green
6
  sdk: gradio
7
- sdk_version: 6.16.0
8
- python_version: '3.13'
9
  app_file: app.py
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  pinned: false
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 &middot; Build demo</span></div>
52
+ <div class="rail-row"><span class="rail-label">Cancel</span><span class="rail-kbd">Click &middot; Cancel build</span></div>
53
+ <div class="rail-row"><span class="rail-label">Refresh</span><span class="rail-kbd">Click &middot; 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
+ ]