mechbench-runner 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mechbench_runner-0.1.0/PKG-INFO +172 -0
- mechbench_runner-0.1.0/README.md +142 -0
- mechbench_runner-0.1.0/mechbench_runner/__init__.py +3 -0
- mechbench_runner-0.1.0/mechbench_runner/_smoke.py +73 -0
- mechbench_runner-0.1.0/mechbench_runner/agent.py +276 -0
- mechbench_runner-0.1.0/mechbench_runner/api_client.py +194 -0
- mechbench_runner-0.1.0/mechbench_runner/channel.py +331 -0
- mechbench_runner-0.1.0/mechbench_runner/cli.py +277 -0
- mechbench_runner-0.1.0/mechbench_runner/config.py +95 -0
- mechbench_runner-0.1.0/mechbench_runner/control.py +517 -0
- mechbench_runner-0.1.0/mechbench_runner/credentials.py +157 -0
- mechbench_runner-0.1.0/mechbench_runner/doctor.py +282 -0
- mechbench_runner-0.1.0/mechbench_runner/exits.py +32 -0
- mechbench_runner-0.1.0/mechbench_runner/job_runner.py +367 -0
- mechbench_runner-0.1.0/mechbench_runner/login.py +257 -0
- mechbench_runner-0.1.0/mechbench_runner/logs.py +161 -0
- mechbench_runner-0.1.0/mechbench_runner/machine.py +48 -0
- mechbench_runner-0.1.0/mechbench_runner/mcp_server.py +95 -0
- mechbench_runner-0.1.0/mechbench_runner/models_cmd.py +82 -0
- mechbench_runner-0.1.0/mechbench_runner/paths.py +28 -0
- mechbench_runner-0.1.0/mechbench_runner/watchdog.py +107 -0
- mechbench_runner-0.1.0/mechbench_runner.egg-info/PKG-INFO +172 -0
- mechbench_runner-0.1.0/mechbench_runner.egg-info/SOURCES.txt +35 -0
- mechbench_runner-0.1.0/mechbench_runner.egg-info/dependency_links.txt +1 -0
- mechbench_runner-0.1.0/mechbench_runner.egg-info/entry_points.txt +2 -0
- mechbench_runner-0.1.0/mechbench_runner.egg-info/requires.txt +10 -0
- mechbench_runner-0.1.0/mechbench_runner.egg-info/top_level.txt +1 -0
- mechbench_runner-0.1.0/pyproject.toml +58 -0
- mechbench_runner-0.1.0/setup.cfg +4 -0
- mechbench_runner-0.1.0/tests/test_agent.py +148 -0
- mechbench_runner-0.1.0/tests/test_channel.py +248 -0
- mechbench_runner-0.1.0/tests/test_control.py +184 -0
- mechbench_runner-0.1.0/tests/test_credentials.py +143 -0
- mechbench_runner-0.1.0/tests/test_doctor.py +192 -0
- mechbench_runner-0.1.0/tests/test_promotion.py +116 -0
- mechbench_runner-0.1.0/tests/test_signed_out.py +112 -0
- mechbench_runner-0.1.0/tests/test_supervision.py +142 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mechbench-runner
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: The mechbench runner: claims jobs from mechbench-api, executes protocols against mechbench-compute, and emits results. Also exposes those primitives as MCP tools.
|
|
5
|
+
Author-email: Benji Smith <benji@shaxpir.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/mechbench/mechbench-runner
|
|
8
|
+
Project-URL: Website, https://mechbench.ai
|
|
9
|
+
Keywords: interpretability,mechanistic-interpretability,mlx,mcp,runner
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Science/Research
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: MacOS :: MacOS X
|
|
15
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
19
|
+
Requires-Python: >=3.11
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
Requires-Dist: mcp>=1.2
|
|
22
|
+
Requires-Dist: mechbench-compute>=0.10
|
|
23
|
+
Requires-Dist: mechbench-schema>=0.13
|
|
24
|
+
Requires-Dist: httpx>=0.27
|
|
25
|
+
Requires-Dist: websockets>=13
|
|
26
|
+
Requires-Dist: pydantic>=2.7
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
29
|
+
Requires-Dist: ruff>=0.6; extra == "dev"
|
|
30
|
+
|
|
31
|
+
# mechbench-runner
|
|
32
|
+
|
|
33
|
+
The machine-side process of the [mechbench](https://mechbench.ai) family: it claims queued jobs from `mechbench-api`, executes them against `mechbench-compute`, and posts results back. It also exposes those same primitives as [Model Context Protocol](https://modelcontextprotocol.io) tools, so an LLM agent can call them directly.
|
|
34
|
+
|
|
35
|
+
**Status:** in use. `login` pairs a machine with an account; the runner then claims and executes jobs, reports progress and preparing steps, holds a live WSS channel for control and telemetry, and installs as a launchd or systemd service so it survives reboots. `doctor` tells you whether a machine will work before it tries. Three MCP tools (`run_protocol`, `get_result`, `list_jobs`) expose the same primitives to an agent.
|
|
36
|
+
|
|
37
|
+
## What this repo is for
|
|
38
|
+
|
|
39
|
+
Two adjacent surfaces for different callers:
|
|
40
|
+
|
|
41
|
+
1. **MCP server.** An LLM agent (Claude, others) connects via MCP stdio and calls mechbench primitives as structured tools. Tool bodies run in-process against `mechbench-compute`.
|
|
42
|
+
2. **Job-runner.** Polls `mechbench-api`'s `/jobs/next` for UI-queued protocols, runs them, posts results back. Same compute path as the MCP `run_protocol` tool; different *trigger*.
|
|
43
|
+
|
|
44
|
+
Both modes share one binary (`mechbench-runner`) with subcommands; they share the loaded model, API client, and protocol executor. Splitting into separate processes is a later operational decision — see "Open design questions" below.
|
|
45
|
+
|
|
46
|
+
## Architectural decisions (task 000185)
|
|
47
|
+
|
|
48
|
+
- **Python.** `mechbench-compute` is Python; delegating to Python via RPC or subprocess-shell from a TS runner adds a layer that pays no dividends in v0. The MCP Python SDK is mature.
|
|
49
|
+
- **One binary, two subcommands.** `mechbench-runner mcp` launches the MCP server over stdio; `mechbench-runner run` starts the job-runner loop. They share `ExperimentRunner` (owns the loaded Gemma model) and `ApiClient`.
|
|
50
|
+
- **Agent authenticates to `mechbench-api` with a dedicated API key**, not a user's personal session. Export `MECHBENCH_API_KEY` (mint one at `/settings/api-keys`, or via `POST /auth/api-keys`). Matches the pattern from the e2e trace.
|
|
51
|
+
- **MCP `run_protocol` runs in-process**, not queued through `mechbench-api`. The MCP caller wants the answer; we are the compute target. Job-queue round-tripping exists for the *UI-triggered* path (job-runner subcommand).
|
|
52
|
+
- **stdio transport only.** SSE / HTTP-SSE transports earn their seat once remote MCP deploy matters (deferred).
|
|
53
|
+
|
|
54
|
+
## Install
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
uv tool install mechbench-runner # or: pipx install mechbench-runner
|
|
58
|
+
mechbench-runner login
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
`login` prints a link, takes the registration token from it, stores a
|
|
62
|
+
durable key at `~/.mechbench/config.toml` (mode 0600), and offers to
|
|
63
|
+
start the runner automatically. Say yes and there is nothing further to
|
|
64
|
+
do: it starts at login, comes back after a crash, and is controlled from
|
|
65
|
+
the website.
|
|
66
|
+
|
|
67
|
+
`mechbench-runner doctor` answers "will this actually work here" —
|
|
68
|
+
Python, backend, credentials, API, model cache, disk — before you find
|
|
69
|
+
out the slow way.
|
|
70
|
+
|
|
71
|
+
Running a model needs Apple Silicon (the MLX backend from
|
|
72
|
+
`mechbench-compute`). The rest installs anywhere.
|
|
73
|
+
|
|
74
|
+
### Running it yourself
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
mechbench-runner run # foreground, ^C to stop
|
|
78
|
+
mechbench-runner install-agent # or have the OS keep it running
|
|
79
|
+
mechbench-runner agent-status
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
The service is supervised by launchd or systemd rather than by anything
|
|
83
|
+
we wrote — see `mechbench_runner/exits.py` for the contract that makes
|
|
84
|
+
that work.
|
|
85
|
+
|
|
86
|
+
### From a checkout
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
git clone https://github.com/mechbench/mechbench-runner.git
|
|
90
|
+
cd mechbench-runner
|
|
91
|
+
python3.11 -m venv .venv
|
|
92
|
+
source .venv/bin/activate
|
|
93
|
+
pip install -e '.[dev]'
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Usage
|
|
97
|
+
|
|
98
|
+
### MCP server
|
|
99
|
+
|
|
100
|
+
Launch as a stdio MCP server — connect from Claude Desktop via `claude_desktop_config.json`:
|
|
101
|
+
|
|
102
|
+
```json
|
|
103
|
+
{
|
|
104
|
+
"mcpServers": {
|
|
105
|
+
"mechbench": {
|
|
106
|
+
"command": "/abs/path/to/mechbench-runner/.venv/bin/mechbench-runner",
|
|
107
|
+
"args": ["mcp"],
|
|
108
|
+
"env": {
|
|
109
|
+
"MECHBENCH_API_URL": "http://localhost:3000",
|
|
110
|
+
"MECHBENCH_API_KEY": "mbk_..."
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Three tools appear in Claude:
|
|
118
|
+
|
|
119
|
+
| tool | description |
|
|
120
|
+
|---|---|
|
|
121
|
+
| `run_protocol` | Run a layer-ablation protocol in-process on a prompt; return per-layer damage. |
|
|
122
|
+
| `get_result` | Fetch a cached payload from `mechbench-api` by MechbenchPath. |
|
|
123
|
+
| `list_jobs` | List the caller's queued / running / completed jobs. |
|
|
124
|
+
|
|
125
|
+
### Job-runner
|
|
126
|
+
|
|
127
|
+
Polls `mechbench-api` for UI-queued jobs. Same compute path as `run_protocol`; different trigger.
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
export MECHBENCH_API_URL=http://localhost:3000
|
|
131
|
+
export MECHBENCH_API_KEY=mbk_...
|
|
132
|
+
mechbench-runner run
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Ctrl-C exits cleanly. API-unreachable is retried with exponential backoff capped at 30 s.
|
|
136
|
+
|
|
137
|
+
### In-process smoke test
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
mechbench-runner smoke # quick: list_jobs + get_result
|
|
141
|
+
mechbench-runner smoke --full # adds run_protocol (42 forwards, ~1-2 min)
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## Configuration
|
|
145
|
+
|
|
146
|
+
All via env vars:
|
|
147
|
+
|
|
148
|
+
| var | default | purpose |
|
|
149
|
+
|---|---|---|
|
|
150
|
+
| `MECHBENCH_API_URL` | `http://localhost:3000` | mechbench-api base URL. Ignored when credentials are stored, which carry their own. |
|
|
151
|
+
| `MECHBENCH_API_KEY` | *(from `login`)* | Overrides the stored credential entirely, URL included. For CI and containers, which have nowhere to put a config file. |
|
|
152
|
+
| `MECHBENCH_POLL_INTERVAL_SECONDS` | `2.0` | Job-runner poll cadence. |
|
|
153
|
+
| `MECHBENCH_WARM_MODEL_ID` | *(none)* | Optional model to load at startup so the first job skips cold start. There is deliberately no default: a protocol names the model it runs against, and a job that names none is an error. |
|
|
154
|
+
| `MECHBENCH_WATCHDOG_SECONDS` | `900` | How long without progress counts as wedged. `0` disables it. |
|
|
155
|
+
|
|
156
|
+
## Relationship to other mechbench repos
|
|
157
|
+
|
|
158
|
+
- **`mechbench-compute`** — imported directly. `Model`, `Ablate`, hook-aware forward.
|
|
159
|
+
- **`mechbench-schema`** — produces `LayerAblationPayload` etc. as typed results.
|
|
160
|
+
- **`mechbench-api`** — the runner's only platform dependency. All workspace state (jobs, cache reads) goes through it.
|
|
161
|
+
- **`mechbench-ui`** — no coupling. UI queues jobs; the job-runner consumes them.
|
|
162
|
+
- **`mechbench-experiments`** — research scripts that use `mechbench-compute` directly, without the job machinery.
|
|
163
|
+
|
|
164
|
+
## Open design questions (deferred)
|
|
165
|
+
|
|
166
|
+
- **One binary or two processes?** Current answer: one binary, two subcommands. Revisit if MCP-caller frequency vs. job-runner throughput diverges enough to want independent scaling.
|
|
167
|
+
- **Structured-summary interface.** The family's philosophy doc describes a read-side surface where agents consume JSON summaries of findings / experiments. Currently implicit in `list_jobs` + `get_result`. A richer summary layer (`GET /summary`, `POST /query`) is still on the table but unbuilt.
|
|
168
|
+
- **MCP-surface observability.** Rate limits, per-tool metrics, audit trail for the tool-calling side. Deferred until a second LLM-agent consumer exists.
|
|
169
|
+
|
|
170
|
+
## License
|
|
171
|
+
|
|
172
|
+
MIT.
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# mechbench-runner
|
|
2
|
+
|
|
3
|
+
The machine-side process of the [mechbench](https://mechbench.ai) family: it claims queued jobs from `mechbench-api`, executes them against `mechbench-compute`, and posts results back. It also exposes those same primitives as [Model Context Protocol](https://modelcontextprotocol.io) tools, so an LLM agent can call them directly.
|
|
4
|
+
|
|
5
|
+
**Status:** in use. `login` pairs a machine with an account; the runner then claims and executes jobs, reports progress and preparing steps, holds a live WSS channel for control and telemetry, and installs as a launchd or systemd service so it survives reboots. `doctor` tells you whether a machine will work before it tries. Three MCP tools (`run_protocol`, `get_result`, `list_jobs`) expose the same primitives to an agent.
|
|
6
|
+
|
|
7
|
+
## What this repo is for
|
|
8
|
+
|
|
9
|
+
Two adjacent surfaces for different callers:
|
|
10
|
+
|
|
11
|
+
1. **MCP server.** An LLM agent (Claude, others) connects via MCP stdio and calls mechbench primitives as structured tools. Tool bodies run in-process against `mechbench-compute`.
|
|
12
|
+
2. **Job-runner.** Polls `mechbench-api`'s `/jobs/next` for UI-queued protocols, runs them, posts results back. Same compute path as the MCP `run_protocol` tool; different *trigger*.
|
|
13
|
+
|
|
14
|
+
Both modes share one binary (`mechbench-runner`) with subcommands; they share the loaded model, API client, and protocol executor. Splitting into separate processes is a later operational decision — see "Open design questions" below.
|
|
15
|
+
|
|
16
|
+
## Architectural decisions (task 000185)
|
|
17
|
+
|
|
18
|
+
- **Python.** `mechbench-compute` is Python; delegating to Python via RPC or subprocess-shell from a TS runner adds a layer that pays no dividends in v0. The MCP Python SDK is mature.
|
|
19
|
+
- **One binary, two subcommands.** `mechbench-runner mcp` launches the MCP server over stdio; `mechbench-runner run` starts the job-runner loop. They share `ExperimentRunner` (owns the loaded Gemma model) and `ApiClient`.
|
|
20
|
+
- **Agent authenticates to `mechbench-api` with a dedicated API key**, not a user's personal session. Export `MECHBENCH_API_KEY` (mint one at `/settings/api-keys`, or via `POST /auth/api-keys`). Matches the pattern from the e2e trace.
|
|
21
|
+
- **MCP `run_protocol` runs in-process**, not queued through `mechbench-api`. The MCP caller wants the answer; we are the compute target. Job-queue round-tripping exists for the *UI-triggered* path (job-runner subcommand).
|
|
22
|
+
- **stdio transport only.** SSE / HTTP-SSE transports earn their seat once remote MCP deploy matters (deferred).
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
uv tool install mechbench-runner # or: pipx install mechbench-runner
|
|
28
|
+
mechbench-runner login
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
`login` prints a link, takes the registration token from it, stores a
|
|
32
|
+
durable key at `~/.mechbench/config.toml` (mode 0600), and offers to
|
|
33
|
+
start the runner automatically. Say yes and there is nothing further to
|
|
34
|
+
do: it starts at login, comes back after a crash, and is controlled from
|
|
35
|
+
the website.
|
|
36
|
+
|
|
37
|
+
`mechbench-runner doctor` answers "will this actually work here" —
|
|
38
|
+
Python, backend, credentials, API, model cache, disk — before you find
|
|
39
|
+
out the slow way.
|
|
40
|
+
|
|
41
|
+
Running a model needs Apple Silicon (the MLX backend from
|
|
42
|
+
`mechbench-compute`). The rest installs anywhere.
|
|
43
|
+
|
|
44
|
+
### Running it yourself
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
mechbench-runner run # foreground, ^C to stop
|
|
48
|
+
mechbench-runner install-agent # or have the OS keep it running
|
|
49
|
+
mechbench-runner agent-status
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The service is supervised by launchd or systemd rather than by anything
|
|
53
|
+
we wrote — see `mechbench_runner/exits.py` for the contract that makes
|
|
54
|
+
that work.
|
|
55
|
+
|
|
56
|
+
### From a checkout
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
git clone https://github.com/mechbench/mechbench-runner.git
|
|
60
|
+
cd mechbench-runner
|
|
61
|
+
python3.11 -m venv .venv
|
|
62
|
+
source .venv/bin/activate
|
|
63
|
+
pip install -e '.[dev]'
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Usage
|
|
67
|
+
|
|
68
|
+
### MCP server
|
|
69
|
+
|
|
70
|
+
Launch as a stdio MCP server — connect from Claude Desktop via `claude_desktop_config.json`:
|
|
71
|
+
|
|
72
|
+
```json
|
|
73
|
+
{
|
|
74
|
+
"mcpServers": {
|
|
75
|
+
"mechbench": {
|
|
76
|
+
"command": "/abs/path/to/mechbench-runner/.venv/bin/mechbench-runner",
|
|
77
|
+
"args": ["mcp"],
|
|
78
|
+
"env": {
|
|
79
|
+
"MECHBENCH_API_URL": "http://localhost:3000",
|
|
80
|
+
"MECHBENCH_API_KEY": "mbk_..."
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Three tools appear in Claude:
|
|
88
|
+
|
|
89
|
+
| tool | description |
|
|
90
|
+
|---|---|
|
|
91
|
+
| `run_protocol` | Run a layer-ablation protocol in-process on a prompt; return per-layer damage. |
|
|
92
|
+
| `get_result` | Fetch a cached payload from `mechbench-api` by MechbenchPath. |
|
|
93
|
+
| `list_jobs` | List the caller's queued / running / completed jobs. |
|
|
94
|
+
|
|
95
|
+
### Job-runner
|
|
96
|
+
|
|
97
|
+
Polls `mechbench-api` for UI-queued jobs. Same compute path as `run_protocol`; different trigger.
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
export MECHBENCH_API_URL=http://localhost:3000
|
|
101
|
+
export MECHBENCH_API_KEY=mbk_...
|
|
102
|
+
mechbench-runner run
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Ctrl-C exits cleanly. API-unreachable is retried with exponential backoff capped at 30 s.
|
|
106
|
+
|
|
107
|
+
### In-process smoke test
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
mechbench-runner smoke # quick: list_jobs + get_result
|
|
111
|
+
mechbench-runner smoke --full # adds run_protocol (42 forwards, ~1-2 min)
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Configuration
|
|
115
|
+
|
|
116
|
+
All via env vars:
|
|
117
|
+
|
|
118
|
+
| var | default | purpose |
|
|
119
|
+
|---|---|---|
|
|
120
|
+
| `MECHBENCH_API_URL` | `http://localhost:3000` | mechbench-api base URL. Ignored when credentials are stored, which carry their own. |
|
|
121
|
+
| `MECHBENCH_API_KEY` | *(from `login`)* | Overrides the stored credential entirely, URL included. For CI and containers, which have nowhere to put a config file. |
|
|
122
|
+
| `MECHBENCH_POLL_INTERVAL_SECONDS` | `2.0` | Job-runner poll cadence. |
|
|
123
|
+
| `MECHBENCH_WARM_MODEL_ID` | *(none)* | Optional model to load at startup so the first job skips cold start. There is deliberately no default: a protocol names the model it runs against, and a job that names none is an error. |
|
|
124
|
+
| `MECHBENCH_WATCHDOG_SECONDS` | `900` | How long without progress counts as wedged. `0` disables it. |
|
|
125
|
+
|
|
126
|
+
## Relationship to other mechbench repos
|
|
127
|
+
|
|
128
|
+
- **`mechbench-compute`** — imported directly. `Model`, `Ablate`, hook-aware forward.
|
|
129
|
+
- **`mechbench-schema`** — produces `LayerAblationPayload` etc. as typed results.
|
|
130
|
+
- **`mechbench-api`** — the runner's only platform dependency. All workspace state (jobs, cache reads) goes through it.
|
|
131
|
+
- **`mechbench-ui`** — no coupling. UI queues jobs; the job-runner consumes them.
|
|
132
|
+
- **`mechbench-experiments`** — research scripts that use `mechbench-compute` directly, without the job machinery.
|
|
133
|
+
|
|
134
|
+
## Open design questions (deferred)
|
|
135
|
+
|
|
136
|
+
- **One binary or two processes?** Current answer: one binary, two subcommands. Revisit if MCP-caller frequency vs. job-runner throughput diverges enough to want independent scaling.
|
|
137
|
+
- **Structured-summary interface.** The family's philosophy doc describes a read-side surface where agents consume JSON summaries of findings / experiments. Currently implicit in `list_jobs` + `get_result`. A richer summary layer (`GET /summary`, `POST /query`) is still on the table but unbuilt.
|
|
138
|
+
- **MCP-surface observability.** Rate limits, per-tool metrics, audit trail for the tool-calling side. Deferred until a second LLM-agent consumer exists.
|
|
139
|
+
|
|
140
|
+
## License
|
|
141
|
+
|
|
142
|
+
MIT.
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""In-process smoke test for the three MCP tools (task 000185 acceptance).
|
|
2
|
+
|
|
3
|
+
Exercises the server's tool functions directly — no stdio
|
|
4
|
+
subprocess, no MCP client — so CI / local dev can verify the
|
|
5
|
+
surface without spawning a Claude session. Assumes mechbench-api is
|
|
6
|
+
running on MECHBENCH_API_URL with a seeded `benji` user; a fresh
|
|
7
|
+
API key must be available via MECHBENCH_API_KEY.
|
|
8
|
+
|
|
9
|
+
The layer-ablation run is gated behind --full because loading Gemma
|
|
10
|
+
4 and running 42 forward passes takes 1-2 minutes; the default run
|
|
11
|
+
asserts only the get_result / list_jobs tools, which are
|
|
12
|
+
fast and enough to verify the wiring.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import sys
|
|
19
|
+
|
|
20
|
+
from .api_client import ApiClient
|
|
21
|
+
from .config import Config
|
|
22
|
+
from .mcp_server import build_server
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def main(full: bool = False) -> int:
|
|
26
|
+
config = Config.from_env()
|
|
27
|
+
if not config.api_key:
|
|
28
|
+
print(
|
|
29
|
+
"error: MECHBENCH_API_KEY is required for smoke test.",
|
|
30
|
+
file=sys.stderr,
|
|
31
|
+
)
|
|
32
|
+
return 2
|
|
33
|
+
|
|
34
|
+
server = build_server(config)
|
|
35
|
+
# FastMCP stores tools on the internal manager; pull the bound
|
|
36
|
+
# functions directly so the smoke test doesn't need an MCP client.
|
|
37
|
+
tools = {t.name: t.fn for t in server._tool_manager.list_tools()} # noqa: SLF001
|
|
38
|
+
|
|
39
|
+
# --- list_jobs: sanity check that the runner can reach the API.
|
|
40
|
+
jobs = tools["list_jobs"]()
|
|
41
|
+
print(f"✓ list_jobs returned {len(jobs)} job(s)")
|
|
42
|
+
|
|
43
|
+
# --- get_result: exercise against the most recent done job, if any.
|
|
44
|
+
done = [j for j in jobs if j["status"] == "done" and j.get("resultPath")]
|
|
45
|
+
if done:
|
|
46
|
+
path = done[0]["resultPath"]
|
|
47
|
+
payload = tools["get_result"](path=path)
|
|
48
|
+
print(f"✓ get_result({path}) → kind/protocol={payload.get('protocol')}")
|
|
49
|
+
else:
|
|
50
|
+
# Queue one via the API so get_result has something to target.
|
|
51
|
+
with ApiClient(config) as api:
|
|
52
|
+
res = api._client.get("/auth/me") # noqa: SLF001 — direct probe
|
|
53
|
+
res.raise_for_status()
|
|
54
|
+
print("✓ get_result skipped (no completed jobs); api /auth/me reachable")
|
|
55
|
+
|
|
56
|
+
if full:
|
|
57
|
+
payload = tools["run_protocol"](
|
|
58
|
+
prompt="Complete this sentence with one word: The Eiffel Tower is in"
|
|
59
|
+
)
|
|
60
|
+
print(
|
|
61
|
+
f"✓ run_protocol → protocol={payload['protocol']} "
|
|
62
|
+
f"n_layers={payload['n_layers']} "
|
|
63
|
+
f"baseline={payload['prompts'][0]['baseline_logprob']}"
|
|
64
|
+
)
|
|
65
|
+
else:
|
|
66
|
+
print("(skipping run_protocol; pass --full to include it)")
|
|
67
|
+
|
|
68
|
+
print("\nall smoke checks passed.")
|
|
69
|
+
return 0
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
if __name__ == "__main__":
|
|
73
|
+
sys.exit(main(full="--full" in sys.argv))
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""Installing the runner as a service the operating system supervises.
|
|
2
|
+
|
|
3
|
+
This writes a launchd agent on macOS or a systemd **user** unit on
|
|
4
|
+
Linux, and then gets out of the way. It deliberately does not supervise
|
|
5
|
+
anything itself: both platforms already do that, and a second supervisor
|
|
6
|
+
underneath the first gives two processes with different ideas about what
|
|
7
|
+
"dead" means (task 000293).
|
|
8
|
+
|
|
9
|
+
What we are responsible for is telling the platform the right policy,
|
|
10
|
+
and the whole policy is the exit-code contract in `exits.py`:
|
|
11
|
+
|
|
12
|
+
* `KeepAlive{SuccessfulExit: false}` / `Restart=on-failure` — come back
|
|
13
|
+
from a crash, stay stopped after a deliberate exit.
|
|
14
|
+
* `ThrottleInterval` / `RestartSec` — do not spin.
|
|
15
|
+
|
|
16
|
+
The command is `sys.executable -m mechbench_runner.cli run` rather than
|
|
17
|
+
whatever `mechbench-runner` resolves to on `PATH`. A service has no
|
|
18
|
+
shell profile, so `PATH` is not ours to rely on — and pinning the
|
|
19
|
+
interpreter pins the environment the runner was installed into, which
|
|
20
|
+
is the one holding its dependencies.
|
|
21
|
+
|
|
22
|
+
No credential is written into the unit. It comes from
|
|
23
|
+
`~/.mechbench/config.toml`, and a key in a plist is a key in a backup.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import os
|
|
29
|
+
import plistlib
|
|
30
|
+
import subprocess
|
|
31
|
+
import sys
|
|
32
|
+
from dataclasses import dataclass
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
|
|
35
|
+
from .paths import mechbench_dir
|
|
36
|
+
|
|
37
|
+
LABEL = "ai.mechbench.runner"
|
|
38
|
+
UNIT_NAME = "mechbench-runner.service"
|
|
39
|
+
|
|
40
|
+
#: Seconds launchd waits between restarts, and systemd's RestartSec.
|
|
41
|
+
THROTTLE_SECONDS = 10
|
|
42
|
+
|
|
43
|
+
#: Long enough for a job in flight to finish on SIGTERM before the
|
|
44
|
+
#: supervisor escalates. A layer-ablation sweep is a couple of minutes.
|
|
45
|
+
STOP_TIMEOUT_SECONDS = 300
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class UnsupportedPlatformError(RuntimeError):
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class AgentStatus:
|
|
54
|
+
installed: bool
|
|
55
|
+
loaded: bool
|
|
56
|
+
running: bool
|
|
57
|
+
path: Path
|
|
58
|
+
detail: str
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def is_macos() -> bool:
|
|
62
|
+
return sys.platform == "darwin"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def is_linux() -> bool:
|
|
66
|
+
return sys.platform.startswith("linux")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def unit_path() -> Path:
|
|
70
|
+
if is_macos():
|
|
71
|
+
return Path.home() / "Library" / "LaunchAgents" / f"{LABEL}.plist"
|
|
72
|
+
if is_linux():
|
|
73
|
+
return Path.home() / ".config" / "systemd" / "user" / UNIT_NAME
|
|
74
|
+
raise UnsupportedPlatformError(
|
|
75
|
+
f"there is no service manager we know how to use on {sys.platform}. "
|
|
76
|
+
f"Run `mechbench-runner run` under whatever supervises processes "
|
|
77
|
+
f"here, or use `mechbench-runner supervise` if there is nothing."
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def program_arguments() -> list[str]:
|
|
82
|
+
return [sys.executable, "-m", "mechbench_runner.cli", "run"]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def boot_log() -> Path:
|
|
86
|
+
"""Where the platform's own stdout goes.
|
|
87
|
+
|
|
88
|
+
Small by construction: the runner replaces stdout with its rotating
|
|
89
|
+
log as soon as it starts, so only failures *before* that reach here —
|
|
90
|
+
which is exactly what you want to read when it will not start.
|
|
91
|
+
"""
|
|
92
|
+
d = mechbench_dir() / "logs"
|
|
93
|
+
d.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
94
|
+
return d / "agent-boot.log"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# --- writing the unit --------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def launchd_plist() -> dict[str, object]:
|
|
101
|
+
return {
|
|
102
|
+
"Label": LABEL,
|
|
103
|
+
"ProgramArguments": program_arguments(),
|
|
104
|
+
"RunAtLoad": True,
|
|
105
|
+
# The exit contract, expressed the only way launchd understands:
|
|
106
|
+
# restart unless the process said it meant to stop.
|
|
107
|
+
"KeepAlive": {"SuccessfulExit": False},
|
|
108
|
+
"ThrottleInterval": THROTTLE_SECONDS,
|
|
109
|
+
"ExitTimeOut": STOP_TIMEOUT_SECONDS,
|
|
110
|
+
# Background: lower priority than anything the user is looking at.
|
|
111
|
+
"ProcessType": "Background",
|
|
112
|
+
"StandardOutPath": str(boot_log()),
|
|
113
|
+
"StandardErrorPath": str(boot_log()),
|
|
114
|
+
"WorkingDirectory": str(Path.home()),
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def systemd_unit() -> str:
|
|
119
|
+
args = " ".join(program_arguments())
|
|
120
|
+
return f"""[Unit]
|
|
121
|
+
Description=mechbench runner
|
|
122
|
+
Documentation=https://mechbench.ai
|
|
123
|
+
After=network-online.target
|
|
124
|
+
|
|
125
|
+
[Service]
|
|
126
|
+
Type=simple
|
|
127
|
+
ExecStart={args}
|
|
128
|
+
# The exit contract (mechbench_runner/exits.py): 0 is deliberate and
|
|
129
|
+
# must stay stopped, anything else is a fault worth restarting.
|
|
130
|
+
Restart=on-failure
|
|
131
|
+
RestartSec={THROTTLE_SECONDS}
|
|
132
|
+
TimeoutStopSec={STOP_TIMEOUT_SECONDS}
|
|
133
|
+
# Give up loudly rather than spinning.
|
|
134
|
+
StartLimitBurst=5
|
|
135
|
+
StartLimitIntervalSec=600
|
|
136
|
+
|
|
137
|
+
[Install]
|
|
138
|
+
WantedBy=default.target
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# --- operations --------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def install() -> AgentStatus:
|
|
146
|
+
path = unit_path()
|
|
147
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
148
|
+
|
|
149
|
+
if is_macos():
|
|
150
|
+
path.write_bytes(plistlib.dumps(launchd_plist()))
|
|
151
|
+
# bootout first so install is idempotent: reinstalling over a
|
|
152
|
+
# loaded agent otherwise keeps the old command running.
|
|
153
|
+
_run(["launchctl", "bootout", _domain(), str(path)], check=False)
|
|
154
|
+
result = _run(["launchctl", "bootstrap", _domain(), str(path)], check=False)
|
|
155
|
+
if result.returncode != 0:
|
|
156
|
+
# Older macOS, or a domain that refuses bootstrap.
|
|
157
|
+
result = _run(["launchctl", "load", "-w", str(path)], check=False)
|
|
158
|
+
if result.returncode != 0:
|
|
159
|
+
return AgentStatus(True, False, False, path,
|
|
160
|
+
f"written, but could not be loaded: {_msg(result)}")
|
|
161
|
+
else:
|
|
162
|
+
path.write_text(systemd_unit())
|
|
163
|
+
_run(["systemctl", "--user", "daemon-reload"], check=False)
|
|
164
|
+
result = _run(["systemctl", "--user", "enable", "--now", UNIT_NAME],
|
|
165
|
+
check=False)
|
|
166
|
+
if result.returncode != 0:
|
|
167
|
+
return AgentStatus(True, False, False, path,
|
|
168
|
+
f"written, but could not be enabled: {_msg(result)}")
|
|
169
|
+
|
|
170
|
+
return status()
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def uninstall() -> AgentStatus:
|
|
174
|
+
path = unit_path()
|
|
175
|
+
if is_macos():
|
|
176
|
+
_run(["launchctl", "bootout", _domain(), str(path)], check=False)
|
|
177
|
+
_run(["launchctl", "unload", "-w", str(path)], check=False)
|
|
178
|
+
else:
|
|
179
|
+
_run(["systemctl", "--user", "disable", "--now", UNIT_NAME], check=False)
|
|
180
|
+
existed = path.exists()
|
|
181
|
+
path.unlink(missing_ok=True)
|
|
182
|
+
if is_linux():
|
|
183
|
+
_run(["systemctl", "--user", "daemon-reload"], check=False)
|
|
184
|
+
return AgentStatus(
|
|
185
|
+
installed=False,
|
|
186
|
+
loaded=False,
|
|
187
|
+
running=False,
|
|
188
|
+
path=path,
|
|
189
|
+
detail="removed" if existed else "there was nothing installed",
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def status() -> AgentStatus:
|
|
194
|
+
path = unit_path()
|
|
195
|
+
if not path.exists():
|
|
196
|
+
return AgentStatus(False, False, False, path, "not installed")
|
|
197
|
+
|
|
198
|
+
if is_macos():
|
|
199
|
+
printed = _run(["launchctl", "print", f"{_domain()}/{LABEL}"], check=False)
|
|
200
|
+
loaded = printed.returncode == 0
|
|
201
|
+
running = loaded and "state = running" in printed.stdout
|
|
202
|
+
pid = _extract(printed.stdout, "pid = ")
|
|
203
|
+
detail = (
|
|
204
|
+
f"running (pid {pid})" if running
|
|
205
|
+
else "loaded, not currently running" if loaded
|
|
206
|
+
else "installed but not loaded"
|
|
207
|
+
)
|
|
208
|
+
return AgentStatus(True, loaded, running, path, detail)
|
|
209
|
+
|
|
210
|
+
enabled = _run(["systemctl", "--user", "is-enabled", UNIT_NAME], check=False)
|
|
211
|
+
active = _run(["systemctl", "--user", "is-active", UNIT_NAME], check=False)
|
|
212
|
+
loaded = enabled.returncode == 0
|
|
213
|
+
running = active.stdout.strip() == "active"
|
|
214
|
+
return AgentStatus(
|
|
215
|
+
True, loaded, running, path,
|
|
216
|
+
f"{active.stdout.strip() or 'unknown'}, {enabled.stdout.strip() or 'disabled'}",
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def kickstart() -> bool:
|
|
221
|
+
"""Start it now, or restart it if it is already up.
|
|
222
|
+
|
|
223
|
+
Needed after `login`: a runner that exited because it was signed out
|
|
224
|
+
exited *deliberately*, so the supervisor is correctly leaving it
|
|
225
|
+
alone and nothing else will bring it back.
|
|
226
|
+
"""
|
|
227
|
+
if not unit_path().exists():
|
|
228
|
+
return False
|
|
229
|
+
if is_macos():
|
|
230
|
+
r = _run(["launchctl", "kickstart", "-k", f"{_domain()}/{LABEL}"], check=False)
|
|
231
|
+
return r.returncode == 0
|
|
232
|
+
r = _run(["systemctl", "--user", "restart", UNIT_NAME], check=False)
|
|
233
|
+
return r.returncode == 0
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def linger_hint() -> str | None:
|
|
237
|
+
"""On Linux a user service stops at logout unless lingering is on —
|
|
238
|
+
which is the difference between a headless box that works and one
|
|
239
|
+
that works until you close the SSH session."""
|
|
240
|
+
if not is_linux():
|
|
241
|
+
return None
|
|
242
|
+
user = os.environ.get("USER") or os.environ.get("LOGNAME") or ""
|
|
243
|
+
check = _run(["loginctl", "show-user", user, "--property=Linger"], check=False)
|
|
244
|
+
if "Linger=yes" in check.stdout:
|
|
245
|
+
return None
|
|
246
|
+
return (
|
|
247
|
+
f"This service stops when you log out. To keep it running on a "
|
|
248
|
+
f"headless machine:\n sudo loginctl enable-linger {user}"
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
# --- plumbing ----------------------------------------------------------------
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _domain() -> str:
|
|
256
|
+
return f"gui/{os.getuid()}"
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _run(cmd: list[str], *, check: bool) -> subprocess.CompletedProcess[str]:
|
|
260
|
+
try:
|
|
261
|
+
return subprocess.run( # noqa: S603
|
|
262
|
+
cmd, capture_output=True, text=True, check=check, timeout=30
|
|
263
|
+
)
|
|
264
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
265
|
+
return subprocess.CompletedProcess(cmd, 127, "", str(exc))
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _msg(result: subprocess.CompletedProcess[str]) -> str:
|
|
269
|
+
return (result.stderr or result.stdout or "no output").strip().splitlines()[0]
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _extract(text: str, marker: str) -> str:
|
|
273
|
+
for line in text.splitlines():
|
|
274
|
+
if marker in line:
|
|
275
|
+
return line.split(marker, 1)[1].strip().rstrip(";")
|
|
276
|
+
return "?"
|