Skip to content

feat: Switchyard OpenCode integration - #2080

Open
drifold wants to merge 12 commits into
NVIDIA-NeMo:feature/switchyard-openhands-integrationfrom
drifold:switchyard-opencode-integration
Open

feat: Switchyard OpenCode integration#2080
drifold wants to merge 12 commits into
NVIDIA-NeMo:feature/switchyard-openhands-integrationfrom
drifold:switchyard-opencode-integration

Conversation

@drifold

@drifold drifold commented Jul 20, 2026

Copy link
Copy Markdown

Stacked on #2026 (base: feature/switchyard-openhands-integration). Adds upstream opencode as a second harness for the Switchyard token-capture integration — same zero-fork principle as the OpenHands path — plus per-run Switchyard instance spawning for token injection.

What

  • Runs opencode headless (opencode run) with an opencode.json provider pointed at Switchyard — no fork, no harness modification.
  • Captures the session tree through opencode's own correlation headers: a distinct X-Session-Id per (sub)agent session, x-parent-session-id linking subagents to the root.
  • Reconstructs the root session as the main rollout and subagent sessions as token-annotated subagent_trajectories.
  • Per-run Switchyard instances: switchyard_spawn_routing_profile spawns one dedicated token-capture Switchyard instance per agent run on a free port — stateful token injection (feat: token-level capture for RL training (Switchyard ↔ NeMo Gym) Switchyard#63) requires all of a session's calls to reach one process — and reaps it after trace retrieval in run().

Zero-fork architecture

  • The opencode.json provider id is deliberately not opencode-prefixed — opencode only emits its native correlation headers for non-opencode providers; those headers are the whole capture contract.
  • Gym mints no session ids here: opencode's own ids om Switchyard's GET /v1/sessions (no harness logs
    read).
  • Title-generator side-calls (same session id, different conversation) are filtered before reconstruction.

Activation and safety

swe_agents:
  responses_api_agents:
    swe_agents:
      agent_framework: opencode
      opencode_source: opencode        # now the default; use "nv-opencode" for the fork
      switchyard_base_url: http://<host>:<port>
      # OR spawn mode (mutually exclusive with switchyard_base_url)
      # switchyard_spawn_routing_profile: /abs/path/
condition behavior
opencode_source: nv-opencode fork path (bench entrypoint)
upstream (opencode_source: opencode, now default+ subagents reconstruct
retrieval/reconstruction failure rollout kept, switchyard_trace_error set
switchyard_spawn_routing_profile absent behavichyard_base_url` or no Switchyard)
spawn mode enabled per-run instance, records under persistent_dir, instance reaped after retrieval

Changes

  • responses_api_agents/swe_agents/app.pyopencovider JSON), the opencode run command, _list_switchyard_sessions/_reconstruct_sessionssk_id steering via system-prompt instructions (works
    around an opencode quirk where it invents task_ids).
  • responses_api_agents/swe_agents/app.pyswitchyard_spawn_routing_profile / switchyard_spawn_host config (mutually exclusive
    with switchyard_base_url, validated fail-fast), specycle, agent-script build deferred until theinstance URL is known.
  • responses_api_agents/swe_agents/app.pyopenco to opencode` (upstream); config comment/docs updated
    accordingly.
  • responses_api_agents/swe_agents/configs/ — splitnch_opencode.yaml/swebench_opencode_training.yaml now target **upstream** opencode (opencode_source: gs move to swebench_nv_opencode.yaml /
    swebench_nv_opencode_training.yaml (`opencode_soured). Root README table regenerated.
  • nemo_gym/switchyard_trace.py — canonicalize toolstory-extension comparison only (opencodere-serializes compact vs. pretty-printed JSON across turns); reconstructed items keep the model's original arguments.
  • Tests: 8 new test classes across test_app.py and`, plus 7 spawn-lifecycle tests (config conflicts,session minting + script rebuild, spawn/teardown with mocked subprocess, readiness-failure reaping).

Dependencies

Requires the Switchyard side on NVIDIA-NeMo/Switchya

  • 7b33c2c (native X-Session-Id capture), be0b2c5r the opencode capture path.
  • 7f20b2a, 940b767, 8307fd9 (token-continuity ion use-case; spawn mode itself works withcapture-only).

Testing

  • 232 passed across the swe_agents and `switchyard
  • Cluster E2E (vLLM 0.24.0, Qwen3): root + subagent ode headers, both reconstruct with exact token
    fidelity, 0% masked; with token injection enabled, buous across multi-turn reasoning + tool-calling
    conversations (0 fallbacks), and an OpenHands-pattertiguity verification.

Changes from the live description are only: the openthe first two behavior-table rows (fork now keyed onnv-opencode; upstream noted as default), and two addault flip + config split). Everything else — including the - bullets, headings, dependency SHAs, and the vLs unchanged.

drifold and others added 3 commits July 20, 2026 15:00
Adds opencode_source='opencode': runs upstream opencode headless with an
opencode.json provider pointed at Switchyard (no fork), captures the
session tree via opencode's native correlation headers
(X-Session-Id / x-parent-session-id), and reconstructs root and subagent
rollouts from the retrieved sessions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: drifold <drifold@nvidia.com>
…mparison

OpenCode re-serializes tool-call arguments differently across turns (compact vs. pretty-printed JSON), which failed the message-history extension check even though the tokens were contiguous. Canonicalize arguments for the comparison only; reconstructed training items keep the model's original arguments.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: drifold <drifold@nvidia.com>
…system-prompt instructions

Signed-off-by: drifold <drifold@nvidia.com>
@copy-pr-bot

copy-pr-bot Bot commented Jul 20, 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.

@nemo-automation-bot nemo-automation-bot Bot added the community-request Issue reported or requested by someone from the community label Jul 20, 2026
Adds switchyard_spawn_routing_profile: when set, each agent run spawns one
dedicated token-capture Switchyard instance on a free port (stateful token
injection requires all of a session's calls to reach one process), scopes
records under the run's persistent_dir, and reaps the instance after trace
retrieval in run(). Mutually exclusive with switchyard_base_url; behavior
is unchanged when absent. The agent script is built after the instance URL
is known, since it embeds the Switchyard routing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: drifold <drifold@nvidia.com>
@github-actions github-actions Bot added the sla:triage-overdue Review assignment is over the one-business-day SLA label Jul 21, 2026
@svcnvidia-nemo-ci svcnvidia-nemo-ci added the waiting-on-maintainers Waiting on maintainers to respond label Jul 22, 2026
drifold and others added 2 commits July 23, 2026 09:35
…nfigs as nv-opencode

Signed-off-by: drifold <drifold@nvidia.com>
…ed context length

Fetch the served model's max_model_len from Switchyard's /v1/models (surfaced
there for token-capture routes) before building the opencode agent command,
and declare limit.context/limit.output on the generated provider entry.
Without limits, opencode requests a flat 32000 max_tokens and never compacts
(limit.context defaults to 0); with them it budgets like any registry-known
model. Output is reserved as min(32000, context // 4) so the compaction
threshold (context - output) keeps most of the window for history. Fetch
failure degrades to today's limit-less behavior with a warning.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: drifold <drifold@nvidia.com>
drifold added 2 commits July 29, 2026 01:23
…ends

Distribute per-rollout Switchyard spawns across all vLLM generation servers
using a round-robin counter (_switchyard_spawn_count % N). Previously all
spawns used policy_base_url[0], leaving servers 1-15 idle while server 0
was saturated, causing 3600s timeouts for all concurrent rollouts.

Signed-off-by: drifold <drifold@nvidia.com>
Three fixes needed when running 1000+ concurrent rollouts on a single node:

1. Attribute fix: _spawn_switchyard used self.config.ng_global_config_dict_str
   (SWEBenchWrapperConfig, request-level) instead of params.ng_global_config_dict_str
   (SWEBenchWrapperInstanceConfig, which carries the server-level global config).
   This caused AttributeError -> 500 on every /run call.

2. SWITCHYARD_POLICY_MODEL: setting SWITCHYARD_VLLM_BASE_URL via spawn_env caused
   the wrapper to skip its NEMO_GYM_CONFIG_DICT lookup entirely, leaving
   SWITCHYARD_POLICY_MODEL unset. Fix: wrapper now derives the model name
   independently, regardless of whether the URL was pre-set by Gym.

3. Concurrency: 1024 simultaneous Switchyard spawns on one node caused
   ClientOSError (port exhaustion) and ServerDisconnectedError (uvicorn
   connection saturation, each /run held open ~168s during spawn+wait).
   Fix: asyncio.Semaphore(64) limits concurrent spawns; TCP socket probe
   replaces HTTP /v1/models polling (pure kernel-level, no aiohttp overhead).

Signed-off-by: drifold <drifold@nvidia.com>
@drifold
drifold force-pushed the switchyard-opencode-integration branch from ec12517 to 8afdd31 Compare July 29, 2026 08:23
drifold added 2 commits August 2, 2026 00:45
… of discarding all of it

reconstruct_switchyard_rollout validates each captured record as it rebuilds a
rollout, and raised on the first record that failed — discarding every record
already validated. A discarded session yields a rollout with no generation
tokens, which makes NeMo-RL reject and retry the sample's entire 16-rollout
prompt group. Breaks are not rare: context compaction legitimately stops later
records from extending earlier ones (~4% of sessions, measured over 300
production traces), and at that rate group-level rejection destroyed 97% of
groups — a 4-hour run produced zero training steps.

Now a mid-session failure returns the already-validated prefix — a complete,
token-exact rollout up to the break — with `partial_reason` set. Callers mask
the sample: real tokens in the batch, zero loss contribution, and the other 15
samples train normally. This was always the intended semantics ("emit what is
trustworthy, mask the sample" — the compaction degrade path already worked this
way); raise-and-discard was the inconsistency.

Deliberate boundaries: a failure on the FIRST record still raises (nothing is
certified, so there is nothing honest to return), and allow_partial=False gives
fail-fast for analysis and the validation unit tests. Partial is the default
because the training pipeline is the primary caller.

Signed-off-by: drifold <drifold@nvidia.com>
…n the rollout's own node; drop shared mode

Distribution. Previously the Gym server node hosted every per-rollout Switchyard
(~1024 x 94MB at recipe concurrency) and later every trace retrieval — hundreds
of Lustre record reads plus CPU-bound token reconstruction per rollout, executed
on the server's single event loop. At scale that starved response delivery
outright: 2,600 rollouts finished server-side while ~200 responses reached the
trainer in 2h and the replay buffer never left zero. Both now live in the
rollout's Ray task (scheduling_strategy=SPREAD): the proxy spawns on the agent's
node, and after teardown — when the record set is complete by construction — the
task reads and reconstructs its own trace and returns it in the task result. The
server keeps only in-memory work: applying the trace to the response, masking,
and the round-robin backend cursor (carried to the task via
switchyard_backend_url).

Crossing the server->Ray-worker boundary surfaced three environment assumptions,
each fixed here and each caught at full scale first:
- the gym venv's bin/ is not on a Ray worker's PATH: resolve the switchyard CLI
  next to the running interpreter;
- NEMO_GYM_CONFIG_DICT does not inherit: set it at the task entry point (the
  venv's switchyard wrapper derives SWITCHYARD_POLICY_MODEL from it, and
  get_global_config_dict() otherwise falls through to parsing Hydra CLI args);
- proxy readiness now waits 240s, not 60: under Lustre load the CLI's imports
  alone stall past 60s (measured p90=46s with the distribution truncated at the
  old timeout), and every timeout kill triggered a 16-rollout group retry — an
  amplification loop. Dead-on-arrival proxies still fail fast via the
  process-exit check.

Retrieval consumes the longest-valid-prefix semantics from nemo_gym: partial or
degraded traces (context compaction) emit real tokens plus mask_sample=True, so
a sample excludes itself from the loss without aborting its prompt group.

Shared mode (an externally managed proxy at switchyard_base_url, records fetched
over HTTP) is removed: a dead proxy strands its tokens behind an unreachable
endpoint — retried without bound — which is the failure mode disk retrieval
exists to eliminate, and per-rollout spawn is the only configuration injection
supports anyway. The instance-level field survives as switchyard_spawned_base_url,
internal plumbing from the task's spawn to the harness command builder.

Signed-off-by: drifold <drifold@nvidia.com>
@drifold
drifold requested a review from a team as a code owner August 3, 2026 09:11
Runs the per-rollout Switchyard lifecycle inside a REAL Ray task with the
production decorator options — deliberately unmocked. The existing spawn tests
mock create_subprocess_exec, which verifies argv but cannot fail on what broke
at scale three times: state the Gym server process has that a Ray worker does
not (PATH, environment, sys.path). One task round-trip covers CLI resolution
without PATH (with a negative control that reports the test inconclusive
rather than vacuously green when the worker has the CLI on PATH), proxy
readiness (bound + accepting), params crossing the boundary exactly as
production sends them (model_dump -> model_validate), task-side trace
reconstruction from seeded records, payload survival through the Ray object
store, and clean teardown. Skips gracefully where ray or the CLI is
unavailable. ~33s wall when fully exercised; reviewers may prefer an
integration marker for per-commit CI.

Signed-off-by: drifold <drifold@nvidia.com>
@drifold
drifold force-pushed the switchyard-opencode-integration branch from 510bf3f to afd76b8 Compare August 3, 2026 09:30
…ports its env

The spawned proxy's config previously lived outside the repo: a routing
profile with hardcoded parsers, plus a wrapper script installed over the
venv's switchyard entrypoint to derive the policy endpoint/model and put
the serving vLLM on PYTHONPATH. Rebuilding the venv silently deleted the
wrapper and broke training.

- switchyard_profile.yaml: shipped, deployment-independent profile. The
  endpoint, model, and parsers resolve from ${...} env vars; sampling
  params stay numeric literals (env interpolation is string-only). The
  default/policy_model routes are aliases of one route: dispatch is an
  exact match on the request's model id, and upstream opencode POSTs
  "default" while the OpenHands fork POSTs the model server's name.
- _spawn_switchyard_local now exports everything the profile references:
  endpoint/model derived from the run's config, parsers and PYTHONPATH
  from three new config fields (switchyard_tool_parser,
  switchyard_reasoning_parser, switchyard_parser_pythonpath).

Signed-off-by: drifold <drifold@nvidia.com>
linj-glitch added a commit that referenced this pull request Aug 18, 2026
Serves Gym model calls through a Switchyard routing proxy, so any benchmark
Gym already supports can be evaluated against a router without touching
harness code:

    gym eval run --benchmark <name> --model-type switchyard_model

Switchyard is integrated at the model layer rather than the agent layer. The
existing Switchyard work in Gym (#2026 token capture, #2080 OpenCode) wires
one harness at a time under responses_api_agents/swe_agents; putting the
router behind a Responses API model instead makes it available to every
benchmark at once, which is what evaluating the router itself requires.

Two modes. Attach (default) points at a proxy run outside Gym, which keeps
proxy lifecycle and image pinning with the eval harness so a run can be
compared against a scaled-evals run of the same Switchyard commit. Launch
starts the proxy from a routing profile and shuts it down at exit, for a
single-command run.

Gym's rollout-attempt id is forwarded as Switchyard's opaque session id, so
proxy-side routing decisions and costs join back to the rollout that produced
them. The correlation prefix is stripped by Gym's capture middleware before
handlers run, so a thin outer middleware republishes the id for the request.

The nemo-switchyard dependency is scoped to this package rather than Gym's
core dependencies: only runs that route through Switchyard pay for it, and
attach mode needs no dependency at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Lin Jia <linj@nvidia.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

community-request Issue reported or requested by someone from the community sla:triage-overdue Review assignment is over the one-business-day SLA waiting-on-maintainers Waiting on maintainers to respond

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants