fix(VT-FP8-QUANT-ARCH-GATE): register QuantFp8Static from an unconditional CUDA TU (#960) - #991
fix(VT-FP8-QUANT-ARCH-GATE): register QuantFp8Static from an unconditional CUDA TU (#960)#991localai-bot wants to merge 7 commits into
Conversation
…-independent TU `vt::QuantFp8Static`'s only CUDA registration lives in `src/vt/cuda/cuda_matmul_fp8_cutlass.cu:376`, and `CMakeLists.txt:1668` compiles that translation unit only when `VT_CUTLASS_FP8_ARCHS` is non-empty. The kernel body has no cutlass dependency at all -- it is `out[i] = e4m3(x[i] * (1/input_scale))` -- so on every CUDA arch outside the cutlass-fp8 cell the op is not registered for `DeviceType::kCUDA`, the resolver installs the portable CPU reference tier for a CUDA queue, and the first call dereferences device pointers and segfaults. Nothing refuses first: the GEMM partner `kMatmulFp8CublasLt` is registered unconditionally, so the model-layer guard that keys on it passes and the crash arrives one call later under a banner reading "correct but slow". This spec is committed before the implementation, per AGENTS.md "Spec before code". It records the scope, the three TUs considered as a new home and why a dedicated file wins, the upstream partition being restored, and the two instruments the change owes: a runtime pin that observes the registration, and a structural checker that can fail at PR time on a host CI actually runs. Issue #960. FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: AGENT:claude-opus-5 [claude-code]
…ional CUDA TU `vt::QuantFp8Static` was registered for `DeviceType::kCUDA` from `src/vt/cuda/cuda_matmul_fp8_cutlass.cu`, a translation unit CMake adds to the target only when `VT_CUTLASS_FP8_ARCHS` resolves non-empty. The kernel needs no cutlass -- it is a grid-stride `x * (1/scale)` followed by a hardware e4m3 convert -- so it inherited an arch gate that has nothing to do with it. On sm_110, where `cutlass-fp8: DISABLED` is the documented normal profile, the op was simply absent for CUDA, the resolver installed the portable CPU reference tier for a CUDA queue, and the first call dereferenced device pointers: SIGSEGV, exit 139. The kernel, its two device helpers and its registration move verbatim into a new `src/vt/cuda/cuda_quant_fp8.cu`, listed in the unconditional `if(VLLM_CPP_CUDA)` source list. Only the local `Check()` message prefix changes, from `matmul_fp8_cutlass` to `quant_fp8`, which stopped being true the moment the code moved. No arithmetic, no dtype dispatch and no dispatch condition is touched. A dedicated file rather than `cuda_matmul.cu` (which already hosts the unconditional fp8 GEMM registrations) or `cuda_ops.cu` (which hosts the FUSED arm of this same math): the defect is that a kernel's compilation was governed by a feature it does not use, and both alternatives re-create a weaker form of that coupling by binding it to an unrelated file's includes and requirements. This also restores upstream's own partition -- vLLM builds `static_scaled_fp8_quant` from the unconditional `VLLM_EXT_SRC` list and gates only its cutlass `scaled_mm` sources. Two instruments, because neither covers the other. `test_ops_fp8_cpu` G4 observes the property that matters, `OpRegistered(kQuantFp8Static, kCUDA)`, and requires `kMatmulFp8Cutlass` to track `VT_CUTLASS_FP8` instead, so it proves the two are independent rather than that one is present. But it can only speak on a CUDA build WITHOUT cutlass-fp8, and no CI job produces one, so it would not have caught this before it landed. `scripts/check-cuda-op-arch-gate.py` reads the build description, runs on every host including CPU-only CI, and fails at PR time: HOME (the TU is in the unconditional list), REGISTERED (exactly one live registration), UNGUARDED (preprocessor depth 0, so an `#ifdef VT_CUTLASS_FP8` wrapper is not a pass), EXCLUSIVE (no second kCUDA registration elsewhere). Its own suite binds the checker as a MODULE rather than pulling three names out of it at import time, so `check-pr-size`'s creation-mutation stub fails every case instead of producing an ImportError the evidence contract reads as "executed no tests". `docs/USAGE.md` gains the operator-facing half: `cutlass-fp8: DISABLED` removes a GEMM, not FP8, and a `[vt reference-tier]` banner naming an op on a `cuda` device is a missing kernel to report rather than a slow path to accept. This removes one live INSTANCE of #844 and does not address its class: the reference tier still runs a host kernel over device pointers for any other op with no native CUDA kernel, and still calls it "correct but slow". #844 stays open. Also fixes #989 in flow, because it had to: registering the new checker's creation mutation means editing `scripts/check-pr-size.py`, and that file's evidence contract requires its own suite green at HEAD. That suite has been red on `main` since #888 added `.agents/reachability.md` without a path class, and red silently -- it runs in no CI job, so the only thing that loads it is the checker-evidence contract, which fires only when someone edits a checker. Third instance of the same class after #856 and #668, fixed the same way both were. Issue #960. Spec `.agents/specs/vt-fp8-quant-arch-gate.md`. FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: AGENT:claude-opus-5 [claude-code]
…tcome Fills the spec's evidence sections with the runs themselves rather than a summary of them: the Thor red-before at the base SHA (exit 139, and the `assertions: 43 | 43 passed | 0 failed` printed beside `Status: FAILURE!` that makes a crash read as green to anything grepping that line), the RED-first G4 result with its non-vacuity arm passing, the green-after, and a fresh-tree clean build at HEAD because three incremental rebuilds of one directory do not prove the committed tree builds. The GB10 before/after is the behaviour-preservation claim and is stated as such: four fp8 suites, identical case and assertion counts, distinct binary shas in both columns, `cutlass-fp8: ENABLED for [121a]` asserted in both configure logs. `test_linear_method` fails IDENTICALLY in both, which is how the run proves that failure is #907 and not ours -- named case, file:line, and assertion. Also recorded: the first GB10 after-run reported the suite at 4 cases when the binary should have had 5. `tar` restored the test file with an mtime older than the object compiled during the base build, so ninja skipped the compile and relinked only because libvllm.a had changed -- a suite reporting on a binary that contained the fix but not the test, with a sha that had legitimately moved. Verify the case COUNT, not the sha. Issue #960. FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: AGENT:claude-opus-5 [claude-code]
…# Now` The `## Now` line said "landed on `main`", which it is not: it is open as #991, measured on both gate hosts, awaiting a fresh review and an operator merge. A `## Now` that anticipates its own landing is exactly the record that cannot be trusted afterwards -- the whole point of the line is that a reader learns the position without asking anyone. It also names the only red lanes and why they are not this row's: `windows-msvc-cpu`/`windows-msvc-vulkan` fail at the identical step on #988 and #982, which share no code with this change, so the arm is matched rather than assumed; the cause is #968 under #584's PR-only lane and a fix is already open as #983. Issue #960. FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: AGENT:claude-opus-5 [claude-code]
Pinned SHA, per #841. main moved four commits while this row was measuring and re-landing: #993, #982, #988 and #992. One of them is directly relevant -- #988 split #810's A2-Q into A2-Q1 and A2-Q2, and A2-Q1 is the unit this row exists to unblock -- and none of them touch the fp8 quant registration, the CUDA source list, or either checker this branch edits. `.agents/issue-index.md` is the only keyed record both sides wrote. Verified afterwards that origin/main's copy is a strict PREFIX of the merged file and that only this branch's own two rows follow it, which is what append-only means and what a clean-but-wrong interleave would break. FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: AGENT:claude-opus-5 [claude-code]
…in's The merge of `3ce1cf7c7` produced a CLEAN but WRONG `.agents/issue-index.md`: every row from both sides survived, and `check-issue-index-append-only.py` passed, but main's newest row (#987) ended up AFTER this branch's #960 and #989 rather than before them. `merge=union` interleaves by hunk, not by arrival, so "no conflict" says nothing about order -- and the file's whole contract is that it is append-only, which is an ORDER claim. Detected by the check the union driver cannot make: is `origin/main`'s file a strict PREFIX of ours? It was not. Repaired by taking main's version wholesale and re-appending only this branch's own two rows, then verifying the shared prefix is byte-for-byte identical (251 rows) and that exactly #960 and #989 follow it. No row was edited and none was deleted. FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: AGENT:claude-opus-5 [claude-code]
…nostic "Known-red" is not a measurement, and this file was carrying it as one. The two `windows-msvc-*` jobs now carry the diagnostic that failed them: `warning C4244: '=': conversion from 'const double' to 'float'`, raised inside MSVC's own `<vector>` while compiling `src/vllm/multimodal/ltx2_video.cpp`, promoted to `error C2220` by `/WX`. That is #968, its fix is already open as #983, and `main` carries no baseline for those jobs because #584 makes them PR-only. The matched arm is recorded too: #988 and #982 fail at the identical step and share no file with this row. Also records the post-merge full gate: 491/491 rather than 489/489, because `3ce1cf7c7` adds two suites. Quoting the old number after a merge that changed the denominator would be a stale measurement presented as a current one. Issue #960. FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: AGENT:claude-opus-5 [claude-code]
CI at
|
| job | result |
|---|---|
agent-record · build · build-test-cpu · build-test-cpu-arm64 · build-test-vulkan |
pass |
commit-protocol-tag · cuda-arch-features · cuda-fat-build · device-leakage |
pass |
documentation-checkpoint · drift-check · last-gated-commit · plan · pr-size |
pass |
sanitize-cpu (address,undefined) · sanitize-cpu (thread) · vulkan-spirv-freshness |
pass |
verify (cpu, …) · verify (vulkan, …) |
pass |
windows-msvc-cpu · windows-msvc-vulkan |
fail — inherited, see below |
pr-size passing is the load-bearing one: its evidence contract runs tests/scripts/test_check_cuda_op_arch_gate.py at HEAD, then replaces the new checker with a disabled stub and re-runs the same module, and requires pass-then-fail with a non-zero case count on both halves. The checker's red-before/green-after is therefore machine-verified in CI, not asserted in this description.
The MSVC red, named rather than labelled
ltx2_video.cpp
…\MSVC\14.44.35207\include\vector(1461,29): warning C4244: '=': conversion from 'const double' to 'float', possible loss of data
(compiling source file '../src/vllm/multimodal/ltx2_video.cpp')
…\include\vector(1461,29): error C2220: the following warning is treated as an error
That is #968 — the narrowing positions.assign sites #964 added — surfacing from inside MSVC's <vector> under /WX. A fix is already open as #983. main carries no baseline for these jobs because #584 makes windows-msvc-* PR-only, which is exactly why it presents to each author in turn as their own diff's fault.
Matched arm, measured not assumed: open PRs #988 and #982 fail at the byte-identical step name, Build and execute the native Windows CPU focused gate, and neither shares a file with this row. This branch touches no src/vllm/multimodal/ path at all.
Post-merge re-verification at 142fcba7f
origin/main moved four commits (#993, #982, #988, #992) while this row was measuring. Merged at the pinned SHA 3ce1cf7c7 per #841, rebuilt (BUILD_EXIT=0, 0 warnings) and re-ran the full local gate: 491/491 passed, 0 failed — 491 rather than 489 because that merge adds two suites — plus check-pr-size, both checker suites, and the record gates against the new base.
One of those four is worth naming: #988 split #810's A2-Q into A2-Q1 and A2-Q2, and A2-Q1 is the unit this row exists to unblock.
The merge produced a clean but WRONG .agents/issue-index.md and it is repaired in 142fcba7f's parent. Every row from both sides survived and check-issue-index-append-only.py passed, but merge=union interleaves by hunk rather than by arrival, so main's newest row (#987) landed after this branch's #960 and #989. "No conflict" says nothing about order, and order is the file's entire contract. Caught by the check the union driver cannot make — is origin/main's file a strict prefix of ours? — and repaired by taking main's copy wholesale and re-appending only this branch's two rows, verified byte-for-byte identical across the shared 251 rows.
FOLLOWING_AGENTS_PROTOCOL
Following-Agents-Protocol: true
AI-Assisted: true
Assisted-by: AGENT:claude-opus-5 [claude-code]
Closes #960. Closes #989.
Spec:
.agents/specs/vt-fp8-quant-arch-gate.md.Owning row:
MODEL-TEXT-nemotron-h-nemotron-hfor-causal-lm(#517), whose A2-Q1 unit (#810) is the caller this blocks. Base: #940.What was wrong
vt::QuantFp8Static's only CUDA registration wassrc/vt/cuda/cuda_matmul_fp8_cutlass.cu:376, andCMakeLists.txt:1668compiles that translation unit only whenVT_CUTLASS_FP8_ARCHSis non-empty.The kernel body has no cutlass dependency of any kind — zero
cutlass/CUTLASStokens inQuantFp8StaticKernelCuda(:353-370). It isout[i] = e4m3_rne_sat(x[i] * (1/input_scale)), a grid-stride elementwise loop over a hardware convert intrinsic. It shared a TU with the cutlass sm120 fp8 GEMM and inherited that GEMM's arch set.On sm_110 (Thor) that intersection is empty.
cutlass-fp8: DISABLED (no requested arch in [110] provides it)is the arch's documented normal profile, not a misconfiguration. SoOpId::kQuantFp8Staticwas not registered forDeviceType::kCUDAat all, on that arch and on every CUDA arch outside the cutlass-fp8 cell.Nothing refused first, which is what made it expensive. The op's GEMM partner
kMatmulFp8CublasLtis registered unconditionally (src/vt/cuda/cuda_matmul.cu:920), soMatmulFp8CutlassD's guard — which keys on it — passed. The missing quant then resolved throughsrc/vt/op_provider.cpp:501to the portable CPU reference tier, eligible becauseCudaBackend::UnifiedMemory()is true, which dereferenced device pointers on the host:Nothing silently dequantized. Nothing refused either. It crashed one call later, under a banner claiming "correct but slow".
The change
src/vt/cuda/cuda_quant_fp8.cuCMakeLists.txt:1586target_sources(vllm PRIVATE ...)directly insideif(VLLM_CPP_CUDA)src/vt/cuda/cuda_matmul_fp8_cutlass.cu:322The only edit to the moved code is the local
Check()message prefix,matmul_fp8_cutlass→quant_fp8, which stopped being true the moment the code moved. No arithmetic, no dtype dispatch, no dispatch condition is touched. Because the new TU is not in_VT_CUDA_FEATURE_SOURCES,CMakeLists.txt:2231-2236gives it the full${VLLM_CPP_CUDA_ARCHITECTURES}gencode list, which is the point.Why a new file, not
cuda_matmul.cuorcuda_ops.cu. Both were real candidates and both were rejected in the spec.cuda_matmul.cualready hosts the unconditional fp8 GEMM registrations — the strongest argument for it — but it is the cuBLAS/cuBLASLt GEMM wrapper TU and this is not a GEMM.cuda_ops.cuis unconditional, already includes<cuda_fp8.h>, and hostsRmsNormQuantFp8, literally the fused arm of this same math. The defect, though, is precisely that a kernel's compilation was governed by a feature it does not use, and both alternatives re-create a weaker form of that by binding it to an unrelated file's includes and requirements. A file named for the op makes the invariant readable in the CMake diff, is what the structural checker can assert without inference, and is the natural home as the fp8 activation-quant family grows.This also restores upstream's own partition: vLLM builds
static_scaled_fp8_quantfromcsrc/quantization/w8a8/fp8/common.cuin the unconditionalVLLM_EXT_SRClist, and gates only its cutlassscaled_mmsources behindCUDA_ARCHSintersections.Two instruments, because neither covers the other
G4,
tests/vt/test_ops_fp8_cpu.cpp— the runtime pin, and the stronger claim: it observes the property that matters,OpRegistered(kQuantFp8Static, kCUDA), rather than a proxy. It needs no CUDA device (registration is a table fill beforemain), because "which build" is the axis the defect lived on. Its second assertion requireskMatmulFp8Cutlassto trackVT_CUTLASS_FP8instead, so the case proves the two are independent rather than merely that one is present.scripts/check-cuda-op-arch-gate.py— the tripwire. G4 can only speak on a CUDA build without cutlass-fp8, and no CI job produces one: the GB10 gate host resolvescutlass-fp8: ENABLED, where the defect is unreachable by construction, and every other job is CPU-only. G4 would not have caught #960 before it landed; it caught it here only because someone carried a binary to Thor. The checker reads the build description, runs in the ordinary checker lane on every host, and fails at PR time. Four clauses per entry, no inference about what a kernel "needs": HOME (the TU is in the unconditional list), REGISTERED (exactly one live registration), UNGUARDED (preprocessor depth 0, so an#ifdef VT_CUTLASS_FP8wrapper is not a pass), EXCLUSIVE (no secondkCUDAregistration elsewhere). C++ is read throughchecker_text.normalize_source, so a commented-out or#if 0-ed registration reads as absent.Evidence
Thor, sm_110, no cutlass — the red-before
CUDA target architectures: 110,cutlass-fp8: DISABLED,CUTLASS not foundon every build.BUILD_EXIT=0,warnings: 0,enospc: 0. Disk 319 G → 318 G.test_ops_fp8_cpu0e1bee42f6b4d4df071a6…test cases: 2 | 1 passed | 1 failed | 2 skipped·assertions: 43 | 43 passed | 0 failed·Status: FAILURE!· exit 139 (SIGSEGV)63b7940e8609…test cases: 1 | 0 passed | 1 failed | 4 skipped·assertions: 2 | 1 passed | 1 failed·Status: FAILURE!· exit 1690bf71448ea…test cases: 5 | 5 passed | 0 failed | 0 skipped·assertions: 62 | 62 passed | 0 failed·Status: SUCCESS!· exit 0dc83e683f4fd…5 | 5 passed | 0 failed·62 | 62 passed | 0 failed·SUCCESS!· exit 0The base row reproduces #960 verbatim, including the trap #844 names:
assertions: 43 | 43 passed | 0 failedprinted besideStatus: FAILURE!, so anything grepping the assertions line alone reads a crash as green.The RED-first row is the one that matters. G4's first assertion failed —
CHECK( vt::OpRegistered(vt::OpId::kQuantFp8Static, DeviceType::kCUDA) )→values: CHECK( false )— while its second passed,CHECK_FALSE( false )onkMatmulFp8Cutlass, so the case was not vacuous and the arch genuinely lacks the cutlass GEMM. Non-zero case count in both directions. The case name carries no comma:-tcsplits on commas, and a comma would have selected nothing and reportedSUCCESS!with exit 0.In the green rows, G2 — the CPU-vs-CUDA byte comparison, zero tolerance, five scales × 4096 elements — executes on sm_110 for the first time and passes,
bad == 0throughout, with no[vt reference-tier]banner printed at all. That closes the armvt-fp8-w8a8-cpu-arm.md(#468) recorded as owed on a non-cutlass CUDA arch. The last row is a freshgit archiveinto a new directory with no build state, because three incremental rebuilds of one tree do not prove the committed tree builds.GB10, sm_121a — unchanged, which is the claim
Both builds assert
CUDA target architectures: 121aandCUDA feature cutlass-fp8: ENABLED for [121a]in the configure log; aDISABLEDline would void the result.BUILD_EXIT=0,warnings: 0,enospc: 0,-j 4, 2.7 T free. Every binary sha differs between columns, so neither is a stale artifact.0e1bee42f)test_ops_fp8_cpu4 | 4 passed | 0 failed·60 | 60 passed | 0 failed·SUCCESS!5 | 5 passed | 0 failed·62 | 62 passed | 0 failed·SUCCESS!test_ops_fp8_cutlass8 | 8 passed | 0 failed·86 | 86 passed | 0 failed·SUCCESS!test_linear_method10 | 9 passed | 1 failed·97 | 95 passed | 2 failed·FAILURE!test_ops_fused_chain10 | 10 passed | 0 failed·583 | 583 passed | 0 failed·SUCCESS!The only delta is
test_ops_fp8_cpugaining exactly G4: +1 case, +2 assertions. The four pre-existing cases and their 60 assertions are untouched.test_linear_methodfails identically in both columns, which is how this run proves it is not ours rather than assuming it:linear_method: MXFP4 fused gate_up ~= split (numerically) + fused path ran,test_linear_method.cpp:247,CHECK( after == before + 1 ), twice — a Marlin dispatch counter on a suite with no fp8 arm. That is #907'stest_linear_methodrow at the cutlass-enabled build's shape.One trap worth reading, because it nearly produced a false result. The first AFTER build reported 4 cases / 60 assertions — G4 missing from a binary whose sha had changed.
tarrestored the test file with its local mtime (06:46 UTC), older than the object compiled during the base build (07:10 UTC), so ninja skipped the compile and relinked only becauselibvllm.ahad changed. The suite reported on a binary containing the fix but not the test. The table above is from a rebuild aftertouch. Verify the case count, not the sha.CPU-only, local
Release-equivalent build,
BUILD_EXIT=0, 0 warnings.ctest489/489 passed, 0 failed (2 skipped for absent checkpoints).test_ops_fp8_cpureads 4 / 56 here: G4 compiles out on a non-CUDA build, which is correct and is why it is#if defined(VLLM_CPP_CUDA).scripts/agent-preflight.sh: every gate green excepttest_cpu_x86_llamacpp_floor, which exitedNO_QUIET_WINDOW(4) at loadavg 77.49 while this box was building — the harness refusing to measure under contention, #618, inherited.The checker's mutation evidence, executed rather than described
scripts/check-pr-size.py --base <merge-base> --head HEADpasses, which is not a formality: its evidence contract checks the branch into a scratch worktree, runstests/scripts/test_check_cuda_op_arch_gate.pyat HEAD (must pass, non-zero case count), then overwrites the checker with a disabled stub and re-runs the same module (must fail, non-zero case count). Both halves are machine-verified. Locally the suite is 14 cases: one per clause, the two "text the compiler never sees" disguises, a live-tree case, andtest_empty_source_list_is_not_a_pass— because a checker reporting OK on a parse that matched nothing is a green light attached to no measurement.That contract also caught a defect in the first version of the suite: it bound the checker's functions at import time, so the stub produced an ImportError rather than failing cases and the contract reported
semantic evidence did not execute tests— neither red nor green, the instrument declining to say. Fixed before this branch was pushed.#844 is not fixed here
This removes one live instance and does not address the class. The portable reference tier still accepts
DeviceType::kCUDAtensors, still dereferences them, and still calls itself "correct but slow" while doing it. The next feature-gated op to lose its native kernel reproduces #960 exactly. That repair is a larger change to the tier and wants its own spec; #844 stays open.#989 fixed in flow, because it had to be
Registering the new checker's creation mutation means editing
scripts/check-pr-size.py, and that file's own evidence contract requires its suite green at HEAD.tests/scripts/test_check_pr_size.pyhas been red onmainsince #888 added.agents/reachability.mdwith no path class — and red silently: it runs in no CI job and no preflight suite, so the only thing that ever loads it is the checker-evidence contract, which fires only when someone edits a checker. Third instance of the class after #856 and #668, fixed the same way both were: one entry inPROCEDURE_FILES, next to its sibling guides, with a comment naming the issue. Wiring that suite into CI is deliberately not done here.Why this replaces #990
#990 was the same change on
row/VT-FP8-QUANT-ARCH-GATE-960and is closed, notabandoned.
documentation-checkpointis a PER-COMMIT gate and it was right: thecommit that edits
CMakeLists.txtowesdocs/USAGE.mdin that same commit, andmine did not. There is an honest thing to say there, so it is said — a
DISABLEDCUDA feature removes its kernels, not the ops that do not need them, and a
[vt reference-tier]banner naming an op on acudadevice is a defect toreport rather than a slow path to accept.
A later commit cannot satisfy a per-commit gate, so the history had to be
rebuilt.
mainis never force-pushed and neither is a row branch here, so thisis a new branch with the corrected history rather than a rewrite of the pushed
one. The trees differ by exactly
docs/USAGE.md, +25 lines(
git diff --name-onlybetween the two heads). Every measurement above was takenagainst that tree and none of it is restated from memory.
Not in scope
The kernel's arithmetic, option parsing and dispatch conditions (all moved byte-for-byte);
MatmulFp8Cutlass, whose arch gate is correct because it really is a cutlass sm120 kernel; and wiring NemotronH to anything (#810 / #517 A2-Q1), which this unblocks but does not do.FOLLOWING_AGENTS_PROTOCOL
Following-Agents-Protocol: true
AI-Assisted: true
Assisted-by: AGENT:claude-opus-5 [claude-code]