Skip to content

Commit 2070572

Browse files
put a drained window back on cancellation and stop recounting a capped one
two things from review a cancelled collection loses everything it had drained, because that only lives in the local buffer. before this pr that was just the window going with the call that asked for it, but now a lead byte a previous window requeued can be in there, so the session that carries on reads the continuation alone and reports a replacement character for output that did arrive. the loop now puts the buffer back before the cancellation goes on close_pty_tail was folding the tail into output the window had already truncated. that truncates it a second time and recounts the shortened display instead of the source. for 100 bytes at a cap of 10 the window reports 25 tokens and this turned it into 11, with the visible text cut down again as well. a window that already hit the cap is now left alone, since the tail is past the cap like everything else that got dropped and the count already says the output is short both tests fail on 5e124bc
1 parent 5e124bc commit 2070572

2 files changed

Lines changed: 110 additions & 23 deletions

File tree

‎src/agents/sandbox/session/pty_output.py‎

Lines changed: 39 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,11 @@ def close_pty_tail(
4545
collection. Whatever is still waiting then has no later window to complete it, so it is
4646
replaced here rather than leaving with the session.
4747
48-
The result is truncated again because the tail is added after the window already applied
49-
``max_output_tokens``, and the returned count covers the text including it.
48+
A window that already hit ``max_output_tokens`` is left alone. Its output is at the cap, so
49+
the tail sits past it like the rest of what was dropped, and ``original_token_count`` has
50+
already told the caller the output is short. Folding it in there would truncate the text a
51+
second time and recount the shortened display instead of the source, reporting fewer tokens
52+
than the window measured.
5053
"""
5154
if not leftover:
5255
return output, original_token_count
@@ -55,9 +58,13 @@ def close_pty_tail(
5558
if not tail:
5659
return output, original_token_count
5760

61+
if original_token_count is not None:
62+
return output, original_token_count
63+
64+
# Nothing was truncated, so this really is the whole output and the count still fits it.
5865
text = output.decode("utf-8", errors="replace") + tail
59-
truncated, recounted = truncate_text_by_tokens(text, max_output_tokens)
60-
return truncated.encode("utf-8", errors="replace"), recounted
66+
truncated, counted = truncate_text_by_tokens(text, max_output_tokens)
67+
return truncated.encode("utf-8", errors="replace"), counted
6168

6269

6370
async def flush_pty_tail(
@@ -95,29 +102,38 @@ async def collect_pty_output(
95102
deadline = time.monotonic() + (yield_time_ms / 1000)
96103
output = bytearray()
97104

98-
while True:
99-
async with output_lock:
100-
while output_chunks:
101-
output.extend(output_chunks.popleft())
102-
103-
if time.monotonic() >= deadline:
104-
break
105-
106-
if is_done():
105+
try:
106+
while True:
107107
async with output_lock:
108108
while output_chunks:
109109
output.extend(output_chunks.popleft())
110-
break
111-
112-
remaining_s = deadline - time.monotonic()
113-
if remaining_s <= 0:
114-
break
115110

116-
try:
117-
await asyncio.wait_for(output_notify.wait(), timeout=remaining_s)
118-
except asyncio.TimeoutError:
119-
break
120-
output_notify.clear()
111+
if time.monotonic() >= deadline:
112+
break
113+
114+
if is_done():
115+
async with output_lock:
116+
while output_chunks:
117+
output.extend(output_chunks.popleft())
118+
break
119+
120+
remaining_s = deadline - time.monotonic()
121+
if remaining_s <= 0:
122+
break
123+
124+
try:
125+
await asyncio.wait_for(output_notify.wait(), timeout=remaining_s)
126+
except asyncio.TimeoutError:
127+
break
128+
output_notify.clear()
129+
except asyncio.CancelledError:
130+
# Everything drained so far lives only in this buffer, and the session outlives a
131+
# cancelled call, so put it back before the cancellation goes on. Otherwise the next
132+
# window reads a continuation whose lead byte went with the abandoned call and reports
133+
# a replacement character for output that did arrive.
134+
if output:
135+
output_chunks.appendleft(bytes(output))
136+
raise
121137

122138
# Output is collected in repeated windows over one persistent deque, so a character
123139
# whose bytes straddle a window boundary used to be replaced twice and lost. An

‎tests/sandbox/test_pty_output.py‎

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
collect_pty_output,
1212
flush_pty_tail,
1313
)
14+
from agents.sandbox.session.pty_types import truncate_text_by_tokens
1415

1516

1617
@pytest.mark.asyncio
@@ -292,3 +293,73 @@ async def window(yield_time_ms: int) -> bytes:
292293
chunks.append(raw[1:])
293294
done["value"] = True
294295
assert (await window(60)).decode("utf-8") == "\u00e9"
296+
297+
298+
@pytest.mark.asyncio
299+
async def test_collect_pty_output_puts_a_drained_window_back_when_cancelled() -> None:
300+
# the window drains the lead byte a previous one requeued, then the call is cancelled while
301+
# it waits. the session lives on, so those bytes have to go back or its next read reports a
302+
# replacement character for output that did arrive
303+
raw = "\u00e9".encode()
304+
chunks: deque[bytes] = deque([raw[:1]])
305+
lock = asyncio.Lock()
306+
notify = asyncio.Event()
307+
done = {"value": False}
308+
309+
async def window(yield_time_ms: int) -> bytes:
310+
notify.set()
311+
collected, _ = await collect_pty_output(
312+
output_chunks=chunks,
313+
output_lock=lock,
314+
output_notify=notify,
315+
is_done=lambda: done["value"],
316+
yield_time_ms=yield_time_ms,
317+
max_output_tokens=None,
318+
)
319+
return collected
320+
321+
task = asyncio.create_task(window(60_000))
322+
await asyncio.sleep(0.05)
323+
assert not chunks
324+
325+
task.cancel()
326+
with contextlib.suppress(asyncio.CancelledError):
327+
await task
328+
329+
assert list(chunks) == [raw[:1]]
330+
331+
chunks.append(raw[1:])
332+
done["value"] = True
333+
assert (await window(60)).decode("utf-8") == "\u00e9"
334+
335+
336+
def test_close_pty_tail_leaves_a_window_that_already_hit_the_cap_alone() -> None:
337+
# the window truncated, so its output is at the cap and the count describes the source.
338+
# folding a tail in here would truncate a second time and recount the shortened display
339+
display, count = truncate_text_by_tokens("a" * 100, 10)
340+
assert count is not None
341+
342+
output, kept = close_pty_tail(
343+
leftover="\u00e9".encode()[:1],
344+
output=display.encode(),
345+
original_token_count=count,
346+
max_output_tokens=10,
347+
)
348+
349+
assert output == display.encode()
350+
assert kept == count
351+
352+
353+
def test_close_pty_tail_still_folds_the_tail_into_an_untruncated_window() -> None:
354+
display, count = truncate_text_by_tokens("hi ", 10)
355+
assert count is None
356+
357+
output, recounted = close_pty_tail(
358+
leftover="\u00e9".encode()[:1],
359+
output=display.encode(),
360+
original_token_count=count,
361+
max_output_tokens=10,
362+
)
363+
364+
assert output.decode("utf-8") == "hi \ufffd"
365+
assert recounted is None

0 commit comments

Comments
 (0)