Skip to content

Commit 55e6d33

Browse files
fix tau-bench token deltas for reasoning templates
Co-authored-by: Yuki <70699639+Y-L-LIU@users.noreply.github.com>
1 parent fb42ae4 commit 55e6d33

3 files changed

Lines changed: 117 additions & 18 deletions

File tree

examples/tau-bench/token_delta.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
from typing import Any
2+
3+
4+
def get_token_delta(tokenizer: Any, messages: list[dict[str, Any]]) -> tuple[list[int], list[int]]:
5+
"""Return the tokens and loss mask contributed by the last chat message."""
6+
if not messages:
7+
raise ValueError("Cannot calculate a token delta for an empty conversation")
8+
9+
is_assistant = messages[-1]["role"] == "assistant"
10+
curr = tokenizer.apply_chat_template(messages, add_generation_prompt=False, tokenize=False)
11+
prev = tokenizer.apply_chat_template(
12+
messages[:-1],
13+
add_generation_prompt=is_assistant,
14+
tokenize=False,
15+
)
16+
17+
if curr.startswith(prev):
18+
new_text = curr[len(prev) :]
19+
elif messages[-1]["role"] == "user":
20+
# Reasoning templates such as Qwen3 can rewrite history when a new user
21+
# message arrives. Render that message independently instead of slicing
22+
# the rewritten conversation at the old conversation length.
23+
new_text = tokenizer.apply_chat_template(
24+
[messages[-1]],
25+
add_generation_prompt=False,
26+
tokenize=False,
27+
)
28+
if not curr.endswith(new_text):
29+
raise ValueError("The latest user message is not a standalone suffix of the rendered conversation")
30+
else:
31+
raise ValueError("The chat template rewrote history while calculating a non-user token delta")
32+
33+
new_tokens = tokenizer.encode(new_text, add_special_tokens=False)
34+
loss_value = 1 if is_assistant else 0
35+
return new_tokens, [loss_value] * len(new_tokens)

examples/tau-bench/trainable_agents.py

Lines changed: 2 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from tau_bench.agents.base import Agent
99
from tau_bench.agents.tool_calling_agent import RESPOND_ACTION_NAME, ToolCallingAgent
1010
from tau_bench.types import Action, RunConfig
11+
from token_delta import get_token_delta
1112
from transformers import AutoTokenizer
1213

1314
from slime.rollout.sglang_rollout import GenerateState
@@ -347,24 +348,7 @@ def _get_token_delta(self, tokenizer: AutoTokenizer, messages: list[dict]) -> tu
347348
Returns:
348349
Tuple of (token_ids, loss_mask)
349350
"""
350-
curr = tokenizer.apply_chat_template(messages, add_generation_prompt=False, tokenize=False)
351-
token_ids = []
352-
loss_mask = []
353-
354-
# Case 1: last message is an assistant response
355-
if messages[-1]["role"] == "assistant":
356-
prev = tokenizer.apply_chat_template(messages[:-1], add_generation_prompt=True, tokenize=False)
357-
new_tokens = tokenizer.encode(curr[len(prev) :], add_special_tokens=False)
358-
token_ids += new_tokens
359-
loss_mask += [1] * len(new_tokens) # Mask only the new assistant tokens
360-
else:
361-
# Case 2: last message is a tool response or environment observation
362-
prev = tokenizer.apply_chat_template(messages[:-1], add_generation_prompt=False, tokenize=False)
363-
new_tokens = tokenizer.encode(curr[len(prev) :], add_special_tokens=False)
364-
token_ids += new_tokens
365-
loss_mask += [0] * len(new_tokens) # Don't mask environment/tool tokens
366-
367-
return token_ids, loss_mask
351+
return get_token_delta(tokenizer, messages)
368352

369353
def _build_final_result(
370354
self,
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import importlib.util
2+
import re
3+
from pathlib import Path
4+
5+
import pytest
6+
7+
8+
TOKEN_DELTA_PATH = Path(__file__).parents[1] / "examples" / "tau-bench" / "token_delta.py"
9+
10+
11+
def _load_get_token_delta():
12+
if not TOKEN_DELTA_PATH.exists():
13+
pytest.fail("tau-bench token_delta helper does not exist")
14+
15+
spec = importlib.util.spec_from_file_location("tau_bench_token_delta", TOKEN_DELTA_PATH)
16+
module = importlib.util.module_from_spec(spec)
17+
assert spec.loader is not None
18+
spec.loader.exec_module(module)
19+
return module.get_token_delta
20+
21+
22+
class HistoryRewritingTokenizer:
23+
"""Minimal chat template that hides old reasoning after a new user turn."""
24+
25+
@staticmethod
26+
def _strip_reasoning(content: str) -> str:
27+
return re.sub(r"<think>.*?</think>", "", content, flags=re.DOTALL)
28+
29+
def apply_chat_template(self, messages, *, add_generation_prompt, tokenize):
30+
assert tokenize is False
31+
last_user = max((i for i, message in enumerate(messages) if message["role"] == "user"), default=-1)
32+
rendered = []
33+
for i, message in enumerate(messages):
34+
content = message["content"]
35+
if message["role"] == "assistant" and i < last_user:
36+
content = self._strip_reasoning(content)
37+
rendered.append(f'<{message["role"]}>{content}</{message["role"]}>')
38+
if add_generation_prompt:
39+
rendered.append("<assistant>")
40+
return "".join(rendered)
41+
42+
@staticmethod
43+
def encode(text, *, add_special_tokens):
44+
assert add_special_tokens is False
45+
return list(text.encode())
46+
47+
@staticmethod
48+
def decode(token_ids):
49+
return bytes(token_ids).decode()
50+
51+
52+
@pytest.mark.unit
53+
def test_new_user_delta_survives_history_rewrite():
54+
get_token_delta = _load_get_token_delta()
55+
tokenizer = HistoryRewritingTokenizer()
56+
messages = [
57+
{"role": "user", "content": "first user"},
58+
{"role": "assistant", "content": "<think>first reasoning</think>first answer"},
59+
{"role": "user", "content": "second user must remain complete"},
60+
]
61+
62+
token_ids, loss_mask = get_token_delta(tokenizer, messages)
63+
64+
assert tokenizer.decode(token_ids) == "<user>second user must remain complete</user>"
65+
assert loss_mask == [0] * len(token_ids)
66+
67+
68+
@pytest.mark.unit
69+
def test_assistant_delta_keeps_existing_append_only_behavior():
70+
get_token_delta = _load_get_token_delta()
71+
tokenizer = HistoryRewritingTokenizer()
72+
messages = [
73+
{"role": "user", "content": "user question"},
74+
{"role": "assistant", "content": "<think>reasoning</think>answer"},
75+
]
76+
77+
token_ids, loss_mask = get_token_delta(tokenizer, messages)
78+
79+
assert tokenizer.decode(token_ids) == "<think>reasoning</think>answer</assistant>"
80+
assert loss_mask == [1] * len(token_ids)

0 commit comments

Comments
 (0)