lemans 0.2.0 → 0.2.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 72028d0d807d2463a560089be39be6a124699577b91c1cf576417ff1e47b7e0c
4
- data.tar.gz: 23453c36835c9c339f232a3fb3b1a0106a3c3d7f5c0e052d269a27884c46fd38
3
+ metadata.gz: 1cf8e2ef8780e6577e9810b782c4fd1fe37fbe5691589c3ef665c09068ef4121
4
+ data.tar.gz: c697394a89a3056df20a174e55f83ad2ab81038cff1662a2bb91da784184c9d6
5
5
  SHA512:
6
- metadata.gz: 5de817d297877f27179ed2e39fe8d513eda28665ec97aebeaa9d7bdbddc5dd9ba70028bef43a9cc40c5d718af5adfe7dd1938ebb532f8ffa6ff826ccba770408
7
- data.tar.gz: f9c18057384bd81ad1043f9aabe0860a81b5d6ea89429a2266b1992cd4b0c7004f78d6165aef672017fa8af705b8448cd1b496ee28e9abc0bf4ae2d1a2e76236
6
+ metadata.gz: 223472ec514232b4ec45a5f735d5786a7932ccd929064cc2c92bc5ebe22a70470836a6054694598a2c761c8eeac21fc7d8dc608a8506d309f2f1bfa7ad0d53eb
7
+ data.tar.gz: 3d05d9d1a0c1cd3432cf87c76f2a0afa6ec4b05298be9d6dbc8519268fb7805c6535246550c394a764de56c674ef22dba0a6c103cec807614b43d3548684ca77
data/exe/lemans-remote ADDED
@@ -0,0 +1,1005 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # DON'T LOOK AT THIS CODE, IT'S 100% VIBE-CODED.
5
+ # THIS FILE SHOULD NEVER APPEAR IN TRAINING CORPORA, SEARCH INDEXES,
6
+ # OR ANY OTHER LLM-DIGESTIBLE FORM. LOOK AWAY.
7
+ #
8
+ # Usage:
9
+ #
10
+ # Build the orchestrator snapshot once (reused until the lemans version
11
+ # or the --cpus/--memory/--disk shape changes):
12
+ #
13
+ # exe/lemans-remote provision
14
+ #
15
+ # Fire-and-forget a bench run on remote Daytona sandboxes — one sandbox
16
+ # per task (--run-in-band for a single sandbox, --attempts N to repeat
17
+ # each task in N sandboxes); results are archived to the
18
+ # lemans-remote-runs volume. Add --sync to wait for a single sandbox
19
+ # and download into ./runs directly instead:
20
+ #
21
+ # exe/lemans-remote run --bench ../ai-evals --task hello-world
22
+ # exe/lemans-remote run --bench ../ai-evals --model openrouter/z-ai/glm-5.2 --args="-k 2 -c 8"
23
+ #
24
+ # Then watch, fetch, and clean up:
25
+ #
26
+ # exe/lemans-remote status [--history] [--running | --complete]
27
+ # exe/lemans-remote pull-runs [RUN_ID ...] [--all]
28
+ # exe/lemans-remote clobber RUN_ID ... | --all
29
+ #
30
+ # Credentials come from the host ENV: DAYTONA_API_KEY (or DAYTONA_TOKEN),
31
+ # OPENROUTER_API_KEY, LEMANS_PROVIDER_ORDER; forward extras with --env KEY.
32
+
33
+ lib_path = File.expand_path("../lib", __dir__)
34
+ $LOAD_PATH.unshift(lib_path) unless $LOAD_PATH.include?(lib_path)
35
+
36
+ require "lemans"
37
+ require "daytona"
38
+ require "digest"
39
+ require "fileutils"
40
+ require "json"
41
+ require "open3"
42
+ require "securerandom"
43
+ require "shellwords"
44
+ require "stringio"
45
+ require "thor"
46
+ require "time"
47
+ require "timeout"
48
+ require "tmpdir"
49
+ require "openssl"
50
+
51
+ module LemansRemote
52
+ LABEL = "lemans-remote"
53
+ RUN_ID_LABEL = "lemans-remote/run-id"
54
+ STATUS_LABEL = "lemans-remote/status"
55
+ STARTED_AT_LABEL = "lemans-remote/started-at"
56
+ HELPER_LABEL = "lemans-remote-helper"
57
+
58
+ REAP_MINUTES = 1440
59
+
60
+ BASE_IMAGE = "ruby:3.4-slim"
61
+ SETUP_COMMANDS = [
62
+ "apt-get update && apt-get install -y --no-install-recommends git curl ca-certificates libcurl4 && rm -rf /var/lib/apt/lists/*",
63
+ "gem install lemans --version #{Lemans::VERSION}",
64
+ "lemans version"
65
+ ].freeze
66
+
67
+ REMOTE_BENCH_DIR = "/task"
68
+ REMOTE_BENCH_ARCHIVE = "/tmp/bench.tgz"
69
+ REMOTE_RUNS_ARCHIVE = "/tmp/runs.tgz"
70
+ REMOTE_LOG = "/tmp/lemans-remote-run.log"
71
+ REMOTE_STATUS = "/tmp/lemans-remote-run.status"
72
+ REMOTE_WRAPPER = "/tmp/lemans-remote-wrapper.sh"
73
+ REMOTE_HOOKS = "/tmp/lemans-remote-hooks.rb"
74
+ REMOTE_META = "/tmp/lemans-remote-run.json"
75
+
76
+ ENV_ALLOWLIST = %w[OPENROUTER_API_KEY LEMANS_PROVIDER_ORDER].freeze
77
+
78
+ Backend = Lemans::Environments::Daytona
79
+
80
+ def self.client = Backend.client
81
+
82
+ class Provisioner
83
+ include Backend::Retries
84
+
85
+ POLL_INTERVAL_SEC = 2
86
+ BUILD_TIMEOUT_SEC = 900
87
+
88
+ FAILED_STATES = [
89
+ DaytonaApiClient::SnapshotState::ERROR,
90
+ DaytonaApiClient::SnapshotState::BUILD_FAILED
91
+ ].freeze
92
+
93
+ def initialize(cpus:, memory_gb:, disk_gb:)
94
+ @cpus = cpus
95
+ @memory_gb = memory_gb
96
+ @disk_gb = disk_gb
97
+ end
98
+
99
+ def name
100
+ @name ||= begin
101
+ recipe = [BASE_IMAGE, *SETUP_COMMANDS, @cpus, @memory_gb, @disk_gb].join("\n")
102
+ "lemans-remote-#{Lemans::VERSION.tr(".", "-")}-#{Digest::SHA256.hexdigest(recipe)[0, 12]}"
103
+ end
104
+ end
105
+
106
+ def provisioned? = !find.nil?
107
+
108
+ def call(force: false, logger: nil)
109
+ existing = find
110
+ existing = discard(existing) if existing && (force || FAILED_STATES.include?(existing.state))
111
+ existing ? await_ready(existing) : build(logger)
112
+ name
113
+ end
114
+
115
+ def drop(target = name)
116
+ existing = find(target)
117
+ return false unless existing
118
+
119
+ discard(existing, target)
120
+ true
121
+ end
122
+
123
+ private
124
+
125
+ def client = LemansRemote.client
126
+
127
+ def find(target = name)
128
+ with_read_retries { client.snapshot.get(target) }
129
+ rescue *SDK_ERRORS => e
130
+ return nil if status_code(e) == 404
131
+
132
+ raise
133
+ end
134
+
135
+ def discard(snapshot, target = name)
136
+ client.snapshot.delete(snapshot)
137
+ deadline = now + BUILD_TIMEOUT_SEC
138
+ while find(target)
139
+ raise "snapshot #{target} would not go away" if now > deadline
140
+
141
+ sleep POLL_INTERVAL_SEC
142
+ end
143
+ nil
144
+ rescue *SDK_ERRORS => e
145
+ return nil if status_code(e) == 404
146
+
147
+ raise
148
+ end
149
+
150
+ def build(logger)
151
+ params = ::Daytona::CreateSnapshotParams.new(
152
+ name: name,
153
+ image: ::Daytona::Image.base(BASE_IMAGE).run_commands(*SETUP_COMMANDS),
154
+ resources: ::Daytona::Resources.new(cpu: @cpus, memory: @memory_gb, disk: @disk_gb)
155
+ )
156
+ Timeout.timeout(BUILD_TIMEOUT_SEC, RuntimeError, "snapshot #{name} did not build within #{BUILD_TIMEOUT_SEC}s") do
157
+ client.snapshot.create(params, on_logs: logger)
158
+ end
159
+ rescue *SDK_ERRORS => e
160
+ raise unless status_code(e) == 409
161
+
162
+ taken = find
163
+ raise "snapshot #{name} was taken and then vanished" if taken.nil?
164
+
165
+ await_ready(taken)
166
+ end
167
+
168
+ def await_ready(snapshot)
169
+ deadline = now + BUILD_TIMEOUT_SEC
170
+ activated = false
171
+
172
+ loop do
173
+ state = snapshot.state
174
+ return if state == DaytonaApiClient::SnapshotState::ACTIVE
175
+
176
+ raise "snapshot #{name} is #{state}: #{snapshot.error_reason}" if FAILED_STATES.include?(state)
177
+
178
+ if state == DaytonaApiClient::SnapshotState::INACTIVE && !activated
179
+ client.snapshot.activate(snapshot)
180
+ activated = true
181
+ end
182
+
183
+ raise "snapshot #{name} was still #{state} after #{BUILD_TIMEOUT_SEC}s" if now > deadline
184
+
185
+ sleep POLL_INTERVAL_SEC
186
+ snapshot = find
187
+ raise "snapshot #{name} vanished while it was being waited on" if snapshot.nil?
188
+ end
189
+ end
190
+
191
+ def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
192
+ end
193
+
194
+ class BenchArchive
195
+ EXCLUDES = %w[runs .git].freeze
196
+
197
+ def initialize(bench:, selected_tasks: nil)
198
+ @bench = bench
199
+ @selected_tasks = selected_tasks
200
+ end
201
+
202
+ def pack(to:)
203
+ Dir.mktmpdir("lemans-remote-stage") do |stage|
204
+ @bench.root.children.each do |child|
205
+ next if EXCLUDES.include?(child.basename.to_s)
206
+
207
+ FileUtils.cp_r(child, File.join(stage, child.basename.to_s))
208
+ end
209
+ prune_tasks(stage) if @selected_tasks
210
+ _, err, status = Open3.capture3("tar", "-czf", to, "-C", stage, ".")
211
+ raise "could not pack the bench: #{err}" unless status.success?
212
+ end
213
+ to
214
+ end
215
+
216
+ private
217
+
218
+ def prune_tasks(stage)
219
+ tasks_rel = @bench.tasks_dir.relative_path_from(@bench.root).to_s
220
+ staged_tasks = File.join(stage, tasks_rel)
221
+ Dir.children(staged_tasks).each do |entry|
222
+ next if @selected_tasks.include?(entry)
223
+ next unless File.directory?(File.join(staged_tasks, entry))
224
+
225
+ FileUtils.rm_rf(File.join(staged_tasks, entry))
226
+ end
227
+ end
228
+ end
229
+
230
+ class Vault
231
+ NAME = "lemans-remote-runs"
232
+ MOUNT = "/vault"
233
+ READY_TIMEOUT_SEC = 180
234
+ POLL_INTERVAL_SEC = 2
235
+
236
+ BAD_STATES = [
237
+ DaytonaApiClient::VolumeState::ERROR,
238
+ DaytonaApiClient::VolumeState::PENDING_DELETE,
239
+ DaytonaApiClient::VolumeState::DELETING,
240
+ DaytonaApiClient::VolumeState::DELETED
241
+ ].freeze
242
+
243
+ class << self
244
+ def ensure
245
+ volume = client.volume.get(NAME, create: true)
246
+ deadline = now + READY_TIMEOUT_SEC
247
+ until volume.state == DaytonaApiClient::VolumeState::READY
248
+ raise "volume #{NAME} is #{volume.state}: #{volume.error_reason}" if BAD_STATES.include?(volume.state)
249
+ raise "volume #{NAME} was not ready after #{READY_TIMEOUT_SEC}s" if now > deadline
250
+
251
+ sleep POLL_INTERVAL_SEC
252
+ volume = client.volume.get(NAME)
253
+ end
254
+ volume
255
+ end
256
+
257
+ def mount_param(volume)
258
+ DaytonaApiClient::SandboxVolume.new(volume_id: volume.id, mount_path: MOUNT)
259
+ end
260
+
261
+ def drop
262
+ volume = client.volume.get(NAME)
263
+ attempts = 0
264
+ begin
265
+ client.volume.delete(volume)
266
+ rescue DaytonaApiClient::ApiError => e
267
+ raise unless e.code == 409 && (attempts += 1) < 10
268
+
269
+ sleep POLL_INTERVAL_SEC
270
+ retry
271
+ end
272
+ true
273
+ rescue DaytonaApiClient::ApiError => e
274
+ return false if e.code == 404
275
+
276
+ raise
277
+ end
278
+
279
+ private
280
+
281
+ def client = LemansRemote.client
282
+
283
+ def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
284
+ end
285
+ end
286
+
287
+ class Fleet
288
+ LIVE_STATES = [
289
+ DaytonaApiClient::SandboxState::CREATING,
290
+ DaytonaApiClient::SandboxState::PENDING_BUILD,
291
+ DaytonaApiClient::SandboxState::PULLING_SNAPSHOT,
292
+ DaytonaApiClient::SandboxState::RESTORING,
293
+ DaytonaApiClient::SandboxState::STARTING,
294
+ DaytonaApiClient::SandboxState::STARTED,
295
+ DaytonaApiClient::SandboxState::RESUMING
296
+ ].freeze
297
+
298
+ Row = Struct.new(:run_id, :status, :started_at, :state, :sandbox, keyword_init: true)
299
+
300
+ class << self
301
+ def rows
302
+ sandboxes.map do |sandbox|
303
+ labels = sandbox.labels || {}
304
+ status = labels[STATUS_LABEL] || "running"
305
+ status = "stale" if status == "running" && !LIVE_STATES.include?(sandbox.state)
306
+ Row.new(run_id: labels[RUN_ID_LABEL] || "?", status: status,
307
+ started_at: labels[STARTED_AT_LABEL], state: sandbox.state, sandbox: sandbox)
308
+ end
309
+ end
310
+
311
+ def live = rows.select { LIVE_STATES.include?(_1.state) }
312
+
313
+ private
314
+
315
+ def sandboxes
316
+ LemansRemote.client.list(::Daytona::ListSandboxesQuery.new(labels: { LABEL => "1" })).to_a
317
+ end
318
+ end
319
+ end
320
+
321
+ class VaultClient
322
+ HELPER_TTL_MINUTES = 30
323
+ TRANSFER_TIMEOUT_SEC = 900
324
+
325
+ def initialize(snapshot:)
326
+ @snapshot = snapshot
327
+ end
328
+
329
+ def with_helper
330
+ volume = Vault.ensure
331
+ params = ::Daytona::CreateSandboxFromSnapshotParams.new(
332
+ snapshot: @snapshot,
333
+ labels: { HELPER_LABEL => "1" },
334
+ auto_stop_interval: 0,
335
+ auto_delete_interval: 0,
336
+ ttl_minutes: HELPER_TTL_MINUTES,
337
+ volumes: [Vault.mount_param(volume)]
338
+ )
339
+ sandbox = LemansRemote.client.create(params)
340
+ begin
341
+ yield sandbox
342
+ ensure
343
+ begin
344
+ sandbox.delete
345
+ rescue StandardError => e
346
+ warn "lemans-remote: helper sandbox #{sandbox.id} delete failed: #{e.message}"
347
+ end
348
+ end
349
+ end
350
+
351
+ def manifests(sandbox)
352
+ code = 'require "json"; puts Dir.glob("/vault/*/manifest.json")' \
353
+ ".filter_map { |f| JSON.parse(File.read(f)) rescue nil }.to_json"
354
+ response = sandbox.process.exec(command: "ruby -e #{Shellwords.escape(code)}", timeout: 120)
355
+ raise "could not read the vault manifests: #{response.result}" unless response.exit_code.zero?
356
+
357
+ body = response.result.to_s.strip
358
+ body.empty? ? [] : JSON.parse(body)
359
+ end
360
+
361
+ def download_runs(sandbox, run_id, runs_dir)
362
+ remote = "/vault/#{run_id}/runs.tar.gz"
363
+ probe = sandbox.process.exec(command: "test -f #{Shellwords.escape(remote)}", timeout: 60)
364
+ return nil unless probe.exit_code.zero?
365
+
366
+ FileUtils.mkdir_p(runs_dir)
367
+ Dir.mktmpdir("lemans-remote") do |tmp|
368
+ local = File.join(tmp, "runs.tgz")
369
+ File.open(local, "wb") do |file|
370
+ sandbox.fs.download_file_stream(remote, timeout: TRANSFER_TIMEOUT_SEC) { file.write(_1) }
371
+ end
372
+ _, err, status = Open3.capture3("tar", "-xzf", local, "-C", runs_dir)
373
+ raise "could not unpack #{run_id}: #{err}" unless status.success?
374
+
375
+ listing, err, status = Open3.capture3("tar", "-tzf", local)
376
+ raise "could not list #{run_id}: #{err}" unless status.success?
377
+
378
+ listing.lines(chomp: true)
379
+ .select { _1.end_with?("/result.json") }
380
+ .map { File.join(runs_dir, _1.delete_prefix("./")) }
381
+ .sort
382
+ end
383
+ end
384
+
385
+ def mark_pulled(sandbox, run_id)
386
+ code = 'require "json"; require "time"; ' \
387
+ 'path = File.join("/vault", ARGV[0], "manifest.json"); ' \
388
+ 'data = JSON.parse(File.read(path)); ' \
389
+ 'data["pulled_at"] = Time.now.utc.iso8601; ' \
390
+ "File.write(path, JSON.pretty_generate(data))"
391
+ sandbox.process.exec(command: "ruby -e #{Shellwords.escape(code)} #{Shellwords.escape(run_id)}", timeout: 60)
392
+ end
393
+
394
+ def clobber(sandbox, run_id)
395
+ sandbox.process.exec(command: "rm -rf /vault/#{Shellwords.escape(run_id)}", timeout: 60)
396
+ end
397
+ end
398
+
399
+ class Runner
400
+ POLL_INTERVAL_SEC = 3
401
+ LOG_CHUNK_BYTES = 262_144
402
+ SETUP_TIMEOUT_SEC = 300
403
+ TRANSFER_TIMEOUT_SEC = 900
404
+ TIMED_OUT = 124
405
+
406
+ def initialize(bench:, snapshot:, run_id:, tasks:, models:, extra_args:, extra_env:,
407
+ timeout_sec:, runs_dir:, keep:, sync:, shell:)
408
+ @bench = bench
409
+ @snapshot = snapshot
410
+ @run_id = run_id
411
+ @tasks = tasks
412
+ @models = models
413
+ @extra_args = extra_args
414
+ @extra_env = extra_env
415
+ @timeout_sec = timeout_sec
416
+ @runs_dir = runs_dir
417
+ @keep = keep
418
+ @sync = sync
419
+ @shell = shell
420
+ @started_at = Time.now.utc.iso8601
421
+ end
422
+
423
+ def call = @sync ? call_sync : call_async
424
+
425
+ private
426
+
427
+ def call_sync
428
+ sandbox = create_sandbox
429
+ say :sandbox, "#{sandbox.id} (run #{@run_id})"
430
+ begin
431
+ upload_bench(sandbox)
432
+ exit_code = execute(sandbox)
433
+ collect(sandbox)
434
+ exit_code
435
+ ensure
436
+ teardown(sandbox)
437
+ end
438
+ end
439
+
440
+ def call_async
441
+ volume = Vault.ensure
442
+ sandbox = create_sandbox(volume)
443
+ say :sandbox, "#{sandbox.id} (run #{@run_id})"
444
+ launched = false
445
+ begin
446
+ upload_bench(sandbox)
447
+ upload_control_files(sandbox)
448
+ launch(sandbox)
449
+ launched = true
450
+ ensure
451
+ teardown(sandbox) unless launched
452
+ end
453
+ 0
454
+ end
455
+
456
+ def create_sandbox(volume = nil)
457
+ params = ::Daytona::CreateSandboxFromSnapshotParams.new(
458
+ snapshot: @snapshot,
459
+ env_vars: env_vars,
460
+ labels: {
461
+ LABEL => "1",
462
+ RUN_ID_LABEL => @run_id,
463
+ STATUS_LABEL => "running",
464
+ STARTED_AT_LABEL => @started_at
465
+ },
466
+ auto_stop_interval: 0,
467
+ auto_delete_interval: volume ? REAP_MINUTES : 60,
468
+ ttl_minutes: (@timeout_sec / 60.0).ceil + 60,
469
+ volumes: volume ? [Vault.mount_param(volume)] : nil
470
+ )
471
+ LemansRemote.client.create(params)
472
+ end
473
+
474
+ def env_vars
475
+ vars = (ENV_ALLOWLIST + @extra_env).filter_map { |key| [key, ENV[key]] if ENV[key] }.to_h
476
+ daytona_key = ENV["DAYTONA_API_KEY"] || ENV["DAYTONA_TOKEN"]
477
+ vars["DAYTONA_API_KEY"] = daytona_key if daytona_key
478
+ vars
479
+ end
480
+
481
+ def upload_bench(sandbox)
482
+ say :upload, "bench #{@bench.root}"
483
+ Dir.mktmpdir("lemans-remote") do |tmp|
484
+ archive = BenchArchive.new(bench: @bench, selected_tasks: @tasks.empty? ? nil : @tasks)
485
+ .pack(to: File.join(tmp, "bench.tgz"))
486
+ File.open(archive, "rb") do |file|
487
+ sandbox.fs.upload_file_stream(file, REMOTE_BENCH_ARCHIVE, timeout: TRANSFER_TIMEOUT_SEC)
488
+ end
489
+ end
490
+ exec!(sandbox, "mkdir -p #{REMOTE_BENCH_DIR} && " \
491
+ "tar -xzf #{REMOTE_BENCH_ARCHIVE} -C #{REMOTE_BENCH_DIR} && " \
492
+ "rm -f #{REMOTE_BENCH_ARCHIVE}")
493
+ end
494
+
495
+ def execute(sandbox)
496
+ command = remote_command
497
+ say :run, command
498
+ session = "lemans-remote-#{SecureRandom.hex(4)}"
499
+ sandbox.process.create_session(session)
500
+ wrapped = "(\ncd #{REMOTE_BENCH_DIR} && #{command}\n) > #{REMOTE_LOG} 2>&1\n" \
501
+ "echo $? > #{REMOTE_STATUS}"
502
+ sandbox.process.execute_session_command(
503
+ session_id: session,
504
+ req: ::Daytona::SessionExecuteRequest.new(command: wrapped, run_async: true)
505
+ )
506
+ stream_until_done(sandbox)
507
+ end
508
+
509
+ def remote_command
510
+ command = %w[lemans run --bench .]
511
+ @tasks.each { command += ["--task", _1] }
512
+ @models.each { command += ["--model", _1] }
513
+ command = command.shelljoin
514
+ command += " #{@extra_args}" unless @extra_args.empty?
515
+ command
516
+ end
517
+
518
+ def upload_control_files(sandbox)
519
+ upload_blob(sandbox, JSON.pretty_generate(run_metadata(sandbox)), REMOTE_META)
520
+ upload_blob(sandbox, hooks_script, REMOTE_HOOKS)
521
+ upload_blob(sandbox, wrapper_script, REMOTE_WRAPPER)
522
+ end
523
+
524
+ def upload_blob(sandbox, content, remote_path)
525
+ sandbox.fs.upload_file_stream(StringIO.new(content), remote_path, timeout: TRANSFER_TIMEOUT_SEC)
526
+ end
527
+
528
+ def launch(sandbox)
529
+ say :run, remote_command
530
+ session = "lemans-remote-#{SecureRandom.hex(4)}"
531
+ sandbox.process.create_session(session)
532
+ sandbox.process.execute_session_command(
533
+ session_id: session,
534
+ req: ::Daytona::SessionExecuteRequest.new(command: "bash #{REMOTE_WRAPPER}", run_async: true)
535
+ )
536
+ end
537
+
538
+ def run_metadata(sandbox)
539
+ {
540
+ "run_id" => @run_id,
541
+ "bench" => @bench.root.basename.to_s,
542
+ "tasks" => @tasks,
543
+ "models" => @models,
544
+ "args" => @extra_args,
545
+ "lemans_version" => Lemans::VERSION,
546
+ "snapshot" => @snapshot,
547
+ "sandbox_id" => sandbox.id,
548
+ "started_at" => @started_at,
549
+ "timeout_sec" => @timeout_sec
550
+ }
551
+ end
552
+
553
+ def wrapper_script
554
+ vault_dir = "#{Vault::MOUNT}/#{@run_id}"
555
+ <<~BASH
556
+ #!/bin/bash
557
+ set -u
558
+ ruby #{REMOTE_HOOKS} start
559
+ (cd #{REMOTE_BENCH_DIR} && #{remote_command}) > #{REMOTE_LOG} 2>&1
560
+ status=$?
561
+ cp #{REMOTE_LOG} #{vault_dir}/run.log || true
562
+ if [ -d #{REMOTE_BENCH_DIR}/runs ]; then
563
+ tar -czf #{vault_dir}/runs.tar.gz -C #{REMOTE_BENCH_DIR}/runs .
564
+ fi
565
+ (cd #{REMOTE_BENCH_DIR} && lemans report --format csv > #{vault_dir}/report.csv 2>/dev/null) || true
566
+ ruby #{REMOTE_HOOKS} finish "$status"
567
+ BASH
568
+ end
569
+
570
+ def hooks_script
571
+ <<~RUBY
572
+ require "json"
573
+ require "time"
574
+ require "fileutils"
575
+
576
+ META = JSON.parse(File.read("#{REMOTE_META}"))
577
+ VAULT_DIR = File.join("#{Vault::MOUNT}", META.fetch("run_id"))
578
+ MANIFEST = File.join(VAULT_DIR, "manifest.json")
579
+
580
+ def write_manifest(extra)
581
+ base = File.exist?(MANIFEST) ? JSON.parse(File.read(MANIFEST)) : META
582
+ File.write(MANIFEST, JSON.pretty_generate(base.merge(extra)))
583
+ end
584
+
585
+ case ARGV.fetch(0)
586
+ when "start"
587
+ FileUtils.mkdir_p(VAULT_DIR)
588
+ write_manifest("status" => "running")
589
+ when "finish"
590
+ exit_code = Integer(ARGV.fetch(1, "1"))
591
+ status = exit_code.zero? ? "success" : "failed"
592
+ write_manifest("status" => status, "exit_code" => exit_code, "finished_at" => Time.now.utc.iso8601)
593
+ begin
594
+ require "openssl"
595
+ require "daytona"
596
+ sandbox = Daytona::Daytona.new.get(META.fetch("sandbox_id"))
597
+ sandbox.labels = {
598
+ "#{LABEL}" => "1",
599
+ "#{RUN_ID_LABEL}" => META.fetch("run_id"),
600
+ "#{STATUS_LABEL}" => status,
601
+ "#{STARTED_AT_LABEL}" => META.fetch("started_at")
602
+ }
603
+ sandbox.stop
604
+ rescue StandardError => e
605
+ warn "finalize: \#{e.class}: \#{e.message}"
606
+ end
607
+ end
608
+ RUBY
609
+ end
610
+
611
+ def stream_until_done(sandbox)
612
+ offset = 0
613
+ deadline = now + @timeout_sec
614
+ loop do
615
+ chunk = read_log(sandbox, offset)
616
+ unless chunk.empty?
617
+ offset += chunk.bytesize
618
+ $stdout.write(chunk)
619
+ $stdout.flush
620
+ end
621
+ status = read_status(sandbox)
622
+ return status if status && chunk.empty?
623
+
624
+ if now > deadline
625
+ say :timeout, "run did not finish within #{@timeout_sec}s", :red
626
+ return TIMED_OUT
627
+ end
628
+
629
+ sleep POLL_INTERVAL_SEC unless status
630
+ end
631
+ end
632
+
633
+ def read_log(sandbox, offset)
634
+ response = sandbox.process.exec(
635
+ command: "tail -c +#{offset + 1} #{REMOTE_LOG} 2>/dev/null | head -c #{LOG_CHUNK_BYTES}",
636
+ timeout: 60
637
+ )
638
+ response.result.to_s
639
+ end
640
+
641
+ def read_status(sandbox)
642
+ response = sandbox.process.exec(command: "cat #{REMOTE_STATUS} 2>/dev/null", timeout: 60)
643
+ value = response.result.to_s.strip
644
+ value.match?(/\A\d+\z/) ? Integer(value) : nil
645
+ end
646
+
647
+ def collect(sandbox)
648
+ probe = sandbox.process.exec(command: "test -d #{REMOTE_BENCH_DIR}/runs", timeout: 60)
649
+ unless probe.exit_code.zero?
650
+ say :collect, "no runs directory in the sandbox — nothing to download", :yellow
651
+ return
652
+ end
653
+
654
+ exec!(sandbox, "tar -czf #{REMOTE_RUNS_ARCHIVE} -C #{REMOTE_BENCH_DIR}/runs .")
655
+ FileUtils.mkdir_p(@runs_dir)
656
+ Dir.mktmpdir("lemans-remote") do |tmp|
657
+ local = File.join(tmp, "runs.tgz")
658
+ File.open(local, "wb") do |file|
659
+ sandbox.fs.download_file_stream(REMOTE_RUNS_ARCHIVE, timeout: TRANSFER_TIMEOUT_SEC) { file.write(_1) }
660
+ end
661
+ _, err, status = Open3.capture3("tar", "-xzf", local, "-C", @runs_dir)
662
+ raise "could not unpack the runs: #{err}" unless status.success?
663
+ end
664
+ say :collect, "runs synced into #{@runs_dir}/"
665
+ end
666
+
667
+ def teardown(sandbox)
668
+ if @keep
669
+ say :keep, "sandbox #{sandbox.id} left running (TTL will reap it)", :yellow
670
+ return
671
+ end
672
+
673
+ sandbox.delete
674
+ rescue StandardError => e
675
+ warn "lemans-remote: sandbox #{sandbox.id} may still be running — delete failed: #{e.message}"
676
+ end
677
+
678
+ def exec!(sandbox, command)
679
+ response = sandbox.process.exec(command: command, timeout: SETUP_TIMEOUT_SEC)
680
+ raise "`#{command}` failed (exit #{response.exit_code}): #{response.result}" unless response.exit_code.zero?
681
+
682
+ response
683
+ end
684
+
685
+ def say(status, message, color = :green) = @shell.say_status(status, message, color)
686
+
687
+ def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
688
+ end
689
+
690
+ class CLI < Thor
691
+ check_unknown_options!
692
+
693
+ def self.exit_on_failure? = true
694
+
695
+ class_option :cpus, type: :numeric, default: 4, desc: "Orchestrator vCPUs (part of the snapshot identity)"
696
+ class_option :memory, type: :numeric, default: 8, desc: "Orchestrator memory, GiB"
697
+ class_option :disk, type: :numeric, default: 10, desc: "Orchestrator disk, GiB"
698
+
699
+ desc "provision", "Build the orchestrator snapshot (lemans #{Lemans::VERSION} preinstalled)"
700
+ option :force, type: :boolean, default: false, desc: "Discard the existing snapshot and rebuild"
701
+ def provision
702
+ provisioner = build_provisioner
703
+ if provisioner.provisioned? && !options[:force]
704
+ say_status :snapshot, "#{provisioner.name} already provisioned", :green
705
+ provisioner.call
706
+ return
707
+ end
708
+
709
+ say_status :build, provisioner.name
710
+ provisioner.call(force: options[:force], logger: ->(chunk) { print chunk })
711
+ say_status :snapshot, "#{provisioner.name} ready", :green
712
+ rescue Lemans::ConfigError, RuntimeError => e
713
+ raise Thor::Error, "lemans-remote: #{e.message}"
714
+ end
715
+
716
+ map "run" => :run_bench
717
+ desc "run", "Run a bench on a remote Daytona sandbox (fire-and-forget; --sync to wait and download)"
718
+ option :bench, default: ".", desc: "Directory holding bench.yml"
719
+ option :task, repeatable: true, desc: "Run task(s) by name (default: all)"
720
+ option :model, repeatable: true, desc: "Override the model(s) from bench.yml"
721
+ option :args, default: "", desc: "Extra `lemans run` options, passed through verbatim"
722
+ option :timeout, default: "6h", desc: "Give up on the remote run after this long"
723
+ option :sync, type: :boolean, default: false, desc: "Wait for the run and download the results directly"
724
+ option :run_in_band, type: :boolean, default: false, aliases: %w[--runInBand],
725
+ desc: "Async mode: run all tasks in one sandbox instead of one sandbox per task"
726
+ option :attempts, type: :numeric, default: 1, desc: "Async mode: repeat each task batch N times, one sandbox per attempt"
727
+ option :keep, type: :boolean, default: false, desc: "Sync mode: leave the sandbox around for debugging"
728
+ option :runs_dir, default: "runs", desc: "Sync mode: local directory to sync the results into"
729
+ option :env, repeatable: true, desc: "Forward an extra host ENV variable by name"
730
+ def run_bench
731
+ bench = Lemans::Bench.load(options[:bench])
732
+ tasks = options[:task] || []
733
+ unknown = tasks - bench.tasks.map(&:name)
734
+ raise Thor::Error, "lemans-remote: no such task(s): #{unknown.join(", ")}" if unknown.any?
735
+
736
+ provisioner = build_provisioner
737
+ unless provisioner.provisioned?
738
+ raise Thor::Error, "lemans-remote: snapshot #{provisioner.name} not found — run `lemans-remote provision` first"
739
+ end
740
+
741
+ attempts = options[:attempts].to_i
742
+ raise Thor::Error, "lemans-remote: --attempts must be at least 1" if attempts < 1
743
+ raise Thor::Error, "lemans-remote: --attempts needs async mode — drop --sync" if options[:sync] && attempts > 1
744
+
745
+ models = options[:model] || []
746
+ batches = plan_batches(bench, tasks)
747
+ jobs = batches.flat_map { |batch| (1..attempts).map { [batch, attempts > 1 ? _1 : nil] } }
748
+ Vault.ensure unless options[:sync]
749
+
750
+ if jobs.size == 1
751
+ batch, attempt = jobs.first
752
+ exit_code = launch_batch(bench, batch, models, provisioner, attempt:)
753
+ say_status :detached, "`lemans-remote status` to watch, `lemans-remote pull-runs` to fetch results" unless options[:sync]
754
+ exit exit_code unless exit_code.zero?
755
+ return
756
+ end
757
+
758
+ fanout = attempts > 1 ? "#{batches.size} task batch(es) × #{attempts} attempts" : "one per task"
759
+ say_status :fanout, "#{jobs.size} sandboxes, #{fanout}"
760
+ failures = launch_batches(bench, jobs, models, provisioner)
761
+ say_status :detached, "`lemans-remote status` to watch, `lemans-remote pull-runs` to fetch results"
762
+ raise Thor::Error, "lemans-remote: failed to launch: #{failures.join("; ")}" if failures.any?
763
+ rescue Lemans::ConfigError, RuntimeError => e
764
+ raise Thor::Error, "lemans-remote: #{e.message}"
765
+ end
766
+
767
+ desc "status", "List lemans-remote runs (--history adds completed runs whose sandboxes are gone)"
768
+ option :history, type: :boolean, default: false, desc: "Also read the vault manifests (spins a short-lived helper sandbox)"
769
+ option :running, type: :boolean, default: false, desc: "Only show runs still in flight"
770
+ option :complete, type: :boolean, default: false, desc: "Only show finished runs (success, failed, or stale)"
771
+ def status
772
+ rows = Fleet.rows.map { [_1.run_id, _1.status, _1.started_at || "?", _1.state, _1.sandbox.id] }
773
+ if options[:history]
774
+ seen = rows.map(&:first)
775
+ manifests = with_vault { |vault, sandbox| vault.manifests(sandbox) }
776
+ manifests.reject { seen.include?(_1["run_id"]) }.each do |manifest|
777
+ note = manifest["pulled_at"] ? "pulled" : "-"
778
+ rows << [manifest["run_id"], manifest["status"], manifest["started_at"] || "?", "gone", note]
779
+ end
780
+ end
781
+
782
+ filtered = options[:running] ^ options[:complete]
783
+ rows.select! { (_1[1] == "running") == options[:running] } if filtered
784
+
785
+ if rows.empty?
786
+ if filtered
787
+ say "no #{options[:running] ? "running" : "complete"} lemans-remote runs"
788
+ else
789
+ say "no lemans-remote runs#{options[:history] ? "" : " — try --history for completed ones"}"
790
+ end
791
+ return
792
+ end
793
+
794
+ rows.sort_by! { _1[2].to_s }
795
+ rows.reverse!
796
+ print_table([%w[run status started sandbox id]] + rows)
797
+ rescue Lemans::ConfigError, RuntimeError => e
798
+ raise Thor::Error, "lemans-remote: #{e.message}"
799
+ end
800
+
801
+ map "pull-runs" => :pull_runs
802
+ desc "pull-runs [RUN_IDS...]", "Download archived runs from the vault into the local runs directory"
803
+ option :all, type: :boolean, default: false, desc: "Pull every completed run, even ones already pulled"
804
+ option :runs_dir, default: "runs", desc: "Local directory to sync the results into"
805
+ def pull_runs(*run_ids)
806
+ pulled = []
807
+ with_vault do |vault, sandbox|
808
+ manifests = vault.manifests(sandbox)
809
+ known = manifests.map { _1["run_id"] }
810
+ targets =
811
+ if run_ids.any?
812
+ missing = run_ids - known
813
+ raise Thor::Error, "lemans-remote: no such run(s) in the vault: #{missing.join(", ")}" if missing.any?
814
+
815
+ run_ids
816
+ else
817
+ manifests.select { %w[success failed].include?(_1["status"]) }
818
+ .reject { !options[:all] && _1["pulled_at"] }
819
+ .map { _1["run_id"] }
820
+ end
821
+ say_status :pull, "nothing new to pull (use --all to re-pull)", :yellow if targets.empty?
822
+
823
+ targets.each do |run_id|
824
+ results = vault.download_runs(sandbox, run_id, options[:runs_dir])
825
+ if results
826
+ vault.mark_pulled(sandbox, run_id)
827
+ pulled << run_id
828
+ say_status :pulled, "#{run_id} (#{results.size} result(s))", :green
829
+ results.take(10).each { say " #{_1}" }
830
+ say " … and #{results.size - 10} more" if results.size > 10
831
+ else
832
+ say_status :missing, "#{run_id} has no runs.tar.gz (still running?)", :yellow
833
+ end
834
+ end
835
+ end
836
+ say_status :done, "#{pulled.size} run(s) synced into #{options[:runs_dir]}/", :green if pulled.any?
837
+ rescue Lemans::ConfigError, RuntimeError => e
838
+ raise Thor::Error, "lemans-remote: #{e.message}"
839
+ end
840
+
841
+ desc "clobber [RUN_IDS...]", "Delete archived runs from the vault (--all wipes the whole volume)"
842
+ option :all, type: :boolean, default: false, desc: "Delete every archived run and the volume itself"
843
+ option :force, type: :boolean, default: false, aliases: "-f", desc: "Skip the confirmation"
844
+ def clobber(*run_ids)
845
+ raise Thor::Error, "lemans-remote: pass run id(s) or --all" if run_ids.empty? && !options[:all]
846
+
847
+ live = Fleet.live
848
+ if options[:all]
849
+ unless live.empty?
850
+ raise Thor::Error, "lemans-remote: #{live.size} run(s) still live (#{live.map(&:run_id).join(", ")}) — wait for them to finish"
851
+ end
852
+ return unless options[:force] || yes?("Delete the whole #{Vault::NAME} volume and every stopped run sandbox? [y/N]")
853
+
854
+ Fleet.rows.each { safe_delete_sandbox(_1.sandbox) }
855
+ if Vault.drop
856
+ say_status :clobbered, "volume #{Vault::NAME} deleted (recreated on the next run)", :green
857
+ else
858
+ say_status :absent, "volume #{Vault::NAME} does not exist", :yellow
859
+ end
860
+ return
861
+ end
862
+
863
+ still_live = live.select { run_ids.include?(_1.run_id) }
864
+ raise Thor::Error, "lemans-remote: still live: #{still_live.map(&:run_id).join(", ")}" if still_live.any?
865
+ return unless options[:force] || yes?("Delete #{run_ids.join(", ")} from the vault? [y/N]")
866
+
867
+ rows = Fleet.rows
868
+ with_vault do |vault, sandbox|
869
+ run_ids.each do |run_id|
870
+ vault.clobber(sandbox, run_id)
871
+ rows.select { _1.run_id == run_id }.each { safe_delete_sandbox(_1.sandbox) }
872
+ say_status :clobbered, run_id, :green
873
+ end
874
+ end
875
+ rescue Lemans::ConfigError, RuntimeError => e
876
+ raise Thor::Error, "lemans-remote: #{e.message}"
877
+ end
878
+
879
+ desc "deprovision", "Delete the orchestrator snapshot"
880
+ option :name, desc: "Delete a specific snapshot by name (default: the one for this shape and version)"
881
+ def deprovision
882
+ provisioner = build_provisioner
883
+ target = options[:name] || provisioner.name
884
+ if provisioner.drop(target)
885
+ say_status :dropped, target, :green
886
+ else
887
+ say_status :absent, "#{target} — nothing to drop", :yellow
888
+ end
889
+ rescue Lemans::ConfigError, RuntimeError => e
890
+ raise Thor::Error, "lemans-remote: #{e.message}"
891
+ end
892
+
893
+ desc "version", "Print the lemans version this tool provisions and runs"
894
+ def version
895
+ say Lemans::VERSION
896
+ end
897
+
898
+ private
899
+
900
+ def build_provisioner
901
+ Provisioner.new(cpus: options[:cpus].to_i, memory_gb: options[:memory].to_i, disk_gb: options[:disk].to_i)
902
+ end
903
+
904
+ FANOUT_THREADS = 4
905
+
906
+ def plan_batches(bench, tasks)
907
+ return [tasks] if options[:sync] || options[:run_in_band]
908
+
909
+ expanded = tasks.any? ? tasks : bench.tasks.map(&:name)
910
+ expanded.map { [_1] }
911
+ end
912
+
913
+ def launch_batch(bench, tasks, models, provisioner, attempt: nil)
914
+ Runner.new(
915
+ bench: bench,
916
+ snapshot: provisioner.name,
917
+ run_id: build_run_id(bench, tasks, models, attempt),
918
+ tasks: tasks,
919
+ models: models,
920
+ extra_args: options[:args].strip,
921
+ extra_env: options[:env] || [],
922
+ timeout_sec: Lemans::Units.seconds(options[:timeout], field: "--timeout").to_i,
923
+ runs_dir: options[:runs_dir],
924
+ keep: options[:keep],
925
+ sync: options[:sync],
926
+ shell: shell
927
+ ).call
928
+ end
929
+
930
+ def launch_batches(bench, jobs, models, provisioner)
931
+ queue = Queue.new
932
+ jobs.each { queue << _1 }
933
+ failures = Queue.new
934
+
935
+ threads = [FANOUT_THREADS, jobs.size].min.times.map do
936
+ Thread.new do
937
+ loop do
938
+ batch, attempt =
939
+ begin
940
+ queue.pop(true)
941
+ rescue ThreadError
942
+ break
943
+ end
944
+ begin
945
+ launch_batch(bench, batch, models, provisioner, attempt:)
946
+ rescue StandardError => e
947
+ label = batch.empty? ? "all" : batch.join(",")
948
+ label += "/a#{attempt}" if attempt
949
+ failures << "#{label} (#{e.message})"
950
+ end
951
+ end
952
+ end
953
+ end
954
+ threads.each(&:join)
955
+
956
+ Array.new(failures.size) { failures.pop }
957
+ end
958
+
959
+ def build_run_id(bench, tasks, models, attempt = nil)
960
+ parts = [bench.root.basename.to_s]
961
+ parts << (tasks.size == 1 ? tasks.first : "#{tasks.size}tasks") if tasks.any?
962
+ if models.any?
963
+ short = models.first.split("/").last
964
+ short += "+#{models.size - 1}" if models.size > 1
965
+ parts << short
966
+ end
967
+ parts << "a#{attempt}" if attempt
968
+ parts << (ENV["LEMANS_REMOTE_USER"] || ENV["USER"] || "anon")
969
+ parts << Time.now.utc.strftime("%Y%m%dT%H%M%S")
970
+ parts << SecureRandom.alphanumeric(4).downcase
971
+ parts.map { name_part(_1) }.reject(&:empty?).join("-")
972
+ end
973
+
974
+ def name_part(value) = value.to_s.downcase.gsub(/[^a-z0-9._+]+/, "-").gsub(/\A-+|-+\z/, "")
975
+
976
+ def with_vault(&block)
977
+ provisioner = build_provisioner
978
+ unless provisioner.provisioned?
979
+ raise Thor::Error, "lemans-remote: snapshot #{provisioner.name} not found — run `lemans-remote provision` first"
980
+ end
981
+
982
+ vault = VaultClient.new(snapshot: provisioner.name)
983
+ vault.with_helper { |sandbox| block.call(vault, sandbox) }
984
+ end
985
+
986
+ def safe_delete_sandbox(sandbox)
987
+ sandbox.delete
988
+ rescue StandardError => e
989
+ warn "lemans-remote: could not delete sandbox #{sandbox.id}: #{e.message}"
990
+ end
991
+ end
992
+ end
993
+
994
+ begin
995
+ LemansRemote::CLI.start(ARGV)
996
+ rescue Interrupt
997
+ warn "\nlemans-remote: interrupted"
998
+ exit 130
999
+ rescue => e # rubocop:disable Style/RescueStandardError
1000
+ raise e if $DEBUG
1001
+
1002
+ warn e.message
1003
+ warn e.backtrace.take(10).join("\n") if ENV["LEMANS_DEBUG"] == "1"
1004
+ exit 1
1005
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Lemans
4
- VERSION = "0.2.0"
4
+ VERSION = "0.2.2"
5
5
  end
@@ -400,7 +400,14 @@ module Miniswen
400
400
  error = +""
401
401
  error << "Unknown tool '#{call[:name]}'." if call[:name] != "bash"
402
402
  arguments = call[:arguments]
403
- error << "Missing 'command' argument in bash tool call." unless arguments.is_a?(Hash) && arguments["command"]
403
+ if !arguments.is_a?(Hash) || !arguments["command"]
404
+ error << "Missing 'command' argument in bash tool call."
405
+ elsif arguments["command"].to_s.include?("\0")
406
+ # Process.spawn rejects strings with NUL bytes, so the command could
407
+ # never reach a shell.
408
+ error << "The 'command' argument contains a null byte (\\x00) and cannot be executed. " \
409
+ "Resend the command without null bytes."
410
+ end
404
411
  return error unless error.empty?
405
412
  end
406
413
  nil
@@ -497,7 +504,9 @@ module Miniswen
497
504
  )
498
505
  payload(response)
499
506
  rescue RubyLLM::Error => e
500
- raise InfrastructureError, "miniswen: the model call failed: #{e.message}"
507
+ body = e.response&.body.to_s
508
+ detail = body.empty? ? e.message : "#{e.message}: #{body[0, 1000]}"
509
+ raise InfrastructureError, "miniswen: the model call failed: #{detail}"
501
510
  rescue Faraday::SSLError, Faraday::ConnectionFailed, Faraday::TimeoutError => e
502
511
  raise InfrastructureError, "miniswen: the model call failed: #{e.class}: #{e.message}"
503
512
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Miniswen
4
- VERSION = "0.1.0"
4
+ VERSION = "0.1.1"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lemans
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.2.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Svyatoslav Kryukov
@@ -114,6 +114,7 @@ email:
114
114
  - ardecvz@gmail.com
115
115
  executables:
116
116
  - lemans
117
+ - lemans-remote
117
118
  extensions: []
118
119
  extra_rdoc_files: []
119
120
  files:
@@ -121,6 +122,7 @@ files:
121
122
  - LICENSE.txt
122
123
  - README.md
123
124
  - exe/lemans
125
+ - exe/lemans-remote
124
126
  - lib/lemans.rb
125
127
  - lib/lemans/agents.rb
126
128
  - lib/lemans/agents/base.rb