llm-experiment 0.1.0
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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +40 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/LICENSE.txt +21 -0
- data/README.md +190 -0
- data/exe/llmx +6 -0
- data/lib/llm-experiment.rb +3 -0
- data/lib/llm_experiment/auth.rb +143 -0
- data/lib/llm_experiment/cleaner.rb +183 -0
- data/lib/llm_experiment/cli/build_command.rb +37 -0
- data/lib/llm_experiment/cli/clean_command.rb +42 -0
- data/lib/llm_experiment/cli/doctor_command.rb +30 -0
- data/lib/llm_experiment/cli/login_command.rb +25 -0
- data/lib/llm_experiment/cli/metrics_command.rb +32 -0
- data/lib/llm_experiment/cli/new_command.rb +21 -0
- data/lib/llm_experiment/cli/parse_command.rb +77 -0
- data/lib/llm_experiment/cli/run_command.rb +74 -0
- data/lib/llm_experiment/cli/sanitize_command.rb +34 -0
- data/lib/llm_experiment/cli/shell_command.rb +35 -0
- data/lib/llm_experiment/cli/status_command.rb +69 -0
- data/lib/llm_experiment/cli/version_command.rb +19 -0
- data/lib/llm_experiment/cli.rb +120 -0
- data/lib/llm_experiment/container.rb +178 -0
- data/lib/llm_experiment/doctor.rb +126 -0
- data/lib/llm_experiment/experiment.rb +163 -0
- data/lib/llm_experiment/grid.rb +117 -0
- data/lib/llm_experiment/image_builder/app.rb +267 -0
- data/lib/llm_experiment/image_builder/base.rb +64 -0
- data/lib/llm_experiment/metrics_report.rb +138 -0
- data/lib/llm_experiment/pins.rb +22 -0
- data/lib/llm_experiment/sanitizer.rb +144 -0
- data/lib/llm_experiment/scaffold.rb +43 -0
- data/lib/llm_experiment/shell.rb +60 -0
- data/lib/llm_experiment/stats.rb +69 -0
- data/lib/llm_experiment/transcript/claude.rb +104 -0
- data/lib/llm_experiment/transcript/codex.rb +96 -0
- data/lib/llm_experiment/transcript/hermeticity.rb +35 -0
- data/lib/llm_experiment/transcript/parser.rb +105 -0
- data/lib/llm_experiment/transcript.rb +27 -0
- data/lib/llm_experiment/trial.rb +204 -0
- data/lib/llm_experiment/version.rb +5 -0
- data/lib/llm_experiment.rb +70 -0
- data/templates/README.md.erb +26 -0
- data/templates/base.Containerfile +120 -0
- data/templates/experiment.yml.erb +30 -0
- data/templates/gitignore +3 -0
- data/templates/runner.rb +305 -0
- metadata +92 -0
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
require "shellwords"
|
|
6
|
+
|
|
7
|
+
module LLMExperiment
|
|
8
|
+
# One trial: one task, one agent, one condition, in a fresh container.
|
|
9
|
+
#
|
|
10
|
+
# On caps: the trial runs to completion under a wall-clock timeout rather
|
|
11
|
+
# than being killed at the first edit. Stopping at the first edit would
|
|
12
|
+
# measure the cost of locating the defect but throw away whether the fix was
|
|
13
|
+
# any good, and an analysis cap can always be applied afterwards from the
|
|
14
|
+
# event log, whereas a killed trial cannot be un-killed. The transcript
|
|
15
|
+
# records the index of the first defect-file read and of the first edit, so a
|
|
16
|
+
# "cost to locate" figure survives without destroying anything.
|
|
17
|
+
#
|
|
18
|
+
# Results land in results-raw/, which is gitignored. Nothing reaches
|
|
19
|
+
# results/ until the sanitize gate has looked at it.
|
|
20
|
+
class Trial
|
|
21
|
+
# Both agents read the mounted credential store, so this asks a dead login
|
|
22
|
+
# the same question a trial would.
|
|
23
|
+
LOGIN_CHECKS = {
|
|
24
|
+
"claude" => ["claude auth status 2>&1", '"loggedIn": false'],
|
|
25
|
+
"codex" => ["codex login status 2>&1", "Not logged in"]
|
|
26
|
+
}.freeze
|
|
27
|
+
|
|
28
|
+
attr_reader :experiment, :task_id, :agent, :condition
|
|
29
|
+
|
|
30
|
+
def initialize(experiment:, task_id:, agent:, condition:, container: nil,
|
|
31
|
+
timeout: nil, shell: Shell)
|
|
32
|
+
@experiment = experiment
|
|
33
|
+
@task_id = task_id.to_s
|
|
34
|
+
@agent = agent.to_s
|
|
35
|
+
@condition = condition.to_s
|
|
36
|
+
@container = container
|
|
37
|
+
@timeout = timeout
|
|
38
|
+
@shell = shell
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def task = @task ||= experiment.task(task_id)
|
|
42
|
+
def app = @app ||= experiment.app(task.app)
|
|
43
|
+
def timeout_seconds = (@timeout || experiment.timeout_seconds).to_i
|
|
44
|
+
def prompt_path = experiment.prompt_path(task, condition)
|
|
45
|
+
|
|
46
|
+
# Pure: reads configuration and the prompt file, touches nothing else. The
|
|
47
|
+
# container never sees anything that is not in here.
|
|
48
|
+
def env
|
|
49
|
+
validate!
|
|
50
|
+
values = {
|
|
51
|
+
"LLMX_TASK_ID" => task.id,
|
|
52
|
+
"LLMX_APP" => app.key,
|
|
53
|
+
"LLMX_AGENT" => agent,
|
|
54
|
+
"LLMX_CONDITION" => condition,
|
|
55
|
+
"LLMX_BRANCH" => task.branch,
|
|
56
|
+
"LLMX_TEST_FILE" => task.test_file,
|
|
57
|
+
"LLMX_IMPL_FILES" => task.impl_files.join(","),
|
|
58
|
+
"LLMX_DB_PREPARE" => app.db_prepare,
|
|
59
|
+
"LLMX_DATABASE" => app.database,
|
|
60
|
+
"LLMX_TEST_COMMAND" => app.test_command,
|
|
61
|
+
"LLMX_SUITE_RAN_PATTERN" => app.suite_ran_pattern,
|
|
62
|
+
"LLMX_TIMEOUT_SECONDS" => timeout_seconds.to_s,
|
|
63
|
+
# Base64 keeps the prompt out of shell quoting entirely: newlines,
|
|
64
|
+
# backticks and quotes in a prompt must not become shell syntax.
|
|
65
|
+
# pack("m0") is strict_encode64 without needing the base64 gem, which
|
|
66
|
+
# Ruby 3.4 unbundled -- and the runner decodes it with unpack1("m0").
|
|
67
|
+
"LLMX_PROMPT_B64" => [prompt].pack("m0")
|
|
68
|
+
}
|
|
69
|
+
model = experiment.agents.dig(agent, "model")
|
|
70
|
+
values["LLMX_MODEL"] = model if model
|
|
71
|
+
values
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def run(dry_run: false)
|
|
75
|
+
environment = env
|
|
76
|
+
return dry_run_report(environment) if dry_run
|
|
77
|
+
|
|
78
|
+
preflight!
|
|
79
|
+
out_dir = prepare_results_dir
|
|
80
|
+
argv = run_argv(out_dir, environment)
|
|
81
|
+
|
|
82
|
+
puts "trial #{task_id} / #{agent} / #{condition} -> #{out_dir}"
|
|
83
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
84
|
+
ok = @shell.sh(*argv, allow_failure: true,
|
|
85
|
+
log_as: run_argv(out_dir, redacted(environment)).shelljoin)
|
|
86
|
+
elapsed = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started).round(1)
|
|
87
|
+
|
|
88
|
+
report(record_meta(out_dir, elapsed, ok), elapsed)
|
|
89
|
+
out_dir
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def container = @container ||= Container.new
|
|
95
|
+
|
|
96
|
+
# Read once. The copy saved next to the results has to be the bytes the
|
|
97
|
+
# agent actually got, not whatever the file says a second later.
|
|
98
|
+
def prompt
|
|
99
|
+
@prompt ||= begin
|
|
100
|
+
path = prompt_path
|
|
101
|
+
raise Error, "missing prompt file #{path}; render the prompts first" unless File.exist?(path)
|
|
102
|
+
|
|
103
|
+
File.read(path)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def validate!
|
|
108
|
+
unless experiment.agents.key?(agent)
|
|
109
|
+
raise Error, "unknown agent #{agent.inspect}; experiment.yml declares #{experiment.agents.keys.join(", ")}"
|
|
110
|
+
end
|
|
111
|
+
return if experiment.conditions.include?(condition)
|
|
112
|
+
|
|
113
|
+
raise Error, "unknown condition #{condition.inspect}; experiment.yml declares #{experiment.conditions.join(", ")}"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# The prompt is the one environment value worth hiding from a log line:
|
|
117
|
+
# it is the whole task, base64-encoded, and it is already saved verbatim
|
|
118
|
+
# as prompt.txt beside the results.
|
|
119
|
+
def redacted(environment)
|
|
120
|
+
environment.merge("LLMX_PROMPT_B64" => "<#{environment["LLMX_PROMPT_B64"].bytesize} bytes>")
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def run_argv(out_dir, environment)
|
|
124
|
+
container.run_argv(app.image, "exec ruby /results/runner.rb",
|
|
125
|
+
memory: experiment.memory, cpus: experiment.cpus,
|
|
126
|
+
volumes: ["#{out_dir}:/results"],
|
|
127
|
+
env: environment, workdir: "/workspace/app")
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def preflight!
|
|
131
|
+
container.ensure_system!
|
|
132
|
+
raise Error, "image #{app.image} not found; run `llmx build app #{app.key}`" unless container.image?(app.image)
|
|
133
|
+
|
|
134
|
+
check_login!
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Checked once up front rather than discovered halfway through a grid: a
|
|
138
|
+
# dead login turns every remaining trial into a wasted container.
|
|
139
|
+
def check_login!
|
|
140
|
+
command, dead = LOGIN_CHECKS[agent]
|
|
141
|
+
return unless command
|
|
142
|
+
|
|
143
|
+
status, = @shell.try(*container.run_argv(LLMExperiment.base_image, command,
|
|
144
|
+
memory: LLMExperiment.default_memory,
|
|
145
|
+
cpus: LLMExperiment.default_cpus))
|
|
146
|
+
return unless status.include?(dead)
|
|
147
|
+
|
|
148
|
+
raise Error, <<~MSG
|
|
149
|
+
#{agent} is not logged in inside the container, so this trial would fail.
|
|
150
|
+
|
|
151
|
+
Run this once and complete the browser flow:
|
|
152
|
+
llmx login --agent #{agent}
|
|
153
|
+
|
|
154
|
+
Credentials persist in #{LLMExperiment.auth_dir} and are mounted into every trial.
|
|
155
|
+
MSG
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def prepare_results_dir
|
|
159
|
+
stamp = Time.now.utc.strftime("%Y%m%dT%H%M%SZ")
|
|
160
|
+
dir = experiment.trial_dir(task_id, agent, condition, stamp)
|
|
161
|
+
FileUtils.mkdir_p(dir)
|
|
162
|
+
# The runner is copied in rather than baked into the image, so fixing the
|
|
163
|
+
# harness does not mean rebuilding every app image.
|
|
164
|
+
FileUtils.cp(LLMExperiment.template_path("runner.rb"), File.join(dir, "runner.rb"))
|
|
165
|
+
File.write(File.join(dir, "prompt.txt"), prompt)
|
|
166
|
+
dir
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def record_meta(out_dir, elapsed, ok)
|
|
170
|
+
path = File.join(out_dir, "meta.json")
|
|
171
|
+
unless File.exist?(path)
|
|
172
|
+
raise Error, "trial produced no meta.json after #{elapsed}s (container exit #{ok.inspect}). " \
|
|
173
|
+
"Inspect #{out_dir}, or open a shell with `llmx shell #{app.key}`"
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
meta = JSON.parse(File.read(path))
|
|
177
|
+
meta["host_wall_seconds"] = elapsed
|
|
178
|
+
meta["container_ok"] = ok
|
|
179
|
+
# No result should ever be unattributable to the harness that produced it.
|
|
180
|
+
meta["llm_experiment_version"] = LLMExperiment::VERSION
|
|
181
|
+
File.write(path, JSON.pretty_generate(meta))
|
|
182
|
+
meta
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def report(meta, elapsed)
|
|
186
|
+
puts format("\n reproduced before : %s", meta["task_reproduces"])
|
|
187
|
+
puts format(" agent exit : %s (timed out: %s)", meta["agent_exit"], meta["timed_out"])
|
|
188
|
+
puts format(" changed files : %s", meta["changed_files"].inspect)
|
|
189
|
+
puts format(" touched impl file : %s", meta["touched_impl_file"])
|
|
190
|
+
puts format(" fix verified : %s", meta["fix_verified"])
|
|
191
|
+
puts format(" wall seconds : %s (container), %s (host)", meta["wall_seconds"], elapsed)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# A dry run starts no container and writes no results directory: it is for
|
|
195
|
+
# reading the prompt and the command back, not for rehearsing them.
|
|
196
|
+
def dry_run_report(environment)
|
|
197
|
+
out_dir = experiment.trial_dir(task_id, agent, condition, "<stamp>")
|
|
198
|
+
puts "would run:"
|
|
199
|
+
puts " #{run_argv(out_dir, redacted(environment)).join(" ")}"
|
|
200
|
+
puts "\nprompt (#{condition}):\n\n#{prompt}"
|
|
201
|
+
out_dir
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "llm_experiment/version"
|
|
4
|
+
|
|
5
|
+
module LLMExperiment
|
|
6
|
+
class Error < StandardError; end
|
|
7
|
+
class ConfigError < Error; end
|
|
8
|
+
class ContainerError < Error; end
|
|
9
|
+
|
|
10
|
+
class << self
|
|
11
|
+
attr_accessor :base_image, :auth_dir, :agent_user,
|
|
12
|
+
:default_memory, :default_cpus,
|
|
13
|
+
:builder_cpus, :builder_memory, :min_free_gb,
|
|
14
|
+
:container_data_dir, :verbose
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Every default can be overridden by an LLMX_* variable, matching the source
|
|
18
|
+
# harness, so existing setups keep working.
|
|
19
|
+
self.base_image = ENV.fetch("LLMX_BASE_IMAGE", "llmx-base:latest")
|
|
20
|
+
self.auth_dir = File.expand_path(ENV.fetch("LLMX_AUTH_DIR", "~/.llmx/auth"))
|
|
21
|
+
self.agent_user = "agent"
|
|
22
|
+
self.default_memory = ENV.fetch("LLMX_MEMORY", "6g")
|
|
23
|
+
self.default_cpus = ENV.fetch("LLMX_CPUS", "4")
|
|
24
|
+
self.builder_cpus = ENV.fetch("LLMX_BUILDER_CPUS", "6")
|
|
25
|
+
self.builder_memory = ENV.fetch("LLMX_BUILDER_MEMORY", "8G")
|
|
26
|
+
# Measured, not guessed: a campfire-sized Rails app image drove free space
|
|
27
|
+
# from 34 GB to 1.8 GB. About 24 GB, and roughly 17 GB of that was the
|
|
28
|
+
# BuildKit builder VM rather than the image itself. The previous 12 GB floor
|
|
29
|
+
# green-lit exactly that build.
|
|
30
|
+
self.min_free_gb = ENV.fetch("LLMX_MIN_FREE_GB", "25").to_i
|
|
31
|
+
# Where the CLI unpacks images. What an image costs is what lives here, not
|
|
32
|
+
# what its manifest sums to, so `llmx clean` plans against `du` on this path.
|
|
33
|
+
self.container_data_dir = File.expand_path(
|
|
34
|
+
ENV.fetch("LLMX_CONTAINER_DATA_DIR", "~/Library/Application Support/com.apple.container")
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
# `--verbose` echoes every container command. Off by default: a grid runs
|
|
38
|
+
# hundreds of them and the interesting output belongs to the agent.
|
|
39
|
+
self.verbose = ENV.key?("LLMX_VERBOSE")
|
|
40
|
+
|
|
41
|
+
def self.template_path(name)
|
|
42
|
+
File.expand_path(File.join(__dir__, "..", "templates", name))
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Below the module body on purpose. These files reference Error, ConfigError
|
|
47
|
+
# and the accessors above, and a file that touches one of them at load time
|
|
48
|
+
# rather than at call time would fail with an inscrutable NameError if it were
|
|
49
|
+
# required first.
|
|
50
|
+
require_relative "llm_experiment/shell"
|
|
51
|
+
require_relative "llm_experiment/pins"
|
|
52
|
+
require_relative "llm_experiment/container"
|
|
53
|
+
require_relative "llm_experiment/auth"
|
|
54
|
+
require_relative "llm_experiment/experiment"
|
|
55
|
+
require_relative "llm_experiment/scaffold"
|
|
56
|
+
require_relative "llm_experiment/doctor"
|
|
57
|
+
require_relative "llm_experiment/image_builder/base"
|
|
58
|
+
require_relative "llm_experiment/image_builder/app"
|
|
59
|
+
require_relative "llm_experiment/trial"
|
|
60
|
+
require_relative "llm_experiment/grid"
|
|
61
|
+
require_relative "llm_experiment/transcript"
|
|
62
|
+
require_relative "llm_experiment/transcript/claude"
|
|
63
|
+
require_relative "llm_experiment/transcript/codex"
|
|
64
|
+
require_relative "llm_experiment/transcript/hermeticity"
|
|
65
|
+
require_relative "llm_experiment/transcript/parser"
|
|
66
|
+
require_relative "llm_experiment/stats"
|
|
67
|
+
require_relative "llm_experiment/metrics_report"
|
|
68
|
+
require_relative "llm_experiment/sanitizer"
|
|
69
|
+
require_relative "llm_experiment/cleaner"
|
|
70
|
+
require_relative "llm_experiment/cli"
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# <%= name %>
|
|
2
|
+
|
|
3
|
+
**Status: designed, not yet run.**
|
|
4
|
+
|
|
5
|
+
## The question
|
|
6
|
+
|
|
7
|
+
## The design
|
|
8
|
+
|
|
9
|
+
## What gets measured
|
|
10
|
+
|
|
11
|
+
## Running it
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
export LLMX_APP_SAMPLE=/path/to/your/checkout # never committed
|
|
15
|
+
llmx doctor
|
|
16
|
+
llmx build base # once per machine
|
|
17
|
+
llmx login # once, interactive
|
|
18
|
+
llmx build app sample
|
|
19
|
+
llmx run --all --dry-run
|
|
20
|
+
llmx run --all
|
|
21
|
+
llmx parse --all
|
|
22
|
+
llmx metrics
|
|
23
|
+
llmx sanitize
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Caveats
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# Base image for LLM agent experiments.
|
|
2
|
+
#
|
|
3
|
+
# Gives every trial a freshly installed agent CLI with no host state: no user
|
|
4
|
+
# CLAUDE.md, no MCP servers, no login. Credentials arrive at run time from a
|
|
5
|
+
# mounted auth volume, never from this image.
|
|
6
|
+
#
|
|
7
|
+
# Built by `llmx build base`, which owns the version pins.
|
|
8
|
+
|
|
9
|
+
FROM ubuntu:24.04
|
|
10
|
+
|
|
11
|
+
ARG RUBY_VERSIONS="3.4.5 4.0.1"
|
|
12
|
+
ARG NODE_VERSION="22"
|
|
13
|
+
ARG CLAUDE_CODE_VERSION
|
|
14
|
+
ARG CODEX_VERSION
|
|
15
|
+
ARG OPENCODE_VERSION
|
|
16
|
+
ARG AGENT_USER=agent
|
|
17
|
+
ARG AGENT_UID=1001
|
|
18
|
+
|
|
19
|
+
ENV DEBIAN_FRONTEND=noninteractive \
|
|
20
|
+
LANG=C.UTF-8 \
|
|
21
|
+
LC_ALL=C.UTF-8 \
|
|
22
|
+
TZ=UTC
|
|
23
|
+
|
|
24
|
+
# System packages: Ruby build dependencies, native media libraries, SQLite,
|
|
25
|
+
# PostgreSQL, and git for the fixture clone.
|
|
26
|
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
27
|
+
ca-certificates curl wget git xz-utils unzip \
|
|
28
|
+
build-essential autoconf bison patch pkg-config \
|
|
29
|
+
libssl-dev libyaml-dev libreadline-dev zlib1g-dev libncurses-dev \
|
|
30
|
+
libffi-dev libgdbm-dev libdb-dev uuid-dev libgmp-dev \
|
|
31
|
+
libsqlite3-dev sqlite3 \
|
|
32
|
+
libpq-dev postgresql postgresql-contrib \
|
|
33
|
+
libvips42 libvips-tools ffmpeg \
|
|
34
|
+
libjemalloc2 tzdata procps less ripgrep jq \
|
|
35
|
+
&& rm -rf /var/lib/apt/lists/*
|
|
36
|
+
|
|
37
|
+
# mise manages the rubies and node. Installed into /opt so every user sees the
|
|
38
|
+
# same toolchain and the shims work in non-login shells.
|
|
39
|
+
ENV MISE_DATA_DIR=/opt/mise \
|
|
40
|
+
MISE_CONFIG_DIR=/opt/mise/config \
|
|
41
|
+
MISE_CACHE_DIR=/opt/mise/cache \
|
|
42
|
+
MISE_STATE_DIR=/opt/mise/state \
|
|
43
|
+
MISE_INSTALL_PATH=/usr/local/bin/mise \
|
|
44
|
+
MISE_YES=1
|
|
45
|
+
ENV PATH=/opt/mise/shims:$PATH
|
|
46
|
+
|
|
47
|
+
RUN curl -fsSL https://mise.run | sh && mise --version
|
|
48
|
+
|
|
49
|
+
# Rubies are compiled here once so trials never pay for it.
|
|
50
|
+
RUN set -eux; \
|
|
51
|
+
for v in ${RUBY_VERSIONS}; do mise install "ruby@${v}"; done; \
|
|
52
|
+
mise install "node@${NODE_VERSION}"; \
|
|
53
|
+
first_ruby="$(echo ${RUBY_VERSIONS} | awk '{print $1}')"; \
|
|
54
|
+
mise use -g "ruby@${first_ruby}" "node@${NODE_VERSION}"; \
|
|
55
|
+
mise reshim
|
|
56
|
+
|
|
57
|
+
# Agent CLIs. Pinned by build_base.rb so a rebuild is reproducible.
|
|
58
|
+
#
|
|
59
|
+
# opencode needs its platform binary named explicitly. opencode-ai resolves its
|
|
60
|
+
# real executable through optional per-platform packages, and npm picks the musl
|
|
61
|
+
# build for this glibc image, then fails in the postinstall with "Unsupported
|
|
62
|
+
# platform ... wanted libc musl, current glibc". Naming the package settles the
|
|
63
|
+
# choice instead of leaving it to detection, and the architecture is read at
|
|
64
|
+
# build time so this works on Apple silicon and on x86 alike.
|
|
65
|
+
RUN set -eux; \
|
|
66
|
+
case "$(uname -m)" in \
|
|
67
|
+
aarch64|arm64) opencode_platform="opencode-linux-arm64" ;; \
|
|
68
|
+
x86_64|amd64) opencode_platform="opencode-linux-x64" ;; \
|
|
69
|
+
*) echo "no opencode build for $(uname -m)" >&2; exit 1 ;; \
|
|
70
|
+
esac; \
|
|
71
|
+
npm install -g \
|
|
72
|
+
"@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" \
|
|
73
|
+
"@openai/codex@${CODEX_VERSION}" \
|
|
74
|
+
"opencode-ai@${OPENCODE_VERSION}" \
|
|
75
|
+
"${opencode_platform}@${OPENCODE_VERSION}"; \
|
|
76
|
+
mise reshim
|
|
77
|
+
|
|
78
|
+
# Never let a trial silently run a different CLI build than the one recorded.
|
|
79
|
+
ENV DISABLE_AUTOUPDATER=1 \
|
|
80
|
+
DISABLE_TELEMETRY=1 \
|
|
81
|
+
DISABLE_ERROR_REPORTING=1 \
|
|
82
|
+
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 \
|
|
83
|
+
npm_config_update_notifier=false
|
|
84
|
+
|
|
85
|
+
# The toolchain is owned by the agent user, not root. `gem install` triggers a
|
|
86
|
+
# mise reshim, and a root-owned shims directory makes that fail noisily on every
|
|
87
|
+
# app image build.
|
|
88
|
+
RUN useradd --create-home --uid ${AGENT_UID} --shell /bin/bash ${AGENT_USER} \
|
|
89
|
+
&& chown -R ${AGENT_USER}:${AGENT_USER} /opt/mise \
|
|
90
|
+
&& mkdir -p /workspace /results \
|
|
91
|
+
&& chown ${AGENT_USER}:${AGENT_USER} /workspace /results
|
|
92
|
+
|
|
93
|
+
# Gems live outside the checkout so `git status` in a trial stays clean and the
|
|
94
|
+
# agent's edits are the only diff.
|
|
95
|
+
ENV BUNDLE_PATH=/home/${AGENT_USER}/bundle \
|
|
96
|
+
BUNDLE_JOBS=4 \
|
|
97
|
+
BUNDLE_RETRY=3 \
|
|
98
|
+
GEM_HOME=/home/${AGENT_USER}/gems \
|
|
99
|
+
PATH=/home/${AGENT_USER}/gems/bin:/opt/mise/shims:$PATH
|
|
100
|
+
|
|
101
|
+
# Record exactly what got installed. Trials copy this into their metadata so a
|
|
102
|
+
# result can always be traced back to a toolchain.
|
|
103
|
+
RUN set -eux; \
|
|
104
|
+
printf '{\n' > /etc/llmx-versions.json; \
|
|
105
|
+
printf ' "image_built_at": "%s",\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> /etc/llmx-versions.json; \
|
|
106
|
+
printf ' "ubuntu": "%s",\n' "$(. /etc/os-release; echo $VERSION_ID)" >> /etc/llmx-versions.json; \
|
|
107
|
+
printf ' "arch": "%s",\n' "$(uname -m)" >> /etc/llmx-versions.json; \
|
|
108
|
+
printf ' "mise": "%s",\n' "$(mise --version | head -1)" >> /etc/llmx-versions.json; \
|
|
109
|
+
printf ' "rubies": "%s",\n' "${RUBY_VERSIONS}" >> /etc/llmx-versions.json; \
|
|
110
|
+
printf ' "node": "%s",\n' "$(node --version)" >> /etc/llmx-versions.json; \
|
|
111
|
+
printf ' "claude_code": "%s",\n' "$(claude --version 2>&1 | head -1)" >> /etc/llmx-versions.json; \
|
|
112
|
+
printf ' "codex": "%s",\n' "$(codex --version 2>&1 | head -1)" >> /etc/llmx-versions.json; \
|
|
113
|
+
printf ' "opencode": "%s",\n' "$(opencode --version 2>&1 | head -1)" >> /etc/llmx-versions.json; \
|
|
114
|
+
printf ' "postgres": "%s"\n' "$(/usr/lib/postgresql/*/bin/postgres --version | head -1)" >> /etc/llmx-versions.json; \
|
|
115
|
+
printf '}\n' >> /etc/llmx-versions.json; \
|
|
116
|
+
cat /etc/llmx-versions.json
|
|
117
|
+
|
|
118
|
+
USER ${AGENT_USER}
|
|
119
|
+
WORKDIR /workspace
|
|
120
|
+
CMD ["bash"]
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
name: <%= name %>
|
|
2
|
+
question: "State the question this experiment answers."
|
|
3
|
+
|
|
4
|
+
agents:
|
|
5
|
+
claude:
|
|
6
|
+
model: claude-opus-5
|
|
7
|
+
codex:
|
|
8
|
+
model: gpt-5.6-sol
|
|
9
|
+
|
|
10
|
+
conditions: [control, treatment]
|
|
11
|
+
|
|
12
|
+
trial:
|
|
13
|
+
timeout_seconds: 900
|
|
14
|
+
|
|
15
|
+
apps:
|
|
16
|
+
sample:
|
|
17
|
+
ruby: "3.4.5"
|
|
18
|
+
bundler: default
|
|
19
|
+
database: sqlite3
|
|
20
|
+
test_command: bin/rails test
|
|
21
|
+
db_prepare: bin/rails db:test:prepare
|
|
22
|
+
publish_transcripts: false
|
|
23
|
+
branch_prefix: exp/<%= name %>
|
|
24
|
+
neutralize: [CLAUDE.md, AGENTS.md, .mcp.json, .claude, .codex]
|
|
25
|
+
|
|
26
|
+
tasks:
|
|
27
|
+
- id: sample-01
|
|
28
|
+
app: sample
|
|
29
|
+
test_file: test/models/replace_me_test.rb
|
|
30
|
+
impl_files: [app/models/replace_me.rb]
|
data/templates/gitignore
ADDED