Skip to content

Commit e9a5615

Browse files
fluffy314cursoragent
authored andcommitted
fix(chat): continue gRPC responses until natural EOS
Treat max_tokens as an internal streaming chunk instead of a response limit, preserve an optional safety cap, and report real stop reasons and throughput. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 6026d2e commit e9a5615

2 files changed

Lines changed: 136 additions & 20 deletions

File tree

scripts/chat_grpc.py

Lines changed: 65 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949

5050
import argparse
5151
import sys
52+
import time
5253
from typing import List, Optional
5354

5455

@@ -92,6 +93,7 @@ def _generate_and_print(
9293
tokenizer,
9394
new_tokens: List[int],
9495
max_tokens: int,
96+
max_response_tokens: int = 0,
9597
) -> int:
9698
"""Drive one append + generate cycle. Streams tokens to stdout
9799
as they arrive, returns the count emitted. The generator's
@@ -103,36 +105,74 @@ def _generate_and_print(
103105
print("kakeya> ", end="", flush=True)
104106
n = 0
105107
accumulated = []
108+
started = time.perf_counter()
109+
server_elapsed = 0.0
110+
stop_reason = "unknown"
106111
try:
107-
for token_id in session.generate(max_tokens=max_tokens):
108-
n += 1
109-
accumulated.append(token_id)
110-
# Decode incrementally — tokenizer.decode on the running
111-
# buffer gives the right text including BPE merges that
112-
# span multiple tokens. We re-decode the full buffer
113-
# each time (Qwen3-family tokenizers re-decode in <1ms
114-
# for a 64-token buffer; per-token decoding loses some
115-
# whitespace correctness on the tokenizer level).
116-
text_so_far = tokenizer.decode(
117-
accumulated, skip_special_tokens=True,
112+
while True:
113+
before = n
114+
remaining = (
115+
min(max_tokens, max_response_tokens - n)
116+
if max_response_tokens > 0 else max_tokens
118117
)
119-
# Print only the suffix that's new since last frame.
120-
if hasattr(_generate_and_print, "_last_text"):
121-
last = _generate_and_print._last_text
122-
else:
123-
last = ""
124-
new_text = text_so_far[len(last):]
125-
print(new_text, end="", flush=True)
126-
_generate_and_print._last_text = text_so_far
118+
if remaining <= 0:
119+
stop_reason = "client_safety_limit"
120+
break
121+
for token_id in session.generate(max_tokens=remaining):
122+
n += 1
123+
accumulated.append(token_id)
124+
# Decode incrementally — tokenizer.decode on the running
125+
# buffer gives the right text including BPE merges that
126+
# span multiple tokens.
127+
text_so_far = tokenizer.decode(
128+
accumulated, skip_special_tokens=True,
129+
)
130+
if hasattr(_generate_and_print, "_last_text"):
131+
last = _generate_and_print._last_text
132+
else:
133+
last = ""
134+
new_text = text_so_far[len(last):]
135+
print(new_text, end="", flush=True)
136+
_generate_and_print._last_text = text_so_far
137+
server_elapsed += float(
138+
getattr(session, "last_total_duration_seconds", 0.0) or 0.0
139+
)
140+
stop_reason = {
141+
1: "max_tokens",
142+
2: "eos",
143+
3: "cancelled",
144+
4: "truncated",
145+
}.get(getattr(session, "last_stop_reason", None), "unknown")
146+
if stop_reason != "max_tokens":
147+
break
148+
if n == before:
149+
stop_reason = "no_progress"
150+
break
127151
except KeyboardInterrupt:
128152
print("\n[interrupted]", file=sys.stderr)
153+
stop_reason = "interrupted"
129154
finally:
130155
# Reset the per-call decoder state so the next turn starts
131156
# fresh.
132157
if hasattr(_generate_and_print, "_last_text"):
133158
del _generate_and_print._last_text
134159

135160
print() # final newline
161+
elapsed = max(time.perf_counter() - started, 1e-9)
162+
measured = server_elapsed or elapsed
163+
print(
164+
f"[{n} tokens · {measured:.2f}s · {n / measured:.2f} tok/s "
165+
f"· stop={stop_reason}]",
166+
file=sys.stderr,
167+
flush=True,
168+
)
169+
if stop_reason == "client_safety_limit":
170+
print(
171+
f"[response reached optional --max-response-tokens "
172+
f"{max_response_tokens}]",
173+
file=sys.stderr,
174+
flush=True,
175+
)
136176
return n
137177

138178

@@ -161,7 +201,11 @@ def main() -> int:
161201
)
162202
ap.add_argument(
163203
"--max-tokens", type=int, default=64,
164-
help="max_tokens per turn",
204+
help="tokens per streaming Generate RPC; max_tokens continues automatically",
205+
)
206+
ap.add_argument(
207+
"--max-response-tokens", type=int, default=0,
208+
help="optional client safety cap per answer; 0 means continue until EOS",
165209
)
166210
ap.add_argument(
167211
"--system-prompt", default="You are a helpful assistant.",
@@ -250,6 +294,7 @@ def _make_session(client):
250294
tokenizer=tokenizer,
251295
new_tokens=new_tokens,
252296
max_tokens=args.max_tokens,
297+
max_response_tokens=args.max_response_tokens,
253298
)
254299
except KakeyaError as exc:
255300
print(f"[runtime error: {exc}]", file=sys.stderr)

tests/scripts/test_chat_grpc.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
from __future__ import annotations
2+
3+
from scripts.chat_grpc import _generate_and_print
4+
5+
6+
class Tokenizer:
7+
def decode(self, token_ids, *, skip_special_tokens=True):
8+
assert skip_special_tokens
9+
return " ".join(str(token) for token in token_ids)
10+
11+
12+
class Session:
13+
def __init__(self, chunks):
14+
self.chunks = list(chunks)
15+
self.calls = 0
16+
self.last_stop_reason = None
17+
self.last_total_duration_seconds = 0.0
18+
self.appended = None
19+
20+
def append(self, token_ids):
21+
self.appended = list(token_ids)
22+
23+
def generate(self, *, max_tokens):
24+
tokens, reason, seconds = self.chunks[self.calls]
25+
self.calls += 1
26+
assert len(tokens) <= max_tokens
27+
yield from tokens
28+
self.last_stop_reason = reason
29+
self.last_total_duration_seconds = seconds
30+
31+
32+
def test_continues_max_token_chunks_until_eos(capsys):
33+
session = Session([
34+
([11, 12], 1, 1.0),
35+
([21, 22], 2, 2.0),
36+
])
37+
count = _generate_and_print(session, Tokenizer(), [9], max_tokens=2)
38+
output = capsys.readouterr()
39+
assert count == 4
40+
assert session.calls == 2
41+
assert session.appended == [9]
42+
assert "11 12 21 22" in output.out
43+
assert "4 tokens" in output.err
44+
assert "1.33 tok/s" in output.err
45+
assert "stop=eos" in output.err
46+
47+
48+
def test_optional_response_cap_is_explicit(capsys):
49+
session = Session([
50+
([1, 2], 1, 1.0),
51+
([3, 4], 1, 1.0),
52+
])
53+
count = _generate_and_print(
54+
session,
55+
Tokenizer(),
56+
[9],
57+
max_tokens=2,
58+
max_response_tokens=4,
59+
)
60+
output = capsys.readouterr()
61+
assert count == 4
62+
assert "stop=client_safety_limit" in output.err
63+
assert "--max-response-tokens 4" in output.err
64+
65+
66+
def test_no_progress_breaks_continuation_loop(capsys):
67+
session = Session([
68+
([], 1, 0.1),
69+
])
70+
assert _generate_and_print(session, Tokenizer(), [9], max_tokens=2) == 0
71+
assert "stop=no_progress" in capsys.readouterr().err

0 commit comments

Comments
 (0)