Skip to content

feat(token-id-capture): Claude Code external-harness example - #2128

Closed
ananthsub wants to merge 9 commits into
ananthsub/tokidcap/sampling-pinfrom
ananthsub/tokidcap/example
Closed

feat(token-id-capture): Claude Code external-harness example#2128
ananthsub wants to merge 9 commits into
ananthsub/tokidcap/sampling-pinfrom
ananthsub/tokidcap/example

Conversation

@ananthsub

@ananthsub ananthsub commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Claude Code external-agent example

Top PR in the stack (base: #2127).

A runnable end-to-end example that exercises the whole stack, plus a scoring fix the example needs.

What it adds

  • A claude_code_agent config, pointed at the Gym model server, with capture turned on. This is the reference setup: the agent calls the model server, the server records the token ids, and the rollout is reassembled for training.
  • A fallback in reasoning_gym answer extraction: when the model's output has no <answer> tag and no \boxed{}, use the last quoted string as the answer. A conversational agent like Claude Code usually states its final answer as the last quoted phrase; scoring the whole reasoning text instead can pick up an intermediate guess and mark a correct answer wrong.

Why it's here

This is the concrete target for the stack: reasoning_gym with Claude Code, capture on, producing rollouts that carry token ids and can be trained on. It's also what motivates the scoring fix — without it, correct answers scored zero and the reward didn't move.

Tests

Verified in the 2-GPU Megatron GRPO run: reward moved and the run stayed on-policy (generation KL ~0.04). The reasoning_gym change is a small fallback in _extract_answer_from_response.

Stack

One commit per PR, each based on the previous branch (bottom of the stack targets main):

  1. feat(token-id-capture): capture training tokens from external harnesses #2124 capture core — store, sink, read route
  2. feat(token-id-capture): chain a rollout's calls into one response #2125 trajectory builder and consumer
  3. feat(token-id-capture): deliver rebuilt trajectories safely #2126 uniform delivery, per-agent scoping, retention
  4. feat(token-id-capture): keep harness side calls out of the trajectory #2179 keep harness side calls out of the trajectory
  5. feat(token-id-capture): resolve each call's parent at request time #2180 resolve each call's parent at request time
  6. feat(vllm-model): supply the previous call's exact training tokens #2181 supply the engine the previous call's exact tokens
  7. feat(token-id-capture): add an optional bearer token to the read route #2182 require a bearer token on the token read route
  8. feat(vllm-model): on-policy sampling pin via sampling_overrides #2183 on-policy sampling pin
  9. feat(token-id-capture): Claude Code external-harness example #2128 Claude Code external-harness example (this PR)

All nine are drafts. #2183 supersedes #2127, which could not be retargeted after
the stack grew (GitHub forbids changing the base of a PR that is part of a stack).

@copy-pr-bot

copy-pr-bot Bot commented Jul 23, 2026

Copy link
Copy Markdown

Auto-sync is disabled for draft pull requests in this repository. Workflows must be run manually.

Contributors can view more details about this message here.

@copy-pr-bot

copy-pr-bot Bot commented Jul 29, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

ananthsub and others added 5 commits July 29, 2026 00:10
An external agent harness returns no token ids, and the ids do not survive to
the client for a streamed response or one translated to Anthropic Messages. The
model server holds the assembled response with the ids on it for a moment
before either of those happens, which is the one point that covers every
dialect.

nemo_gym/token_id_capture/ records one TokenEntry per correlated model call:
prompt ids, generated ids, one log prob per generated token, and the assistant
text and tool calls, since a trainer reads the text for its own penalties.
Records go to <rollout_id>.tokens.jsonl, per-file flock and fsync, awaited so a
record is durable before the call returns. Nothing is added to the client
response.

TokenSink and TokenSource are the write and read seams. Gym owns the record
shape and the capture code; a framework supplies the implementation and runs it
where its tokens are produced, so sink placement is a deployment choice rather
than a fork in the design. The package is a leaf -- no fastapi, ray, uvicorn,
aiohttp or torch -- so a framework's inference worker can import it; a
subprocess test enforces that.

TokenEntry also carries optional parent_call_id, cum_len and digest. cum_len and
digest are stamped at capture; parent_call_id stays null until the model server
can resolve a parent.

A capture failure is logged and also writes a <rollout_id>.tokens.incomplete
marker, so a rollout that lost a call is distinguishable from a complete one.
Capture stays best-effort: a bad payload must not break the harness run.

Signed-off-by: Ananth Subramaniam <ansubramania@nvidia.com>
The builder is a pure function over a rollout's TokenEntry records. per_request
emits one sequence per call. prefix_merging chains calls by the token-prefix
relationship, parenting each call to the earlier call whose prompt-plus-
generation is the longest prefix of this call's prompt, so an append-only
multi-turn rollout becomes one chain. A prompt that extends nothing starts a new
root, which is what a compacted or rewritten context looks like. Both are
order-independent.

Loss masks follow provenance: generated tokens are trained with their captured
log probs, and anything re-fed into a prompt is not. The projection re-emits
contiguous Responses items carrying both content and token ids, which is what
NeMo-RL's postprocess already consumes.

Main-chain selection is by generated-token mass across all roots, not by the
first root. Entries are processed in increasing prompt length, so the first root
is whichever root has the shortest prompt; a rollout's own first call is large
because of the harness system prompt, so an auxiliary short-prompt call would be
selected instead and the rollout dropped at delivery without an error.

Recorded parent links are used when present and verified by digest rather than
trusted, falling back to prefix inference on mismatch. A retry of the final call
is reported unresolved rather than tie-broken, since nothing can say which
generation the client received. Chain count, quarantined fraction and delivered
fraction are returned rather than discarded, and a malformed capture returns an
unbuilt result instead of raising into the caller's loop.

Signed-off-by: Ananth Subramaniam <ansubramania@nvidia.com>
…med records

Rollout collection replaces response.output with the merged, contiguous items
for agents that opted into capture, so NeMo-RL reads response.output the same
way for native and external-harness rollouts. Native agents are excluded by the
per-agent opt-in: they already return exact ids inline, and a rebuild could
differ from what the model server returned.

Retention runs in both directions. Consumed records are deleted once folded into
response.output (NG_KEEP_TOKCAP retains them), and stale records are cleared
before dispatch. Both are needed because rollout ids are deterministic and the
store appends, so a rerun would otherwise stitch a previous attempt's calls
together with this one's.

The build's counts ride the record under _ng_token_capture. Without them a
rollout that trained on one of five calls is indistinguishable from one that
trained on all five. A rollout captured incompletely, or whose final call was
retried ambiguously, is marked for masking and warns.

Signed-off-by: Ananth Subramaniam <ansubramania@nvidia.com>
Claude Code makes model calls that are not part of the rollout: it generates a
conversation title and probes quota. They reach the model server on the same
rollout-prefixed URL and get captured, and because they are genuine policy
output -- real token ids, real log probs -- nothing downstream can tell they do
not belong. Training on them optimizes the policy to write conversation titles
under the rollout's reward.

The record now keeps what the *harness* asked for (requested_model, has_tools),
read off the parsed request body at the handler rather than by touching the body
again in middleware. That is the signal, because a harness asks for a small
model for these calls even though the server serves one model.

Classification uses two signals and needs no harness-specific code in the core:
an optional explicit pattern list for deployments that know their harness, and
self-calibration -- whichever model generated the most tokens in a rollout is
the policy model, and calls asking for a different one are side calls. Records
written before this field existed carry an empty requested_model and are all
kept, so nothing changes for them.

Excluded calls are reported (side_calls_excluded) rather than silently dropped,
and a rollout whose calls were *all* side calls is masked instead of yielding an
empty trajectory.

This is the second half of the title-call problem. The first was structural: a
short side call became the main chain and the real rollout was dropped, fixed by
selecting on generated-token mass. Even with the right chain selected, the side
call would still have been stitched in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Ananth Subramaniam <ansubramania@nvidia.com>
The builder infers lineage after the fact, by finding the earlier call whose
token sequence is the longest prefix of this call's prompt. That cannot do two
things. It cannot resolve a retry: capture records a call once the response is
assembled, including one the client never received, so a retry leaves two
records with the same prompt and different generations, and both are equally
valid children. And it cannot run before the call, which is where supplying the
engine an exact prefix has to happen.

This resolves the parent at request time using only what the harness already
sends. A harness must echo the conversation to continue it, so the assistant
turns in a request are the ones we produced; hashing them in order identifies
the call that produced the last one. Nothing is added to the wire and nothing
depends on the harness preserving a field we invented.

Tool-call arguments are canonicalized (sorted-key JSON) for comparison only,
because harnesses re-serialize them between turns -- compact one turn, pretty
the next. Without that every tool-using turn would miss. The model's original
argument string is what stays in the record.

The index is a map keyed by call, not a running cursor, so it is a tree: two
sub-agents branching from one parent both resolve to that parent and both would
get the same prefix. A cursor would hand the second branch a prefix containing
the first branch's generation, which the splice would apply without complaint.
Entries are added and never mutated, so concurrent sub-agents cannot corrupt
each other's lineage.

Three cases deliberately return no parent rather than a guess: a new or
rewritten conversation, an ambiguous match (two recorded calls with
byte-identical output), and an evicted rollout. Each falls back to prefix
inference, which is what happens today.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Ananth Subramaniam <ansubramania@nvidia.com>
ananthsub and others added 4 commits July 29, 2026 02:18
Without this the engine builds every prompt by re-rendering the conversation
through the chat template. Re-tokenizing an assistant turn can produce a
different split than the one sampled; a tool-call parser truncates the turn at
the tool call and discards anything the model generated after it; and for a
reasoning model the template drops earlier thinking entirely. Any of these means
the new prompt does not extend the previous prompt-plus-generation, the builder
cannot chain, and only the first call is trained on.

Supplying the parent's cumulative ids as required_prefix_token_ids makes
NeMo-RL's splice keep them verbatim and append only the newly rendered tail.

The harness does not send this field and could not. Gym constructs the outbound
engine request itself, so it is injected in
_preprocess_chat_completion_create_params next to the sampling pin. The inbound
request supplies identity; the outbound request supplies payload.

The field also has to ride the separate /tokenize call that recovers
prompt_token_ids. Generation applies the supplied prefix, so without it there
the recorded prompt is a plain re-render that does not extend the previous call
-- the chain looks broken even though the engine generated from the right
tokens, and the record is what training consumes. NeMo-RL accepts the field on
that endpoint for this reason.

The splice applies whatever it is given without checking that it belongs to this
conversation, so supply fires only on a unique, verified parent and otherwise
forwards the request untouched. Each record carries prefix_supplied and the
server tracks a supplied/eligible ratio, so a run can be audited afterwards
rather than inferred from whether chains happen to be contiguous.

Measured on a 2-step GRPO smoke, Qwen2.5-3B with a tool-calling task: 1/8
contiguous parent-child links with supply off, 6/6 with it on, and generation KL
error 0.054 -> 0.023.

Off by default; it needs a backend that honours the field.

Signed-off-by: Ananth Subramaniam <ansubramania@nvidia.com>
The route that serves a rollout's raw training tokens is registered on the same
app the harness calls to generate. That is acceptable inside a trusted cluster.
It is not acceptable once the harness runs in a sandbox whose only egress is
this server, because the harness could read its own training data -- or another
rollout's.

token_id_capture_read_token requires a bearer token on the route, compared in
constant time. When it is unset the route stays open and warns once, so existing
deployments keep working and the gap is visible rather than silent.

This is the one piece of sandboxing that has to land before the sandbox work,
not with it. Everything else about capture is unaffected by sandboxing: capture
happens in the model server, outside the sandbox, and the sandbox only ever sees
text.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Ananth Subramaniam <ansubramania@nvidia.com>
Add a framework-agnostic sampling pin to vllm_model: sampling_overrides forces
temperature/top_p on every chat request, read from generic policy_generation_*
keys with on-policy defaults. This keeps an external harness's rollouts
on-policy for training without Gym holding any framework-specific knowledge —
the training framework publishes its sampling into the generic keys.

Signed-off-by: Ananth Subramaniam <ansubramania@nvidia.com>
Turns on token_id_capture for the claude_code_agent configs and restricts the
harness to the Bash tool. Claude Code's system prompt is dominated by tool
definitions and each allowed tool adds its schema to every call, so restricting
the tool set lowers the fixed prompt cost; reasoning_gym only needs Bash, which
its own system prompt already says.

Also adds a last-quoted-string fallback to reasoning_gym answer extraction, so a
conversational reply whose final answer is quoted is scored instead of an
intermediate value pulled from the reasoning text.

Signed-off-by: Ananth Subramaniam <ansubramania@nvidia.com>
@ananthsub
ananthsub force-pushed the ananthsub/tokidcap/sampling-pin branch from 7342d4e to f507b6f Compare July 29, 2026 09:19
@ananthsub
ananthsub force-pushed the ananthsub/tokidcap/example branch from 16d8b00 to 3860daf Compare July 29, 2026 09:19
@ananthsub
ananthsub force-pushed the ananthsub/tokidcap/sampling-pin branch from f507b6f to 190f25c Compare July 29, 2026 12:38
@ananthsub

Copy link
Copy Markdown
Contributor Author

Closing: six of the ten files here were scratch Python that the rollouts wrote during experiments, not intended content.

The one useful change — token_id_capture on the Claude Code agent config — has moved into #2124, set to false.

@ananthsub ananthsub closed this Jul 29, 2026
@ananthsub
ananthsub deleted the ananthsub/tokidcap/example branch July 29, 2026 14:03
adil-a added a commit that referenced this pull request Jul 30, 2026
…gent services (#2163)

Part of the external-agent-integration epic #1396. Supersedes the
collector-side `agent_url` approach proposed in #2006 (closed unmerged
in favor of this PR).

## What

An agent server, `responses_api_agents/remote_agent/`, that drives an
agent service running **outside Gym's process tree** (your own repo,
your own infrastructure) through Gym's tool loop. The remote service
implements one endpoint **compliant with the OpenAI `/v1/responses`
contract**, and the two servers compose as Responses-speaking agents:

`POST {agent_base_url}/v1/responses` — receives the conversation so far
(the row's `responses_create_params` with accumulated output and tool
results appended to `input`) and returns a Responses API object:
unpaired `function_call` items to ask Gym to execute environment tools,
paired call+output items as records of its own internal tools (passed
through untouched), or a final assistant message to finish.

Gym owns everything else: it seeds the session and holds its cookies,
executes the asked-for tools against the resources server, appends
results and calls the service again, validates each reply against the
Responses API schema (failing the rollout on mismatch), verifies, and
returns the verify response from `/run`. **The resources server is never
exposed to the service** — tool execution, session cookies, and
`verifier_metadata` all stay inside Gym. The service's own cookies are
round-tripped per call so it can keep per-rollout state; the only
network direction is Gym → service. To the collector it is a normal
named agent — resume, aggregation, and profiling work unchanged.

The loop is `simple_agent`'s `responses()` with two marked in-loop
divergences — the model hop is a hardened POST to `agent_base_url`, and
only *unpaired* function_calls are executed (a call the service already
answered itself is its own record, not an ask) — plus the never-raise
`/run` contract that turns transport/validation errors into failure
rows, and one deliberate cookie difference: the service's own cookies
are round-tripped to it but stay out of the outgoing Set-Cookie (they
are its private session, not Gym's). `run()` mirrors `simple_agent`'s
seed → self-post `/v1/responses` → verify.

## Why a server instead of collector-side `agent_url` (#2006)

- The token-capture/training stack (#2124#2128, unmerged) gates
participation by the agent's **name in Gym's config**; a url-dispatched
agent has no config entry and is excluded by construction. A named
RemoteAgent is compatible by design — note this PR contains no capture
wiring; a wiring example is a follow-up once that stack lands.
- Failure handling, validation, timeouts, and session plumbing live in
the agent-server layer — this server's `/run` implementation plus the
shared SimpleServer/ServerClient plumbing — instead of being
reimplemented inside the shared collection loop.
- `/run` responsibilities stay in the agent-server layer; the collector
stays agent-agnostic.
- Same pattern as every other harness integration (claude_code_agent,
codex, …).

## Failure contract

Failures never raise out of `/run`: remote endpoint down (3 total
connection attempts, `ClientOSError`/`ServerDisconnectedError` only;
timeouts and all other errors fail after a single attempt), per-call and
whole-rollout timeouts, malformed/interrupted replies, seed/verify
errors, and internal bugs all become reward-0 sentinel verify-responses
(`_ng_failure_class="remote_agent_error"`) that rollout collection
routes to the failures sidecar and retries on resume — non-terminal
failures only, up to `NEMO_GYM_MAX_ROLLOUT_ATTEMPTS` (default 3). An
invalid Responses object from the service is terminal (a schema bug will
not fix itself on retry); the terminal flag crosses the HTTP self-post
boundary by exception name. Reused rollout/failures JSONL rows carrying
stale result keys are sanitized rather than crashing or leaking routing
flags. Tool-level errors are NOT rollout failures: an unknown tool name
or malformed arguments come back to the service as that call's
`function_call_output`, matching `simple_agent`'s semantics.

## Testing

- 51 offline tests (mocked ServerClient + mocked aiohttp seam; the
`/v1/responses` self-post is routed into the real `responses()` with the
exception middleware emulated): config validation, loop mechanics
(multi-turn tool execution, paired-call pass-through, unknown-tool
feedback, malformed-arguments feedback, max_steps, service-cookie
round-trip, usage accumulation), every transport failure mode →
sentinel, terminal classification across the route boundary, semaphore
bounds incl. release-on-failure, run-wallclock-after-semaphore
semantics, aggregate proxy + bound, route-level serialization (HTTP 200,
never 500).
- **Stateful E2E in-suite**: the real `example_session_state_mgmt`
counter server in-process; the service returns unpaired tool asks, GYM
executes them on the seeded session, reward 1.0 through the real
verifier — and the test asserts the service was fed the counter value
Gym read back.
- **Collector round-trip in-suite**: real
`RolloutCollectionHelper.run_from_config` driving this agent — successes
to the main JSONL, sentinel rows to the failures sidecar.
- **Live E2E, off-host, real agent**: a containerized service (own
network namespace) wrapping the **Claude CLI** (opus via an internal
gateway) as the decision-maker, `agent_base_url` pointing at the
container's bridge IP; the counter resources server stayed on loopback
with a random port — unreachable from the container by construction.
**5/5 rollouts reward 1.0**, trajectories reading `function_call →
function_call_output → … → message`, real token usage accumulated across
loop turns (~8.3k mean/rollout). The only network path was Gym →
container.

## Follow-ups

- run-mode (`/run`-exposing self-scoring services) as a config mode on
this server
- token-capture wiring example once #2124#2128 land (the service would
route its model calls through Gym's model server at the per-rollout
prefixed URL)
- per review discussion: opt-in bounded-retry/timeout kwargs on core
`request()` (then this server's transport collapses onto core), and two
additive core helpers with existing adopters — a failure-row builder
(stirrup/pinchbench hand-roll the same) and an aggregate-metrics proxy
helper (eight agents hand-roll it)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: adil-a <adil.asif2000@hotmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
OlegSudakov pushed a commit to OlegSudakov/Gym that referenced this pull request Aug 7, 2026
…gent services (NVIDIA-NeMo#2163)

Part of the external-agent-integration epic NVIDIA-NeMo#1396. Supersedes the
collector-side `agent_url` approach proposed in NVIDIA-NeMo#2006 (closed unmerged
in favor of this PR).

## What

An agent server, `responses_api_agents/remote_agent/`, that drives an
agent service running **outside Gym's process tree** (your own repo,
your own infrastructure) through Gym's tool loop. The remote service
implements one endpoint **compliant with the OpenAI `/v1/responses`
contract**, and the two servers compose as Responses-speaking agents:

`POST {agent_base_url}/v1/responses` — receives the conversation so far
(the row's `responses_create_params` with accumulated output and tool
results appended to `input`) and returns a Responses API object:
unpaired `function_call` items to ask Gym to execute environment tools,
paired call+output items as records of its own internal tools (passed
through untouched), or a final assistant message to finish.

Gym owns everything else: it seeds the session and holds its cookies,
executes the asked-for tools against the resources server, appends
results and calls the service again, validates each reply against the
Responses API schema (failing the rollout on mismatch), verifies, and
returns the verify response from `/run`. **The resources server is never
exposed to the service** — tool execution, session cookies, and
`verifier_metadata` all stay inside Gym. The service's own cookies are
round-tripped per call so it can keep per-rollout state; the only
network direction is Gym → service. To the collector it is a normal
named agent — resume, aggregation, and profiling work unchanged.

The loop is `simple_agent`'s `responses()` with two marked in-loop
divergences — the model hop is a hardened POST to `agent_base_url`, and
only *unpaired* function_calls are executed (a call the service already
answered itself is its own record, not an ask) — plus the never-raise
`/run` contract that turns transport/validation errors into failure
rows, and one deliberate cookie difference: the service's own cookies
are round-tripped to it but stay out of the outgoing Set-Cookie (they
are its private session, not Gym's). `run()` mirrors `simple_agent`'s
seed → self-post `/v1/responses` → verify.

## Why a server instead of collector-side `agent_url` (NVIDIA-NeMo#2006)

- The token-capture/training stack (NVIDIA-NeMo#2124NVIDIA-NeMo#2128, unmerged) gates
participation by the agent's **name in Gym's config**; a url-dispatched
agent has no config entry and is excluded by construction. A named
RemoteAgent is compatible by design — note this PR contains no capture
wiring; a wiring example is a follow-up once that stack lands.
- Failure handling, validation, timeouts, and session plumbing live in
the agent-server layer — this server's `/run` implementation plus the
shared SimpleServer/ServerClient plumbing — instead of being
reimplemented inside the shared collection loop.
- `/run` responsibilities stay in the agent-server layer; the collector
stays agent-agnostic.
- Same pattern as every other harness integration (claude_code_agent,
codex, …).

## Failure contract

Failures never raise out of `/run`: remote endpoint down (3 total
connection attempts, `ClientOSError`/`ServerDisconnectedError` only;
timeouts and all other errors fail after a single attempt), per-call and
whole-rollout timeouts, malformed/interrupted replies, seed/verify
errors, and internal bugs all become reward-0 sentinel verify-responses
(`_ng_failure_class="remote_agent_error"`) that rollout collection
routes to the failures sidecar and retries on resume — non-terminal
failures only, up to `NEMO_GYM_MAX_ROLLOUT_ATTEMPTS` (default 3). An
invalid Responses object from the service is terminal (a schema bug will
not fix itself on retry); the terminal flag crosses the HTTP self-post
boundary by exception name. Reused rollout/failures JSONL rows carrying
stale result keys are sanitized rather than crashing or leaking routing
flags. Tool-level errors are NOT rollout failures: an unknown tool name
or malformed arguments come back to the service as that call's
`function_call_output`, matching `simple_agent`'s semantics.

## Testing

- 51 offline tests (mocked ServerClient + mocked aiohttp seam; the
`/v1/responses` self-post is routed into the real `responses()` with the
exception middleware emulated): config validation, loop mechanics
(multi-turn tool execution, paired-call pass-through, unknown-tool
feedback, malformed-arguments feedback, max_steps, service-cookie
round-trip, usage accumulation), every transport failure mode →
sentinel, terminal classification across the route boundary, semaphore
bounds incl. release-on-failure, run-wallclock-after-semaphore
semantics, aggregate proxy + bound, route-level serialization (HTTP 200,
never 500).
- **Stateful E2E in-suite**: the real `example_session_state_mgmt`
counter server in-process; the service returns unpaired tool asks, GYM
executes them on the seeded session, reward 1.0 through the real
verifier — and the test asserts the service was fed the counter value
Gym read back.
- **Collector round-trip in-suite**: real
`RolloutCollectionHelper.run_from_config` driving this agent — successes
to the main JSONL, sentinel rows to the failures sidecar.
- **Live E2E, off-host, real agent**: a containerized service (own
network namespace) wrapping the **Claude CLI** (opus via an internal
gateway) as the decision-maker, `agent_base_url` pointing at the
container's bridge IP; the counter resources server stayed on loopback
with a random port — unreachable from the container by construction.
**5/5 rollouts reward 1.0**, trajectories reading `function_call →
function_call_output → … → message`, real token usage accumulated across
loop turns (~8.3k mean/rollout). The only network path was Gym →
container.

## Follow-ups

- run-mode (`/run`-exposing self-scoring services) as a config mode on
this server
- token-capture wiring example once NVIDIA-NeMo#2124NVIDIA-NeMo#2128 land (the service would
route its model calls through Gym's model server at the per-rollout
prefixed URL)
- per review discussion: opt-in bounded-retry/timeout kwargs on core
`request()` (then this server's transport collapses onto core), and two
additive core helpers with existing adopters — a failure-row builder
(stirrup/pinchbench hand-roll the same) and an aggregate-metrics proxy
helper (eight agents hand-roll it)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: adil-a <adil.asif2000@hotmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant