← Home
Sections

Building and debugging a terminal-agent RL pipeline

October 2026 · Experiment notes

I built an RL loop on Modal with one training GPU and two inference GPUs. It completed ten updates, but the Qwen3.5-9B adapter passed 83 of 184 held-out attempts. The base passed 91. I went through the rollouts to understand what the run had actually taught it.

TL;DR

  • The held-out score fell. Base: 91/184 passes. Step 10: 83/184. More attempts hit output or turn limits; this run did not demonstrate an improvement.
  • One trainer, two inference H100s. Ten LoRA updates, 64 accepted attempts per update, eight attempts per task, and 64 concurrent rollouts per inference GPU—128 total.
  • 216 tasks screened → 103 selected. I kept tasks with two to six passes out of eight, a 25–75% band, then froze an 80/23 training/held-out split.
  • About 89% lower sandbox request floor. Requests fell from 1 CPU and 2 GiB to 0.125 CPU and 0.125 GiB. Actual usage could exceed them; the total bill did not necessarily fall by 89%.
  • Rollouts needed a reliable terminal. I added a 60-second Bash deadline, kept the original task through compaction, and tested output-limit recovery.

The result and the setup

Held-out pass rate

Percent · 184 attempts per model

Base
49.5
Step 10
45.1
91/184 → 83/184. Eight fewer passes, a difference of −4.35 percentage points.

I wanted a model to work in a terminal, have its files graded, and learn from the attempts. Prime RL handled training and asynchronous rollouts; Verifiers ran the agent loop; Harbor graded the files in a separate sandbox. Modal supplied the GPUs and environments.

I used Qwen3.5-9B with thinking enabled, Bash, and an edit tool. My work was wiring those pieces together, checking that their tokens and rewards agreed, and measuring where time and money went.

Adapter
LoRA rank 16, alpha 32, learning rate 1e-5.
Precision
FP8 inference; BF16 training with FP32 optimization and reduction.
Sampling
Temperature 0.6, top-p 0.95, top-k 20; 8,192 output tokens per request.
Context
50,000 tokens, compaction at 30,000, maximum 50 turns.
Terminal-agent RL: task preparation, asynchronous training, and held-out evaluationPinned Harbor tasks pass CPU checks and eight-attempt screening. The qualified split contains 80 training and 23 held-out tasks. Two inference H100s interact with solver sandboxes. A separate grader returns rewards. Prime filters groups and stale rollouts and sends batches of 64 to one H100 trainer. Updated LoRA weights return to inference. Checkpoints are saved at steps 5 and 10. Base and step-10 policies are compared on 184 held-out attempts each. Traces, code, and the adapter are published. Moving purple dashes show weight updates, not episode speed.01 · Prepare tasksPinned task registryGit + Harbor packagesCPU package checksHashes · no-op · solution216 × 8 screening2–6 passes → 103 tasksFreeze the split before training; keep held-out tasks out of updates.80 training tasks ↓ · 23 held out for evaluation02 · Train on the other 80 tasksTwo inference H100s64 concurrent rollouts each · 128 totalSolver sandboxes ↔ modelBash / edit · workspace persistsSeparate Harbor graderHidden tests stay outside the solverGroups of eight attemptsReward checks · filtering · stalenessOne H100 trainer64 accepted / update · 10 updatesDashed loop: LoRA weights v0 → v1003 · Save, compare, publishNative checkpoints: 5 and 10Model · optimizer · progress metadataEvaluate base and step 1023 held-out tasks × 8 each · 91 vs. 83Public research artifactsHugging Face: traces + adapter · GitHubTerminal-agent RL: task preparation, asynchronous training, and held-out evaluationPinned Harbor tasks pass CPU checks and eight-attempt screening. The qualified split contains 80 training and 23 held-out tasks. Two inference H100s interact with solver sandboxes. A separate grader returns rewards. Prime filters groups and stale rollouts and sends batches of 64 to one H100 trainer. Updated LoRA weights return to inference. Checkpoints are saved at steps 5 and 10. Base and step-10 policies are compared on 184 held-out attempts each. Traces, code, and the adapter are published. Moving purple dashes show weight updates, not episode speed.01 · Prepare tasksPinned task registryGit commit + Harbor packagesCPU package checksFile hashes · no-op · known solution216 tasks × 8 attemptsKeep 2–6 passes: 103 tasks80 training tasks ↓ · 23 held out for evaluation02 · Train on the other 80 tasksTwo inference H100s64 concurrent rollouts each · 128 totalSolver sandboxes ↔ modelBash / edit · workspace persistsSeparate Harbor graderHidden tests stay outside the solverGroups of eight attemptsReward checks · filtering · stalenessOne H100 trainer64 accepted / update · 10 updatesDashed loop: LoRA weights v0 → v1003 · Save, compare, publishNative checkpoints: 5 and 10Model · optimizer · progress metadataEvaluate base and step 1023 held-out tasks × 8 each · 91 vs. 83Public research artifactsHugging Face: traces + adapter · GitHub
This run’s order: freeze the split, train, then evaluate both policies on the held-out tasks. Those tasks never feed training. Rollouts and weight updates overlap; the moving dashes show weight broadcasts, not episode speed.

Choosing tasks the model sometimes solves

If all eight attempts on a task get the same reward, there is no reward difference for a group-relative update to learn from. I needed tasks that produced both passes and failures.

One task, eight attempts

Each attempt runs in its own sandbox. The grader returns 1 for a pass and 0 for a failure.

All pass
11111111
No reward difference
All fail
00000000
No reward difference
Mixed
10110010
Rewards can be compared
Illustration, not a recorded group. GRPO compares rewards within the same task. A mixed group has a learning signal; equal rewards have none. Our batch contained 64 accepted attempts, with freshness checks applied separately.

I adapted TermGrade’s measured-difficulty approach: screen the base model in the harness used for training. TermGrade separates its public Terminus-2 grading from its Bash training-selection measurements, then narrows the initial 25–75% band toward 50%. I kept the wider band.

Across 216 tasks with eight screening attempts each, 103 had two to six passes. I froze 80 for training and 23 for evaluation. These held-out tasks had already been screened and examined; they were excluded from updates, not a blind benchmark.

Before using GPUs, I pinned Git commits and file hashes and checked that a no-op failed while the reference solution passed. Twelve retained Dockerfiles had blocked native Harbor environment upload. Removing those active files restored setup without changing the instructions or tests.

Making terminal rollouts reliable

The generated tokens had to be trainable

My first setup generated answers I could not reliably train on. I moved to Prime’s integrated Bash path for tool execution, compaction, and token capture. I checked a full tool-call exchange for matching token prefixes, finite log-probabilities, and sampled tokens inside their recorded support.

Two migration mistakes mattered: older dependencies could not decode the packed log-probabilities and masks, and a system-only prefix failed with “No user query found in messages.” Restoring compatible versions and using Prime’s Qwen3.5 renderer fixed those paths. I had no evidence that Bash itself solved more tasks.

Commands that kept waiting

Some commands and sandboxes had stayed stuck for hours. The Bash executor allowed 3,600 seconds per command; a shorter HTTP timeout would still leave the process running inside the sandbox.

Mr. Bean checks his watch, sits down, and eventually lies in a field while waiting.
Me checking whether that command has finished yet.

My patch closes stdin, starts a process group, and kills the group after 60 seconds. It returns partial output with a bounded follow-up read, so a child holding a pipe open cannot keep the call waiting. A Linux check stopped at 60.02 seconds, passed six process tests, and let the next command run.

That deadline also stops legitimate long builds. File edits remain, and detached processes still need sandbox cleanup. I could not attribute every earlier stall to this one cause.

Paying less while the model thinks

Sandboxes often waited while inference generated tokens. I reduced their requests from one CPU and 2 GiB to 0.125 CPU and 0.125 GiB. These were uncapped requests: an earlier sandbox had reached 9.32 GiB despite requesting 2 GiB.

Old floor
1 core + 2 GiB: $0.1899 per sandbox-hour.
New floor
0.125 core + 0.125 GiB: $0.02074 per sandbox-hour.

Modal bills the higher of requested or actual usage. The request floor fell about 89%; busy tasks still paid for extra usage. These historical estimates use sandbox rates of $0.00003942/core-second and $0.00000667/GiB-second, checked October 31, 2026. They exclude GPU and other service costs.

Keeping the task through a long attempt

Compaction could drop the exact task when rebuilding the prompt. I changed Verifiers to keep the original task plus the latest summary; repeated compaction replaces the old summary. CPU checks verified retention, but a bad summary can still carry a mistake forward.

Some responses used all 8,192 tokens thinking without calling a tool. I kept thinking enabled and added one corrective request per episode, based on mini-SWE-agent’s output-limit feedback. The unfinished response stays in the trace but leaves the next prompt. Recovery scored 9/16 with it and 9/16 without, using 62% more output tokens. It did not establish a pass-rate gain.

Throughput and what reached the trainer

Why I chose 64 per inference GPU

I benchmarked 16, 32, 64, 98, and 128 concurrent requests on one H100. Concurrency 128 had the best synthetic long-output throughput, but longer request latency and 25 observed KV-cache preemptions. The separate 98 run was slower than 64 despite no observed preemptions.

Long prefill: throughput

Output tokens / second

16
435
32
598
64
714
98
570
128
669
Total concurrency on the left. Bar scale: 0–1,500 tokens/s.

Shared-prefix long output: throughput

Output tokens / second

16
637
32
981
64
1285
98
1195
128
1391
Total concurrency on the left. Bar scale: 0–1,500 tokens/s.

Synthetic serving test, one H100 with frozen weights. Both workloads ran together: 64 requests each. Long prefill: about 16k input, 2,048 output tokens. Long output: shared 24k prefix plus 4k input, 8,192 output tokens. Full latency, time, GPU and preemption charts appear below.

I chose 64 per inference GPU—128 globally—for the terminal rollouts. The benchmark helped choose a candidate; it did not prove that 64 produces usable training batches fastest.

Prime keeps collecting attempts while training updates and broadcasts weights. An attempt can start before an update and finish after it. I allowed one off-policy step, grouped eight attempts on each task, and filled each update with 64 accepted attempts.

What happened to the returned attempts?

Episodes, grouped by policy version at group start

ReturnedAccepted for training
v0
285
128
v1
40
32
v2
245
96
v3
84
40
v4
188
88
v5
107
56
v6
179
72
v7
91
56
v8
154
72
v9
21
0
1,394 returned episodes; 640 accepted. These versions describe group start, not the policy behind every token. v10 had no new returned starts.

The stream returned 1,394 episodes; ten batches accepted 640, including 365 passes and 275 failures. All 80 training tasks appeared, but only 55 contributed accepted trajectories. Prime dropped 306 completed stale traces; other extra or cancelled work had different causes.

Where the trainer spent time

Minutes per update

Waiting for batchForward / backward
01530Update 1: 27.935 Minutes per updateUpdate 2: 0.00069040 Minutes per updateUpdate 3: 24.799 Minutes per updateUpdate 4: 1.8488 Minutes per updateUpdate 5: 21.670 Minutes per updateUpdate 6: 9.6783 Minutes per updateUpdate 7: 21.128 Minutes per updateUpdate 8: 3.9069 Minutes per updateUpdate 9: 19.352 Minutes per updateUpdate 10: 11.223 Minutes per updateUpdate 1: 7.9385 Minutes per updateUpdate 2: 5.5459 Minutes per updateUpdate 3: 3.8027 Minutes per updateUpdate 4: 7.5819 Minutes per updateUpdate 5: 4.5368 Minutes per updateUpdate 6: 4.7321 Minutes per updateUpdate 7: 4.5878 Minutes per updateUpdate 8: 4.8576 Minutes per updateUpdate 9: 3.9182 Minutes per updateUpdate 10: 5.0357 Minutes per update135710Optimizer update
Totals: 141.5 minutes waiting, 52.5 minutes forward/backward. Other work is excluded.

Across roughly 3 hours 23 minutes of training, the trainer waited 141.5 minutes for batches and spent 52.5 minutes on forward/backward passes. Rollout collection kept it waiting far longer than the updates themselves. Gradients and entropy showed no obvious numerical collapse, but batch reward could not tell me whether unseen-task performance improved.

What the held-out traces showed

Both policies received eight attempts on each of 23 held-out tasks. All 368 were graded, with no infrastructure errors or timeouts. Eleven tasks declined, six improved, and six tied.

How attempts ended

Attempts out of 184 · categories overlap

BaseStep 10
Truncated
104
124
Turn limit
92
108
Zero tools
4
10

Generated tokens per attempt

Mean token count

BaseStep 10
All output
20029.5
21684.4
Reasoning
10856.4
11963.7
Reasoning is included in output, not added on top.

The adapter used more reasoning tokens and reached limits more often, while tool-result counts barely changed. Zero-tool attempts rose from four to ten, even though empty Bash calls fell from 32 to 18. The parser alone did not explain that pattern.

The traces also contained ordinary code bugs. One log-parser edit destroyed message whitespace:

An edit that changed the required output
" ".join("retry  after   timeout".split())
# 'retry after timeout'

Another attempt saw an empty CSV return no quantiles instead of three zeros, then declared completion without fixing it.

Passing the tests did not mean stopping

Of 640 accepted training trajectories, 452 were truncated, including 253 of the 365 passes. The grader checks files, so an attempt can earn reward even after continuing until a limit. Two final-evaluation attempts spent two full responses without any tool action despite recovery.

I used GRPO advantages with Prime’s IPO loss, token aggregation, no GRPO length penalty, and no separate completion reward. I suspect that gave the model too little incentive to stop once its files were correct. The traces support investigating that hypothesis; they do not prove it caused the score decline.

Passes on each held-out task

Passes out of eight · descriptive task names

BaseStep 10
Streaming top-k / quantile
3
0
Session log analyzer
5
2
CSV top-k / quantiles
6
3
Integer interval set
6
4
JSON template renderer
8
6
Configuration merger
7
5
Stdin template renderer
7
6
Named interval database
8
7
Numeric-file top-k
5
4
Fixed-point file calculator
4
3
Query-log filters
6
5
Weighted-grid pathfinder
1
1
LEB128 TLV decoder
4
4
Multi-query pathfinder
5
5
Token-bucket rate limiter
2
2
WAL key-value store
0
0
Dependency topological sort
2
2
Log aggregation
5
6
CSV reconciliation
0
2
JSON reconciliation
2
4
Fixed-point expressions
1
3
Custom-varint TLV decoder
4
6
CSV-to-JSON pipeline
0
3
11 tasks declined, 6 improved, 6 tied. Each task has eight attempts per model. The downloadable evidence includes the original task IDs.

This was one small run. The paired-task bootstrap 95% interval for the score difference was approximately −13.0 to +4.35 percentage points. Base evaluation actually ran on H200 and final evaluation on H100 despite matching requests. I cannot claim a general improvement or a definitive cause for the lower score.

What the reward could establish

I used TermGrade correctness tests through Harbor. All 103 selected tasks used separate grading and cleared stale reward files; the grader received selected files, not the solver’s running processes. The shared image still defaulted to root without unprivileged Rewardkit workers, leaving a tampering gap identified through Harbor’s guidance. I found no evidence that it caused this regression.

The audit also needed debugging

My checkpoint check rejected trainer step 5 beside orchestrator step 6. Prime’s interval save records the orchestrator’s next step; both were at 10 in the final save. I fixed the check and verified metadata, model/optimizer state, scheduler progress, shard extents, and 256 finite adapter tensors. A full training resume was not tested.

A later audit ran outside Prime’s environment, missed zstandard, and searched the wrong trace directory. Using the native environment and indexed-trace reader let me keep the completed 184 base attempts and run only the missing adapter evaluation.

Supporting charts and implementation notes

These are the remaining benchmark and training measurements. They stay visible here so the main account can focus on the decisions they informed.

Inference latency, elapsed time, and memory pressure

Long prefill: request latency

Mean seconds per request

16
37.6
32
54.8
64
91.4
98
149.1
128
192.4
Includes waiting. Bar scale: 0–400 seconds.

Shared-prefix long output: request latency

Mean seconds per request

16
102.9
32
133.6
64
203.9
98
278.7
128
360.3
Includes waiting. Bar scale: 0–400 seconds.

Long prefill: elapsed time

Seconds to finish the workload

16
301.3
32
219.2
64
183.5
98
230.1
128
196.0
64 requests; startup excluded. Bar scale: 0–900 seconds.

Shared-prefix long output: elapsed time

Seconds to finish the workload

16
823.2
32
534.6
64
407.9
98
438.6
128
377.0
64 requests; startup excluded. Bar scale: 0–900 seconds.

Average GPU utilization

Percent · monitored phase

16
95.4
32
94.0
64
94.2
98
90.5
128
90.8
Includes preparation and gaps between requests. This is not KV-cache occupancy.

Observed KV-cache preemptions

Count · monitored phase

16
0
32
0
64
0
98
0
128
25
Samples can end before the last requests; zero means none observed. The 98 run was separate.

Reward and numerical checks across ten updates

Accepted training reward

Pass rate (%)

050100Update 1: 53.125 Pass rate (%)Update 2: 54.688 Pass rate (%)Update 3: 59.375 Pass rate (%)Update 4: 51.563 Pass rate (%)Update 5: 50.000 Pass rate (%)Update 6: 60.938 Pass rate (%)Update 7: 65.625 Pass rate (%)Update 8: 48.438 Pass rate (%)Update 9: 59.375 Pass rate (%)Update 10: 67.188 Pass rate (%)135710Optimizer update
64 accepted trajectories per update. This is training data, not held-out accuracy.

Gradient norm

L2 norm

00.00450.009Update 1: 0.0072937 L2 normUpdate 2: 0.0061633 L2 normUpdate 3: 0.0070925 L2 normUpdate 4: 0.0052802 L2 normUpdate 5: 0.0064736 L2 normUpdate 6: 0.0063955 L2 normUpdate 7: 0.0074596 L2 normUpdate 8: 0.0064589 L2 normUpdate 9: 0.0070944 L2 normUpdate 10: 0.0065221 L2 norm135710Optimizer update
Finite throughout the ten updates; no obvious numerical collapse here.

Token entropy

Logged mean entropy

00.0750.15Update 1: 0.10541 Logged mean entropyUpdate 2: 0.11117 Logged mean entropyUpdate 3: 0.10844 Logged mean entropyUpdate 4: 0.10038 Logged mean entropyUpdate 5: 0.11668 Logged mean entropyUpdate 6: 0.11660 Logged mean entropyUpdate 7: 0.10618 Logged mean entropyUpdate 8: 0.10826 Logged mean entropyUpdate 9: 0.12158 Logged mean entropyUpdate 10: 0.11893 Logged mean entropy135710Optimizer update
Entropy alone does not tell me whether the agent solves more tasks.

Rollout / trainer mismatch KL

Logged mean KL

00.0020.004Update 1: 0.0027642 Logged mean KLUpdate 2: 0.0029298 Logged mean KLUpdate 3: 0.0028514 Logged mean KLUpdate 4: 0.0027440 Logged mean KLUpdate 5: 0.0029465 Logged mean KLUpdate 6: 0.0030167 Logged mean KLUpdate 7: 0.0028261 Logged mean KLUpdate 8: 0.0028252 Logged mean KLUpdate 9: 0.0031205 Logged mean KLUpdate 10: 0.0031223 Logged mean KL135710Optimizer update
Probability mismatch between rollout and training paths. Not distance from the base model.

Completed stale traces dropped

Count per update

04080Update 1: 0.0000 Count per updateUpdate 2: 0.0000 Count per updateUpdate 3: 78.000 Count per updateUpdate 4: 7.0000 Count per updateUpdate 5: 67.000 Count per updateUpdate 6: 15.000 Count per updateUpdate 7: 26.000 Count per updateUpdate 8: 25.000 Count per updateUpdate 9: 64.000 Count per updateUpdate 10: 24.000 Count per update135710Optimizer update
306 in total. Separate from cancelled or other extra asynchronous work.

Mismatch KL compares rollout and training probabilities, not distance from the base model. Accepted-batch reward also changes with which tasks enter a batch.

Endpoint discovery and authentication

Two screening workers failed because the served model name was a string where a list was expected. Another router path omitted authentication during discovery. With one engine per worker, I used supported direct-engine mode and verified the advertised name, wrong-key rejection, and generation.

Code, data, and references

Inspect this run

Back to home · Teaching a language model to write music