Skip to content

feat(token-id-capture): capture training tokens from external harnesses - #2124

Merged
ananthsub merged 12 commits into
mainfrom
ananthsub/tokidcap/capture-core
Aug 21, 2026
Merged

feat(token-id-capture): capture training tokens from external harnesses#2124
ananthsub merged 12 commits into
mainfrom
ananthsub/tokidcap/capture-core

Conversation

@ananthsub

@ananthsub ananthsub commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Records the exact prompt token IDs, generated token IDs, and generation log probabilities for model calls made by an external agent harness.

Control and data flow

sequenceDiagram
    participant RC as Rollout collection
    participant AS as Agent server
    participant H as External harness
    participant MW as Model middleware
    participant MS as Model route
    participant TS as TokenSink

    RC->>AS: /run with rollout identity
    AS->>AS: select static agent capture or all_agents
    AS->>H: model URL with /ng-rollout/id/training-token-capture
    H->>MW: model request
    MW->>MW: mint model_call_id and CaptureContext
    MW->>MS: normalized model request
    MS->>MS: assemble exact token arrays
    MS->>TS: await put(TokenEntry)
    alt capture fails or arrays are incomplete
        MS->>TS: await mark_incomplete(rollout_id, model_call_id)
    end
    MS-->>H: dialect response or stream
Loading

Capture happens before dialect conversion or stream synthesis can discard token fields. The write is awaited before the model response returns.

Summary

  • Separates run-level infrastructure enablement from static per-agent capture selection; all_agents selects every configured agent for a training run.
  • Encodes selected training intent in the explicit /training-token-capture path, independently of rollout correlation and evaluation observability.
  • Defines framework-neutral async TokenSink and TokenSource contracts with durable incomplete state, atomic frozen snapshots, lifecycle hooks, and versioned conditional retirement.
  • Keeps a frozen tombstone after retirement so a late writer cannot resurrect a consumed attempt; explicit pre-dispatch cleanup starts the next attempt.
  • Uses durable call digests and tail reconciliation so the common append path does not reread or reserialize prior token arrays.
  • Rejects malformed or conflicting token records and closes owned endpoints through the app lifespan.

Gym imports no training-framework data plane. A framework configures sink and lineage proxy factories in Gym model-server workers and constructs its source in the rollout-consumer process.

Stack base. Followed by #2125.

@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.

@ananthsub ananthsub changed the title ananthsub/tokidcap/capture core feat(token-id-capture): capture core — store, sink, read route Jul 23, 2026
@ananthsub ananthsub changed the title feat(token-id-capture): capture core — store, sink, read route (feat) external agent harness training support: token id capture core Jul 23, 2026
@ananthsub
ananthsub marked this pull request as ready for review July 23, 2026 16:18
@github-actions github-actions Bot added the sla:review-overdue Review response is over the one-business-day SLA label Jul 24, 2026
@anwithk anwithk linked an issue Jul 27, 2026 that may be closed by this pull request
13 tasks
@ananthsub
ananthsub force-pushed the ananthsub/tokidcap/capture-core branch from 32b555f to d0a5092 Compare July 29, 2026 06:55
@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
ananthsub force-pushed the ananthsub/tokidcap/capture-core branch from d0a5092 to 767c488 Compare July 29, 2026 07:12
@ananthsub
ananthsub marked this pull request as draft July 29, 2026 07:12
@github-actions github-actions Bot removed the sla:review-overdue Review response is over the one-business-day SLA label Jul 29, 2026
ananthsub added a commit to ananthsub/RL that referenced this pull request Jul 29, 2026
An agent harness we do not control, such as the Claude Code CLI, returns a
transcript with no token ids. Training on its rollouts needs the exact ids and
log probabilities the policy sampled, so Gym captures them at the model server
and rebuilds each rollout's model calls into one contiguous Responses payload.
This is the NeMo-RL side of that: correlate the rollouts, read the rebuilt
response back, and report what the rebuild kept.

In NemoGymEnvironment:

- Stamp each rollout with a correlation id before dispatch, so the harness's
  model calls can be attributed to the rollout that produced them. Ids are
  derived per shard, so two actors cannot mint the same one.
- After each row is yielded, replace response.output with the rebuilt items when
  the producing agent opted into capture. Agents that already carry token ids
  inline are left alone: replacing them with a reconstruction would silently
  train on the reconstruction wherever the two differ.
- Accumulate the per-rollout capture metrics across the stream and emit them
  with the timing metrics, since the generator has no end-of-loop.
- Retire a rollout's records once they have been read.

Failure handling follows the same rule throughout: one malformed capture must
degrade its own sample, never the batch. A failed rebuild keeps its records as
evidence and masks the sample rather than training on a partial rollout, and the
call is wrapped because the builder cannot contain a corrupt file or an
unreadable directory.

The recipes pin skip_tokenizer_init=false. Gym serves model calls over vLLM's
OpenAI-compatible HTTP server, which needs a tokenizer to apply the chat
template; without one every model call fails and the harness generates nothing.
configure_generation_config already defaults this to false when
expose_http_server is set, but setup_nemo_gym_config sets expose_http_server
after that default is resolved, so the check never sees it.

Four recipes ship: _smoke (0.5B, 2 steps, the minimum end-to-end check), _tools
(3B, adds a hermes tool parser so the harness actually calls tools), _supply
(3B, the same plus prefix supply), and an unsuffixed config for longer runs. The
two without a tool parser produce single-turn rollouts by design.

Depends on the Gym token-capture stack beginning at NVIDIA-NeMo/Gym#2124.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Ananth Subramaniam <ansubramania@nvidia.com>
cmunley1
cmunley1 previously approved these changes Aug 6, 2026
@cmunley1

Copy link
Copy Markdown
Contributor

/claude review

Comment thread nemo_gym/base_responses_api_model.py Outdated
@claude

claude Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

SHIP WITH CARE

Training-token capture: a new leaf package (nemo_gym.token_id_capture) that records prompt/generation token ids + logprobs per model call into a rollout-keyed store, gated independently from eval capture. Reviewed the correctness-critical paths (verifier/scorer analog here is the capture→readback→train chain, since a hole in it silently corrupts training data).

What I checked and where it holds up:

  • Durability before return. capture_tokens awaits sink.put on the model server response path, so the entry is fsynced+flock'd before the model call returns — a post-rollout reader in another process never races a partial file. Tested (test_a_record_is_readable_as_soon_as_put_returns, test_concurrent_appends_to_one_rollout_stay_intact).
  • Incomplete = loud, not silent. The worst outcome — a lost/token-less call that leaves the rollout looking complete, so its generated tokens leak into the next prompt at mask 0 — is guarded on every path: write failure, build failure, and a response with no token ids all mark_incomplete. A sink lacking mark_incomplete is logged at error, not swallowed. Directly tested across store, installed-sink, and configured-sink routes.
  • Schema versioning refuses newer records rather than decoding them clean under extra="allow". Tested end-to-end through the store read.
  • Async hygiene. Blocking file IO offloaded via asyncio.to_thread; no httpx, no ray.get in async paths; sink ContextVar reset in a finally. The leaf-package import boundary (no fastapi/ray/torch) is enforced by a subprocess test.
  • Explicit rollout id round-trips through the path-prefix charset (writer/reader agree), with malformed ids refused rather than sanitized. Tested on both sides.
  • num_workers > 1 correctly handled: sink built per-worker at app startup, with a spawn-context test pinning why install_token_sink alone doesn't survive.

Watch items (not blockers):

  • Multi-carrier assumption. extract_token_fields/strip_token_fields assume exactly one output item carries token arrays and take the last one. True for today's vLLM single-choice path, but a future backend emitting token ids on multiple output items (multi-message / interleaved tool calls) would silently drop all but the last item's tokens into the record — a correctness hole with no error. Fine now; worth an assert or a follow-up if output shapes broaden.
  • One dead-code note inline (_request_messages).

