← Home
Sections

Training a terminal agent with reinforcement learning

October 2026 · Experiment notes

I trained Qwen3.5-9B to solve terminal tasks. The run finished, but the adapter passed fewer held-out tasks than the base model. Here is what I built and what went wrong.

TL;DR

  • One trainer, two inference GPUs. I trained Qwen3.5-9B with LoRA for ten updates using Prime RL on three H100s in Modal.
  • 128 concurrent rollouts. I configured 64 per inference GPU, with eight attempts per task and 64 accepted trajectories per training update.
  • Tasks with a 25–75% screening pass rate. I kept tasks with two to six passes out of eight attempts: 103 total, split into 80 training and 23 held-out tasks.
  • About 89% lower sandbox request floor. I reduced requests from 1 CPU and 2 GiB to 0.125 CPU and 0.125 GiB. Sandboxes could still burst above those requests, so this is not an 89% saving on the total bill.
  • 60 seconds per Bash command. Commands that exceeded the limit returned partial output so the agent could continue. Compaction also kept the original task alongside its latest summary.

Held-out result: 91/184 passes for the base model, 83/184 after training. More attempts reached turn or output limits. This run did not demonstrate an improvement.

I wanted to let the model work in a terminal, grade its files, and train on those attempts. First I had to get that loop working.

The setup

I used Qwen3.5-9B with thinking enabled, Bash, and an edit tool. Prime RL handled training and asynchronous rollouts; Verifiers ran the agent loop; Harbor graded the resulting files in a separate environment; Modal supplied the GPUs and sandboxes.

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 evaluationBase evaluation · 23 held-out tasksSave 184 attempts for comparison02 · 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 metadataHeld-out base vs. step 1023 × 8 each · 91 vs. 83 passesPublic 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 evaluationBase evaluation · 23 held-out tasksSave 184 attempts for comparison02 · 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 metadataHeld-out base vs. step 1023 × 8 each · 91 vs. 83 passesPublic research artifactsHugging Face: traces + adapter · GitHub
Rollouts finish at different times while training and weight broadcasts overlap. The held-out branch never feeds training. The workflow can collect and reuse a base evaluation before training; in this experiment, both held-out arms were collected afterward using their respective weights. Motion only shows the direction of weight updates.

Following TermGrade’s selection approach, I screened 216 tasks with eight attempts each. The 103 tasks with two to six passes became an 80/23 training/held-out split. Difficulty depends on the model and harness: TermGrade evaluated with Terminus-2, while I used Bash.

I pinned Git commits and file hashes, checked that a no-op failed and the reference solution passed, and fixed Harbor packaging before using GPUs. Twelve retained Dockerfiles had interfered with native environment upload; removing those active files restored the intended setup path.

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.

Getting inference and training to agree

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 had no evidence that Bash solved more tasks.

I broke a migration by replacing bundled dependencies with older releases. The client could no longer decode the packed log-probabilities and sampling masks. Replaying one response through both decoders exposed the mismatch. I restored compatible versions. I checked a full tool-call exchange for matching token prefixes, finite log-probabilities, and sampled tokens inside their recorded support.

A full conversation rendered, but a system-only prefix failed with “No user query found in messages.” I used Prime’s Qwen3.5 renderer and checked prefixes and masks as the conversation grew.

Two screening workers later failed discovery because I supplied the served model name as a string instead of a list. Another router path omitted authentication internally. With one engine per worker, I used supported direct-engine mode, kept authentication enabled, and checked the advertised name, wrong-key rejection, and generation.

Commands that kept waiting

Some terminal commands and sandboxes had stayed stuck for hours. When I checked the executor, it allowed 3,600 seconds per command. Shortening the 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.Mr. Bean’s Holiday waiting meme · image source

My Bash patch closes stdin, starts a process group, and kills that group after 60 seconds, returning partial output. Killing only Bash can leave children running or holding output pipes open, so the follow-up read is bounded too.

The Linux check stopped at 60.02 seconds, passed six process tests, and let the next command run. Legitimate long builds get stopped too. File edits remain, and detached processes still need sandbox cleanup. I could not trace every earlier wait to the same cause.

Paying less while sandboxes wait

Sandboxes often sat idle while the model generated tokens. I reduced each request from one CPU and 2 GiB RAM to 0.125 CPU and 0.125 GiB. These are uncapped requests: one earlier sandbox 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 CPU/memory usage. The request floor fell about 89%; busy tasks still pay for their usage, and lower requests guarantee less capacity on a busy host. GPU time and other services are additional.

These estimates use sandbox rates, three times function rates: $0.00003942/core-second and $0.00000667/GiB-second, checked against pricing on October 31, 2026. They are not measured total-bill savings.

Keeping the task, then getting back to work

Compaction rebuilt the prompt from system instructions and a generated summary, potentially dropping the exact task. I changed Verifiers to retain the original task plus the latest summary. Repeated compaction replaces the previous summary instead of accumulating summaries. CPU checks confirmed that the task survived. A bad summary or an earlier coding mistake can still carry through.

Some attempts spent all 8,192 output tokens thinking without calling a tool. I kept thinking enabled and added one corrective request per episode, using mini-SWE-agent’s output-limit feedback. The unfinished response stays in the trace but is omitted from the next prompt. I kept Bash’s timeout and compaction behavior rather than switching adapters.

The recovery comparison finished 9/16 with recovery and 9/16 without, while recovery used 62% more output tokens. Two final-evaluation attempts still consumed two full responses without taking any tool action.

Choosing inference concurrency

I tested 16, 32, 64, 98, and 128 concurrent requests on one H100 with two fixed workloads: long uncached prefill and shared-prefix long output.

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.

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.

One H100, frozen weights. At each concurrency, both workloads ran together with 64 requests each. Long prefill used about 16k input and 2,048 output tokens. Long output used a shared 24k prefix plus 4k input and 8,192 output tokens. These are synthetic serving measurements.

Concurrency 128 won synthetic long-output throughput, but requests took longer and it recorded 25 KV-cache preemptions. The separate 98 run was slower than 64 despite zero preemptions. I chose 64 per inference GPU for real rollouts, 128 globally. I chose for usable training batches, not token throughput alone.

What reached the trainer

Prime keeps collecting attempts while the trainer updates the model and sends out new weights. An attempt can start before an update and finish after it. I allowed one off-policy step. For GRPO, I grouped eight attempts on the same task. If all eight get the same reward, that group gives no reward difference to learn from.

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.

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.

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.

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.

The trainer waited 141.5 minutes for batches and spent 52.5 minutes on forward/backward passes during a roughly 3-hour-23-minute training window. Gradients and entropy showed no obvious numerical collapse. Mismatch KL compares rollout and training probabilities, not distance from the base model. Batch reward also depends on which tasks make it into the batch, so I used held-out tasks to check whether training helped.

Fixing my own checks

My check rejected trainer step 5 beside orchestrator step 6. I had read the convention wrong: Prime’s interval save records the orchestrator’s next step. Both were at 10 in the final save. After fixing my check, I verified the metadata, model and optimizer state, scheduler progress, and shard extents. The adapter had 256 finite tensors, though I did not test a full training resume.

After 184 base evaluations completed, my audit failed because it ran outside Prime’s virtual environment without zstandard and searched the wrong directory. I switched to the native environment and official indexed-trace reader, tested that path on CPU, and continued only the adapter evaluation. The completed base results stayed untouched.

The model passed fewer held-out tasks

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

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.

Passes on each held-out task

Passes out of eight · anonymized task labels

BaseStep 10
T01
3
0
T02
5
2
T03
6
3
T04
6
4
T05
8
6
T06
7
5
T07
7
6
T08
8
7
T09
5
4
T10
4
3
T11
6
5
T12
1
1
T13
4
4
T14
5
5
T15
2
2
T16
0
0
T17
2
2
T18
5
6
T19
0
2
T20
2
4
T21
1
3
T22
4
6
T23
0
3
11 tasks declined, 6 improved, 6 tied. Each task has exactly eight attempts for each model.

Eleven tasks declined, six improved, and six tied. With eight attempts per task, the paired-task bootstrap interval was wide: approximately −13.0 to +4.35 percentage points. The actual base/final GPUs were H200/H100 despite matching requests. I cannot claim an improvement from this run.

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 spent more tokens reasoning and hit limits more often, while the number of tool results barely changed. Zero-tool attempts rose from four to ten, while empty Bash calls fell from 32 to 18. That gave me no reason to blame the parser alone.

The traces also showed ordinary coding failures. One log-parser edit collapsed message whitespace:

A successful edit can still break the task
" ".join("retry  after   timeout".split())
# 'retry after timeout'

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

Why did it keep going?

Of 640 accepted training trajectories, 452 were truncated, including 253 of the 365 passing trajectories. The grader checks the workspace, so correct files can earn reward even if the agent keeps going until a limit.

I used GRPO advantages with Prime’s IPO loss, token aggregation, no GRPO length penalty, and no separate completion reward. I suspect the model had too little incentive to finish once the files were correct. That would explain some of the continued reasoning, but these traces do not prove it caused the regression.

What the reward actually checked

I used TermGrade correctness tests through Harbor, with local packaging and dependency-error checks. The separate grader received selected files from the solver. Its running processes did not carry over. A pass still only tells me that those tests passed.

All 103 tasks used separate grading and cleared stale reward files. However, the shared image defaulted to root without unprivileged Rewardkit workers, leaving submitted code able to tamper with the grader. Harbor’s guidance helped identify that hardening gap. I found no evidence it caused this regression.

References

Back to home · Teaching a language model to write music