From 3ed23a1cdf865e643df683eaec8af2e6004e0ec8 Mon Sep 17 00:00:00 2001
From: Azeez Ishaqui <95100110+AzeezIsh@users.noreply.github.com>
Date: Sun, 18 Oct 2026 00:05:24 -0400
Subject: [PATCH 1/5] scheduler: resolve the tool-parameter marker ids from the
served tokenizer (Nemotron large tool calls were cut at 1024 tokens)
update_tool_param_state tracks whether generation is inside a qwen3_coder
value, whose tokens are exempt from the 1024-token
envelope-stuck cap. It compared against hard-coded Qwen3.6 ids
(< 27, = 28, > 29, 510, parameter 15704). Nemotron 3.5 Super VL serves
the same XML tool format with its own tokenizer (1060 / 1061 / 1062 and a
merged >\n 1561 / 1885 / 31960; the Qwen ids are ,
, ... there), so a value was never recognised: every file
content token counted as envelope, any argument past 1024 tokens tripped
tool_envelope_stuck, and the client got a salvaged fragment. Observed in
an agent session: large create_file writes landing as the 11 bytes
'\n
forms, and read by the guard. A tokenizer that does not encode a marker as
one token leaves the tracking and the cap off instead of misfiring;
--tool-max-tokens still bounds a runaway envelope. Qwen ids resolve to the
same values as before, so Qwen behaviour is unchanged.
Tests: the resolver on Qwen and Nemotron tables; the real guard on a
Nemotron write with a 1500-token content (with closes) finishes
with the Nemotron markers, trips with the Qwen ids (the old behaviour),
stays off with none, and a never-closed envelope is still cut.
Co-Authored-By: Claude Opus 5.5
---
.../serve_phases/tokenizer_runtime.rs | 13 ++
.../spark-server/src/scheduler/emit_step.rs | 31 ++--
crates/spark-server/src/scheduler/mod.rs | 1 +
.../src/scheduler/tool_markers.rs | 146 ++++++++++++++++++
.../src/scheduler/tool_markers_emit_tests.rs | 92 +++++++++++
5 files changed, 269 insertions(+), 14 deletions(-)
create mode 100644 crates/spark-server/src/scheduler/tool_markers.rs
create mode 100644 crates/spark-server/src/scheduler/tool_markers_emit_tests.rs
diff --git a/crates/spark-server/src/main_modules/serve_phases/tokenizer_runtime.rs b/crates/spark-server/src/main_modules/serve_phases/tokenizer_runtime.rs
index 826144d70..28cfc705b 100644
--- a/crates/spark-server/src/main_modules/serve_phases/tokenizer_runtime.rs
+++ b/crates/spark-server/src/main_modules/serve_phases/tokenizer_runtime.rs
@@ -287,6 +287,19 @@ pub(crate) fn resolve_tokenizer_runtime(
if let Some(tid) = tool_call_end_token {
tracing::info!("Tool call end token: {} ({})", tid, tc_end_str);
}
+ // qwen3_coder XML parameter markers for the envelope-stuck guard, from
+ // THIS tokenizer (they were hard-coded to Qwen3.6 ids; Nemotron's differ).
+ let tool_markers = crate::scheduler::tool_markers::ToolMarkers::resolve(|s| {
+ tokenizer.encode(s).ok()
+ });
+ match tool_markers {
+ Some(m) => tracing::info!("Tool parameter markers: {m:?}"),
+ None => tracing::warn!(
+ "Tool parameter markers unresolved for this tokenizer: parameter-value \
+ tracking and the envelope-stuck cap are off (--tool-max-tokens still applies)"
+ ),
+ }
+ crate::scheduler::tool_markers::set(tool_markers);
let grammar_engine = {
let stop_ids: Vec = eos_tokens.iter().map(|&id| id as i32).collect();
diff --git a/crates/spark-server/src/scheduler/emit_step.rs b/crates/spark-server/src/scheduler/emit_step.rs
index ec30b6b76..f542da30d 100644
--- a/crates/spark-server/src/scheduler/emit_step.rs
+++ b/crates/spark-server/src/scheduler/emit_step.rs
@@ -625,8 +625,10 @@ pub enum StartPrefillResult {
// - `a.param_body_chars_emitted` ++ per non-close body token
// - `a.finished` forced when stuck >MAX_TOOL_BODY_TOKENS
//
-// Token IDs are Qwen3.6 byte-level BPE (verified via /tokenize 2026-05-25):
-// 27 = `<`, 28 = `=`, 29 = `>`, 510 = ``, 15704 = `parameter`.
+// Token ids are the SERVED tokenizer's (`tool_markers`), resolved at load:
+// Qwen3.6/3.8 `<`=27 `=`=28 `>`=29 ``=510 `parameter`=15704; Nemotron 3.5
+// Super VL `<`=1060 `=`=1061 `>`=1062 (and `>\n`=1561) ``=1885
+// `parameter`=31960. They used to be hard-coded to Qwen's.
/// Cap on tool-call ENVELOPE tokens (everything inside `…`
/// that is NOT a parameter-value body). Catches a model that opens ``
@@ -669,6 +671,13 @@ pub fn update_tool_param_state(a: &mut ActiveSeq, tok: u32, quiet: bool) {
if !a.inside_tool_body {
return;
}
+ // Without the served tokenizer's markers the value body cannot be told
+ // from the envelope, and counting file content as envelope kills large
+ // writes at MAX_TOOL_BODY_TOKENS. Leave both the tracking and the cap off;
+ // `--tool-max-tokens` still bounds a runaway envelope.
+ let Some(m) = super::tool_markers::get() else {
+ return;
+ };
// CC6 (2026-06-07): count ONLY envelope tokens — tokens of a
// `` VALUE (the file content of a `write` call) are exempt,
// so a legitimately large single-file Write streams without tripping the
@@ -690,12 +699,6 @@ pub fn update_tool_param_state(a: &mut ActiveSeq, tok: u32, quiet: bool) {
a.finished = true;
}
- const TOK_LT: u32 = 27;
- const TOK_PARAMETER: u32 = 15704;
- const TOK_EQ: u32 = 28;
- const TOK_GT: u32 = 29;
- const TOK_LT_SLASH: u32 = 510;
-
if a.inside_parameter_body {
// P0-1 (2026-07-09): PROVISIONAL close detection. The old code
// exited the body on ANY `` token (510) — but since the
@@ -713,7 +716,7 @@ pub fn update_tool_param_state(a: &mut ActiveSeq, tok: u32, quiet: bool) {
// next exact close still exits.
match a.param_close_pending {
0 => {
- if tok == TOK_LT_SLASH {
+ if tok == m.lt_slash {
a.param_close_pending = 1;
} else {
// Any non-close body token advances the counter. The
@@ -724,7 +727,7 @@ pub fn update_tool_param_state(a: &mut ActiveSeq, tok: u32, quiet: bool) {
}
}
1 => {
- if tok == TOK_PARAMETER {
+ if tok == m.parameter {
a.param_close_pending = 2;
} else {
// `` was value content (e.g. ``), not a close.
@@ -734,7 +737,7 @@ pub fn update_tool_param_state(a: &mut ActiveSeq, tok: u32, quiet: bool) {
}
_ => {
a.param_close_pending = 0;
- if tok == TOK_GT {
+ if m.is_gt(tok) {
// Confirmed `` — exit body. Also reset the
// envelope streak: a confirmed close IS forward progress
// (the doc comment above always claimed this reset; the
@@ -756,7 +759,7 @@ pub fn update_tool_param_state(a: &mut ActiveSeq, tok: u32, quiet: bool) {
// Not yet inside_parameter_body: scan for `` opener
// ending at this `>` (29). Lookback 8 tokens for `[27, 15704, 28]`
// signature without an intervening close.
- if tok != TOK_GT {
+ if !m.is_gt(tok) {
return;
}
// Auto-detect whether `tok` is already in output_tokens (caller
@@ -776,7 +779,7 @@ pub fn update_tool_param_state(a: &mut ActiveSeq, tok: u32, quiet: bool) {
let window = &a.output_tokens[start..n_for_lookback];
let mut sig_idx: Option = None;
for i in 0..window.len().saturating_sub(2) {
- if window[i] == TOK_LT && window[i + 1] == TOK_PARAMETER && window[i + 2] == TOK_EQ {
+ if window[i] == m.lt && window[i + 1] == m.parameter && window[i + 2] == m.eq {
sig_idx = Some(i + 3);
}
}
@@ -786,7 +789,7 @@ pub fn update_tool_param_state(a: &mut ActiveSeq, tok: u32, quiet: bool) {
let body_segment = &window[after_eq..];
let intervening_close = body_segment
.iter()
- .any(|&t| t == TOK_LT_SLASH || t == TOK_GT);
+ .any(|&t| t == m.lt_slash || m.is_gt(t));
if !intervening_close {
a.inside_parameter_body = true;
a.param_body_chars_emitted = 0;
diff --git a/crates/spark-server/src/scheduler/mod.rs b/crates/spark-server/src/scheduler/mod.rs
index c2ed8f13f..c1a7de47b 100644
--- a/crates/spark-server/src/scheduler/mod.rs
+++ b/crates/spark-server/src/scheduler/mod.rs
@@ -75,6 +75,7 @@ mod prefill_preempt;
mod repetition;
mod rollback;
mod sample_step;
+pub(crate) mod tool_markers;
pub mod sched_ctx;
mod shared_prefix;
#[cfg(test)]
diff --git a/crates/spark-server/src/scheduler/tool_markers.rs b/crates/spark-server/src/scheduler/tool_markers.rs
new file mode 100644
index 000000000..dacb79e02
--- /dev/null
+++ b/crates/spark-server/src/scheduler/tool_markers.rs
@@ -0,0 +1,146 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+
+//! Token ids of the qwen3_coder XML parameter markers (`<`, `=`, `>`, ``,
+//! `parameter`) for the tokenizer being served.
+//!
+//! `emit_step::update_tool_param_state` tracks whether generation is inside a
+//! `` VALUE, whose tokens are exempt from the 1024-token
+//! envelope-stuck cap. It used to compare against Qwen3.6 BPE ids
+//! (27/28/29/510/15704). Nemotron 3.5 Super VL serves the same XML tool format
+//! with its own tokenizer (`<`=1060, `=`=1061, `>`=1062, ``=1885,
+//! `parameter`=31960, and `>\n` merged into one token, 1561), so the value
+//! body was never recognised: every file-content token counted as envelope,
+//! any argument past 1024 tokens tripped `tool_envelope_stuck`, and the
+//! client got a salvaged fragment (`` alone, plus `>\n` / `>\n\n` where the tokenizer merges them into
+ /// one token (Nemotron does; Qwen does not).
+ pub gt: [Option; 3],
+ pub lt_slash: u32,
+ pub parameter: u32,
+}
+
+impl ToolMarkers {
+ pub fn is_gt(&self, tok: u32) -> bool {
+ self.gt.contains(&Some(tok))
+ }
+
+ /// Resolve from an encoder (`text -> ids`, no special tokens). `None` when
+ /// any required marker is not a single token.
+ pub fn resolve(encode: impl Fn(&str) -> Option>) -> Option {
+ let one = |s: &str| encode(s).filter(|ids| ids.len() == 1).map(|ids| ids[0]);
+ Some(Self {
+ lt: one("<")?,
+ eq: one("=")?,
+ gt: [one(">"), one(">\n"), one(">\n\n")],
+ lt_slash: one("")?,
+ parameter: one("parameter")?,
+ })
+ .filter(|m| m.gt[0].is_some())
+ }
+}
+
+static MARKERS: RwLock` with the served tokenizer's markers, and is what
+//! the Qwen-hardcoded markers used to kill at 1024 tokens.
+
+use super::fixtures::{NEMOTRON, QWEN};
+use super::{TEST_LOCK, ToolMarkers, set};
+use crate::scheduler::emit_step::update_tool_param_state;
+use crate::scheduler::test_support::test_seq;
+
+const NEMO_TOOL_CALL: u32 = 14;
+const NEMO_TOOL_CALL_END: u32 = 15;
+
+/// `` `\n` `\n/tmp/a.html\n\n`
+/// `\n` + body + `\n` ``,
+/// in Nemotron ids (tokenizer.json, 2026-10-18).
+fn nemotron_write(body_tokens: usize) -> Vec {
+ let mut t = vec![NEMO_TOOL_CALL];
+ t.extend([1060, 5165, 1061, 16539, 1561]); // \n
+ t.extend([1060, 31960, 1061, 7368, 1561]); // \n
+ t.extend([1047, 20307, 22139, 7120, 1010]); // /tmp/a.html\n
+ t.extend([1885, 31960, 1561]); // \n
+ t.extend([1060, 31960, 1061, 5431, 1561]); // \n
+ for i in 0..body_tokens {
+ // Mostly prose ids, with a `\n` ( div >\n) every 50 tokens.
+ if i % 50 == 49 {
+ t.extend([1885, 1950, 1561]);
+ } else {
+ t.push(41199 + (i as u32 % 7));
+ }
+ }
+ t.extend([1885, 31960, 1561]); // \n
+ t.extend([1885, 5165, 1062]); //
+ t.push(NEMO_TOOL_CALL_END);
+ t
+}
+
+/// Drive the guard the way `emit_token` does (state update, then push).
+fn run(markers: Option, tokens: &[u32]) -> (bool, Option<&'static str>, bool) {
+ set(markers);
+ let (mut a, _rx) = test_seq(Vec::new(), 100_000, None, 0);
+ a.tool_call_start_token = Some(NEMO_TOOL_CALL);
+ a.tool_call_end_token = Some(NEMO_TOOL_CALL_END);
+ for &tok in tokens {
+ update_tool_param_state(&mut a, tok, true);
+ a.output_tokens.push(tok);
+ if a.finished {
+ break;
+ }
+ }
+ set(None);
+ (a.finished, a.guard_stop, a.inside_tool_body)
+}
+
+#[test]
+fn nemotron_markers_let_a_large_write_finish() {
+ let _g = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
+ let (finished, guard, inside) = run(Some(NEMOTRON), &nemotron_write(1500));
+ assert!(!finished, "guard fired: {guard:?}");
+ assert_eq!(guard, None);
+ assert!(!inside, " should close the body");
+}
+
+#[test]
+fn qwen_ids_on_a_nemotron_stream_reproduce_the_kill() {
+ // The pre-fix behaviour: Qwen ids never match a Nemotron `` then 1100 envelope tokens
+ // that never open a parameter value.
+ let _g = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
+ let mut t = vec![NEMO_TOOL_CALL];
+ t.extend(std::iter::repeat_n(41199u32, 1100));
+ let (finished, guard, _) = run(Some(NEMOTRON), &t);
+ assert!(finished);
+ assert_eq!(guard, Some("tool_envelope_stuck"));
+}
From 217776ccc4b77dee7b2e365d2cdf9d57c31b281a Mon Sep 17 00:00:00 2001
From: Azeez Ishaqui <95100110+AzeezIsh@users.noreply.github.com>
Date: Sun, 18 Oct 2026 00:06:48 -0400
Subject: [PATCH 2/5] scheduler: rustfmt the tool-marker wiring
Co-Authored-By: Claude Opus 5.5
---
.../src/main_modules/serve_phases/tokenizer_runtime.rs | 5 ++---
crates/spark-server/src/scheduler/emit_step.rs | 4 +---
crates/spark-server/src/scheduler/mod.rs | 2 +-
3 files changed, 4 insertions(+), 7 deletions(-)
diff --git a/crates/spark-server/src/main_modules/serve_phases/tokenizer_runtime.rs b/crates/spark-server/src/main_modules/serve_phases/tokenizer_runtime.rs
index 28cfc705b..b87603fb1 100644
--- a/crates/spark-server/src/main_modules/serve_phases/tokenizer_runtime.rs
+++ b/crates/spark-server/src/main_modules/serve_phases/tokenizer_runtime.rs
@@ -289,9 +289,8 @@ pub(crate) fn resolve_tokenizer_runtime(
}
// qwen3_coder XML parameter markers for the envelope-stuck guard, from
// THIS tokenizer (they were hard-coded to Qwen3.6 ids; Nemotron's differ).
- let tool_markers = crate::scheduler::tool_markers::ToolMarkers::resolve(|s| {
- tokenizer.encode(s).ok()
- });
+ let tool_markers =
+ crate::scheduler::tool_markers::ToolMarkers::resolve(|s| tokenizer.encode(s).ok());
match tool_markers {
Some(m) => tracing::info!("Tool parameter markers: {m:?}"),
None => tracing::warn!(
diff --git a/crates/spark-server/src/scheduler/emit_step.rs b/crates/spark-server/src/scheduler/emit_step.rs
index f542da30d..56b763cce 100644
--- a/crates/spark-server/src/scheduler/emit_step.rs
+++ b/crates/spark-server/src/scheduler/emit_step.rs
@@ -787,9 +787,7 @@ pub fn update_tool_param_state(a: &mut ActiveSeq, tok: u32, quiet: bool) {
// Check no intervening `` or `>` in the KEY span between
// ``.
let body_segment = &window[after_eq..];
- let intervening_close = body_segment
- .iter()
- .any(|&t| t == m.lt_slash || m.is_gt(t));
+ let intervening_close = body_segment.iter().any(|&t| t == m.lt_slash || m.is_gt(t));
if !intervening_close {
a.inside_parameter_body = true;
a.param_body_chars_emitted = 0;
diff --git a/crates/spark-server/src/scheduler/mod.rs b/crates/spark-server/src/scheduler/mod.rs
index c1a7de47b..0aab5220a 100644
--- a/crates/spark-server/src/scheduler/mod.rs
+++ b/crates/spark-server/src/scheduler/mod.rs
@@ -75,7 +75,6 @@ mod prefill_preempt;
mod repetition;
mod rollback;
mod sample_step;
-pub(crate) mod tool_markers;
pub mod sched_ctx;
mod shared_prefix;
#[cfg(test)]
@@ -91,6 +90,7 @@ mod teardown;
mod test_support;
#[cfg(test)]
mod think_skip_tests;
+pub(crate) mod tool_markers;
mod types;
#[cfg(test)]
mod verify_bias_tests;
From b1a9621fa2e974ad13a6c4d53f776a23b879147a Mon Sep 17 00:00:00 2001
From: Azeez Ishaqui <95100110+AzeezIsh@users.noreply.github.com>
Date: Sun, 18 Oct 2026 00:08:48 -0400
Subject: [PATCH 3/5] scheduler: tool markers default to the Qwen ids until a
tokenizer installs its own; per-thread in tests
The first version started the slot empty, which turned the parameter
tracking off for any caller that never loads a tokenizer, including the
existing think_commit span test that replays a Qwen tool call. The slot now
starts at the Qwen ids the guard was hard-coded to (every serve path
installs the served tokenizer's markers in tokenizer_runtime). Under
cfg(test) the slot is thread-local, so tests installing other markers
cannot race the ones that rely on the default. The emit tests also reset
the fixture's finished flag; the shared fixture builds a finished
sequence.
Co-Authored-By: Claude Opus 5.5
---
.../src/scheduler/tool_markers.rs | 50 +++++++++++++------
.../src/scheduler/tool_markers_emit_tests.rs | 11 ++--
2 files changed, 40 insertions(+), 21 deletions(-)
diff --git a/crates/spark-server/src/scheduler/tool_markers.rs b/crates/spark-server/src/scheduler/tool_markers.rs
index dacb79e02..accf6691e 100644
--- a/crates/spark-server/src/scheduler/tool_markers.rs
+++ b/crates/spark-server/src/scheduler/tool_markers.rs
@@ -18,6 +18,7 @@
//! token resolves to `None`, and the guard then stays off rather than
//! misfiring: `--tool-max-tokens` still bounds a runaway envelope.
+#[cfg(not(test))]
use std::sync::RwLock;
/// The marker ids for one tokenizer.
@@ -52,36 +53,55 @@ impl ToolMarkers {
}
}
-static MARKERS: RwLock