Public API is additive only (new optional kwargs with defaults, new per-agent token_id_capture: bool = False); no signature breaks. Test coverage is genuinely thorough and asserts real behavior. Merge is a deliberate go — the async-durability and incomplete-marking invariants are the load-bearing ones and they hold.

Comment thread nemo_gym/base_responses_api_agent.py Outdated
@ananthsub

Copy link
Copy Markdown
Contributor Author

/claude review

Comment thread nemo_gym/token_id_capture/store.py Outdated
@claude

claude Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

SHIP WITH CARE — training-token capture infrastructure. Correct-by-construction on the failure paths that matter for RLHF, but one write-path efficiency concern and a couple of NOTEs.

The design is sound and the review-critical property — an incomplete rollout must never look complete — is defended in depth: capture_tokens/commit_entry never raise into the model call, a missing token payload marks the rollout incomplete (_capture_missing), a sink lacking mark_incomplete is logged at ERROR rather than silently swallowed, and the schema version refuses newer records instead of decoding them under extra="allow". The store fsyncs + flocks per file and put() is awaited (durable before the call returns), so a post-rollout reader never races a partial file. Async hygiene checks out: blocking store ops go through asyncio.to_thread, no ray.get() in async, no httpx. The leaf-package import test enforces the fastapi/ray/torch-free boundary the framework-worker path depends on. Test coverage is genuinely thorough — streaming Anthropic end-to-end, external-staging mode, spawned-worker sink isolation, schema round-trips, concurrent appends.

RISK (inline, store.py:151): append() re-reads and re-serializes every prior entry of the rollout on every call → O(N²·T) over a long trajectory, which is exactly the multi-turn external-harness case the two new configs enable. Doesn't corrupt data; degrades write throughput superlinearly. Fix: a seen model_call_id set in state instead of rescanning the file.

NOTE — request-body buffering under token-capture-only mode. When self._store is None but capture_wanted (training capture on, eval capture off), the middleware forwards without buffering the request body (base_responses_api_model.py:1131). That's correct for capture — the sink reads the response — but confirm no eval-only consumer relies on the request bytes in this mode. The tests cover the token side; they don't assert the eval record is intentionally absent here beyond test_tokens_captured_even_when_eval_capture_disabled, which looks right.

NOTE — TOKEN_ID_CAPTURE_KEY_NAME (global_config.py:101) is unused. The per-agent flag is read via config.token_id_capture and the block via TOKEN_ID_CAPTURE_BLOCK; this second constant with the same string value has no referent. Dead constant, linter's call, but it invites future confusion between the block key and the per-agent key since they collide on the literal "token_id_capture".

No blockers. The correctness-critical paths for training data integrity are well-handled.

@ananthsub

Copy link
Copy Markdown
Contributor Author

/claude review

@claude

claude Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Review incomplete: the PR head advanced during review (analysis ran against f480877826ff9dbda89a44411ca608a5443ab9b3, but the pre-publish revision check found the head is now 6aca3bf814baf9fc839609dd6577d886450dd4c7). Inline findings anchored to the old commit could land on the wrong lines, so none were posted. Please re-run the review against the current head.

@ananthsub

Copy link
Copy Markdown
Contributor Author

/claude review

Comment thread nemo_gym/global_config.py
cmunley1
cmunley1 previously approved these changes Aug 18, 2026
Comment thread nemo_gym/token_id_capture/store.py
Comment thread nemo_gym/base_responses_api_agent.py Outdated
An agent harness that drives its own model calls hands back a transcript with no
token ids, because the wire formats it speaks have no field for them. RL trains on
token ids, and re-tokenizing the returned text gives a sequence that differs from
what the policy sampled by an unmeasured amount.

The ids still exist inside the model server, for the moment before it converts the
response to the harness's dialect and synthesizes a stream. Capture takes them
there, keyed to the rollout that produced them, and writes one TokenEntry per
model call.

Calls are correlated to a rollout by the /ng-rollout/<id> path prefix already on
main for evaluation capture, so this adds no second correlation scheme. The
agent-side gate now serves both consumers, and a per-agent token_id_capture flag
scopes which agents participate; native agents leave it off because they carry
token ids on their own response items.

The capture key is derived from a run request's task and rollout indices, which
assumes each dispatch gets a distinct pair. A caller that restarts numbering per
dispatch produces a repeated id, so two dispatches share one key and their calls
stitch into one trajectory. An explicit _ng_rollout_id on the run body replaces
the derivation, with the attempt suffix still applied on top. An id that would not
survive the path segment is refused rather than rewritten, and the id pattern is
defined once so the body check and the middleware cannot disagree.

Settings live in one `token_id_capture` block rather than as flat keys, and it
names where records go:

    token_id_capture:
      enabled: true
      dir: /tmp/ng_tokcap
      sink: my_pkg.sinks:MyDataPlaneSink

`sink` is constructed once per server process at app startup. That matters at
num_workers > 1: uvicorn is handed an app string and workers=N and spawns those
workers, re-importing the app module rather than inheriting the launcher's memory,
so a sink installed programmatically by a launcher does not exist in any worker.
Measured, capture then falls back to the file store, or writes nothing at all when
no directory is set, and logs no error either way. install_token_sink remains for
programmatic use under the same constraint. The validator refuses combinations
that would silently capture nothing: settings with `enabled: false`, a sink beside
a directory, an unknown key, and a sink that cannot report a lost call.

TokenSink and TokenSource are protocols in a module that imports no web framework,
cluster runtime or tensor library, so an inference worker can write into its own
data plane without pulling in the server stack.

Signed-off-by: Ananth Subramaniam <ansubramania@nvidia.com>
…no token ids

capture_tokens returned quietly when a response carried no token ids, so a rollout
that lost a call looked identical to a complete one. The builder reads the gap
between one call's tokens and the next call's prompt as tool output, which closes
the chain over the hole: the missing call's generated tokens are delivered inside
the next prompt at mask 0, and tokens the policy sampled train as if the
environment had written them.

Mark the rollout instead, on the same path an exception already takes.

Signed-off-by: Ananth Subramaniam <ansubramania@nvidia.com>
Separate rollout correlation from capture intent and expose durable paired transport contracts so external frameworks can integrate without silent partial training data.

Signed-off-by: Ananth Subramaniam <ansubramania@nvidia.com>
Use the current FastAPI lifespan API so configured capture transports are closed without breaking server startup.

Signed-off-by: Ananth Subramaniam <ansubramania@nvidia.com>
Make agent selection explicit without changing evaluation defaults, and avoid rescanning growing token payloads on every durable write. Rename snapshot and URL contracts so their lifecycle and training purpose are clear.

Signed-off-by: Ananth Subramaniam <ansubramania@nvidia.com>
Keep comments and docstrings aligned with static agent selection, the training-specific route marker, and frozen source snapshots. Use short standalone sentences throughout the capture path.

Signed-off-by: Ananth Subramaniam <ansubramania@nvidia.com>
Keep remaining protocol and storage comments to one complete thought per line.

Signed-off-by: Ananth Subramaniam <ansubramania@nvidia.com>
Keep a frozen state tombstone after conditional drop so a late writer from the retired attempt cannot recreate its records. Explicit pre-dispatch cleanup starts the next attempt.

Signed-off-by: Ananth Subramaniam <ansubramania@nvidia.com>
Remove framework source construction from Gym configuration so consumers create and inject sources in their own process.

Signed-off-by: Ananth Subramaniam <ansubramania@nvidia.com>
Keep token_id_capture out of server discovery so env prefetch does not treat run-wide capture settings as a server.

Signed-off-by: Ananth Subramaniam <ansubramania@nvidia.com>
Name the request context separately from its sink, remove speculative compatibility prose, and keep Claude Code capture opt-in rather than enabled by default.

Signed-off-by: Ananth Subramaniam <ansubramania@nvidia.com>
Signed-off-by: Ananth Subramaniam <ansubramania@nvidia.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

sla:review-overdue Review response is over the one-business-day SLA

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants