Skip to content

Commit 7892486

Browse files
Better incremental tmux.
1 parent 1bda46c commit 7892486

2 files changed

Lines changed: 269 additions & 30 deletions

File tree

src/smolagents/bp_tools_tmux.py

Lines changed: 54 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,29 @@
2929
TMUX_PREFIX = "bpsa_"
3030
_MAX_READ_LINES = 2000
3131

32-
# Per-session incremental read state: session_name → (line_count, last_line_content)
33-
_last_read: dict[str, tuple[int, str]] = {}
32+
# Per-session incremental read state:
33+
# session_name → (line_before_anchor, anchor_line, last_line)
34+
# The anchor is the second-to-last content line (avoids the active prompt).
35+
# line_before_anchor disambiguates when the anchor appears multiple times.
36+
# last_line lets us suppress "(no new output)" re-sends of an unchanged prompt.
37+
_last_read: dict[str, tuple[str, str, str]] = {}
38+
39+
40+
def _make_fingerprint(all_lines: list[str]) -> tuple[str, str, str]:
41+
"""Return (line_before_anchor, anchor, last_line) from the tail.
42+
43+
The anchor is the second-to-last line to avoid the active prompt which
44+
mutates as the user types. The line before the anchor disambiguates
45+
when the anchor content is duplicated elsewhere in the buffer.
46+
last_line is stored so we can detect when only the unchanged prompt
47+
follows the anchor (i.e. truly no new output).
48+
"""
49+
if len(all_lines) >= 3:
50+
return (all_lines[-3], all_lines[-2], all_lines[-1])
51+
if len(all_lines) == 2:
52+
return ("", all_lines[0], all_lines[1])
53+
# len == 1
54+
return ("", all_lines[0], all_lines[0])
3455

3556

3657
def _full_name(session_name: str) -> str:
@@ -207,38 +228,41 @@ def forward(self, session_name: str, lines: int | None = None, incremental: bool
207228
all_lines.pop()
208229
if not all_lines:
209230
return "(empty screen)"
231+
232+
new_lines = None
210233
prev = _last_read.get(session_name) if incremental else None
211-
if prev:
212-
prev_count, prev_anchor = prev
213-
# Anchor is the second-to-last line (avoids the active prompt
214-
# which mutates when a command is typed).
215-
anchor_idx = prev_count - 2 if prev_count >= 2 else prev_count - 1
216-
# Safety check: does the anchor line still match?
217-
if anchor_idx < len(all_lines) and all_lines[anchor_idx] == prev_anchor:
218-
# Return from one past the anchor onwards (includes the old
219-
# last line which may have changed — that's intentional).
220-
new_lines = all_lines[anchor_idx + 1:]
221-
if not new_lines:
222-
# Update state and report no new output.
223-
_last_read[session_name] = (
224-
len(all_lines),
225-
all_lines[-2] if len(all_lines) >= 2 else all_lines[-1],
226-
)
234+
if prev is not None:
235+
prev_before, prev_anchor, prev_last = prev
236+
# Search backwards for the two-line fingerprint to find where
237+
# we last read up to. This is index-independent, so trailing-
238+
# blank fluctuations and buffer trimming don't break it.
239+
found = -1
240+
for i in range(len(all_lines) - 1, 0, -1):
241+
if all_lines[i] == prev_anchor and all_lines[i - 1] == prev_before:
242+
found = i
243+
break
244+
if found == -1:
245+
# Two-line pair not found — try single-line fallback
246+
# (handles short buffers and the first read after a reset).
247+
for i in range(len(all_lines) - 1, -1, -1):
248+
if all_lines[i] == prev_anchor:
249+
found = i
250+
break
251+
if found >= 0:
252+
new_lines = all_lines[found + 1:]
253+
if not new_lines or new_lines == [prev_last]:
254+
# Nothing after the anchor, or only the same trailing
255+
# line (typically the unchanged prompt).
256+
_last_read[session_name] = _make_fingerprint(all_lines)
227257
return "(no new output)"
228-
else:
229-
# Content mismatch — fall back to full tail.
230-
new_lines = all_lines[-lines:]
231-
else:
232-
# First read or non-incremental — full tail.
258+
259+
if new_lines is None:
260+
# First read, non-incremental, or anchor not found — full tail.
233261
new_lines = all_lines[-lines:]
234-
# Cap at max requested lines from the end.
262+
263+
# Cap at max requested lines.
235264
new_lines = new_lines[-lines:]
236-
# Update state for next incremental read.
237-
# Anchor on second-to-last line to avoid the active prompt.
238-
_last_read[session_name] = (
239-
len(all_lines),
240-
all_lines[-2] if len(all_lines) >= 2 else all_lines[-1],
241-
)
265+
_last_read[session_name] = _make_fingerprint(all_lines)
242266
return "\n".join(new_lines)
243267

244268

tests/test_bp_tools_tmux.py

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Unit tests for TmuxReadTool incremental reading logic.
4+
5+
These tests mock tmux so they run without a real tmux server.
6+
"""
7+
8+
import os
9+
import sys
10+
from unittest.mock import patch, MagicMock
11+
12+
import pytest
13+
14+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src'))
15+
16+
from smolagents.bp_tools_tmux import (
17+
TmuxReadTool,
18+
_last_read,
19+
_make_fingerprint,
20+
)
21+
22+
23+
@pytest.fixture(autouse=True)
24+
def clear_state():
25+
"""Reset incremental read state between tests."""
26+
_last_read.clear()
27+
yield
28+
_last_read.clear()
29+
30+
31+
def _mock_capture(lines: list[str]):
32+
"""Return a mock _run_tmux result simulating capture-pane output."""
33+
result = MagicMock()
34+
result.returncode = 0
35+
result.stdout = "\n".join(lines) + "\n"
36+
return result
37+
38+
39+
class TestMakeFingerprint:
40+
def test_three_or_more_lines(self):
41+
assert _make_fingerprint(["a", "b", "c"]) == ("a", "b", "c")
42+
assert _make_fingerprint(["a", "b", "c", "d"]) == ("b", "c", "d")
43+
44+
def test_two_lines(self):
45+
assert _make_fingerprint(["a", "b"]) == ("", "a", "b")
46+
47+
def test_one_line(self):
48+
assert _make_fingerprint(["a"]) == ("", "a", "a")
49+
50+
51+
class TestTmuxReadIncremental:
52+
"""Test the incremental reading logic of TmuxReadTool."""
53+
54+
def setup_method(self):
55+
self.tool = TmuxReadTool()
56+
57+
@patch("smolagents.bp_tools_tmux._run_tmux")
58+
def test_first_read_returns_full_tail(self, mock_tmux):
59+
lines = ["line1", "line2", "line3", "line4", "line5"]
60+
mock_tmux.return_value = _mock_capture(lines)
61+
62+
result = self.tool.forward("sess", lines=3)
63+
assert result == "line3\nline4\nline5"
64+
65+
@patch("smolagents.bp_tools_tmux._run_tmux")
66+
def test_incremental_returns_only_new_lines(self, mock_tmux):
67+
# First read: 5 lines
68+
lines1 = ["line1", "line2", "line3", "line4", "prompt$"]
69+
mock_tmux.return_value = _mock_capture(lines1)
70+
self.tool.forward("sess", lines=20)
71+
72+
# Second read: 3 new lines appended
73+
lines2 = lines1 + ["output_a", "output_b", "new_prompt$"]
74+
mock_tmux.return_value = _mock_capture(lines2)
75+
result = self.tool.forward("sess", lines=20)
76+
77+
# Should see everything after the anchor (line3), which is:
78+
# line4 (old last line), output_a, output_b, new_prompt$
79+
assert "output_a" in result
80+
assert "output_b" in result
81+
assert "new_prompt$" in result
82+
83+
@patch("smolagents.bp_tools_tmux._run_tmux")
84+
def test_no_new_output(self, mock_tmux):
85+
lines = ["line1", "line2", "line3", "prompt$"]
86+
mock_tmux.return_value = _mock_capture(lines)
87+
self.tool.forward("sess", lines=20)
88+
89+
# Same content again
90+
mock_tmux.return_value = _mock_capture(lines)
91+
result = self.tool.forward("sess", lines=20)
92+
assert result == "(no new output)"
93+
94+
@patch("smolagents.bp_tools_tmux._run_tmux")
95+
def test_non_incremental_always_returns_full_tail(self, mock_tmux):
96+
lines = ["line1", "line2", "line3", "prompt$"]
97+
mock_tmux.return_value = _mock_capture(lines)
98+
self.tool.forward("sess", lines=20)
99+
100+
# Same content, but incremental=False
101+
mock_tmux.return_value = _mock_capture(lines)
102+
result = self.tool.forward("sess", lines=20, incremental=False)
103+
assert "line1" in result
104+
assert "prompt$" in result
105+
106+
@patch("smolagents.bp_tools_tmux._run_tmux")
107+
def test_trailing_blanks_do_not_break_anchor(self, mock_tmux):
108+
"""Issue #2: varying trailing blanks should not cause fallback."""
109+
lines1 = ["line1", "line2", "line3", "prompt$"]
110+
mock_tmux.return_value = _mock_capture(lines1)
111+
self.tool.forward("sess", lines=20)
112+
113+
# Same real content but with trailing blanks appended (tmux does this)
114+
lines2 = ["line1", "line2", "line3", "prompt$", "", "", ""]
115+
mock_tmux.return_value = _mock_capture(lines2)
116+
result = self.tool.forward("sess", lines=20)
117+
assert result == "(no new output)"
118+
119+
@patch("smolagents.bp_tools_tmux._run_tmux")
120+
def test_single_line_content_change_detected(self, mock_tmux):
121+
"""Issue #5: progress counter updating in-place should be visible."""
122+
lines1 = ["progress: 50%"]
123+
mock_tmux.return_value = _mock_capture(lines1)
124+
self.tool.forward("sess", lines=20)
125+
126+
# Content changed (in-place update)
127+
lines2 = ["progress: 100%"]
128+
mock_tmux.return_value = _mock_capture(lines2)
129+
result = self.tool.forward("sess", lines=20)
130+
# Anchor won't match → falls back to full tail → we see the update
131+
assert "100%" in result
132+
assert result != "(no new output)"
133+
134+
@patch("smolagents.bp_tools_tmux._run_tmux")
135+
def test_single_line_unchanged(self, mock_tmux):
136+
lines = ["waiting..."]
137+
mock_tmux.return_value = _mock_capture(lines)
138+
self.tool.forward("sess", lines=20)
139+
140+
mock_tmux.return_value = _mock_capture(lines)
141+
result = self.tool.forward("sess", lines=20)
142+
assert result == "(no new output)"
143+
144+
@patch("smolagents.bp_tools_tmux._run_tmux")
145+
def test_duplicate_lines_use_two_line_fingerprint(self, mock_tmux):
146+
"""Two-line fingerprint should disambiguate duplicate anchor lines."""
147+
# The anchor line "---" appears multiple times
148+
lines1 = ["header", "---", "body1", "---", "prompt$"]
149+
mock_tmux.return_value = _mock_capture(lines1)
150+
self.tool.forward("sess", lines=20)
151+
152+
# New content after the last "---"
153+
lines2 = ["header", "---", "body1", "---", "prompt$", "new_output", "prompt2$"]
154+
mock_tmux.return_value = _mock_capture(lines2)
155+
result = self.tool.forward("sess", lines=20)
156+
# Fingerprint is ("body1", "---"), matching the LAST occurrence.
157+
# New lines start after that "---" → prompt$, new_output, prompt2$
158+
assert "new_output" in result
159+
160+
@patch("smolagents.bp_tools_tmux._run_tmux")
161+
def test_empty_screen(self, mock_tmux):
162+
result = MagicMock()
163+
result.returncode = 0
164+
result.stdout = "\n"
165+
mock_tmux.return_value = result
166+
assert self.tool.forward("sess") == "(empty screen)"
167+
168+
@patch("smolagents.bp_tools_tmux._run_tmux")
169+
def test_error_propagated(self, mock_tmux):
170+
result = MagicMock()
171+
result.returncode = 1
172+
result.stderr = "session not found"
173+
mock_tmux.return_value = result
174+
assert "ERROR" in self.tool.forward("sess")
175+
176+
@patch("smolagents.bp_tools_tmux._run_tmux")
177+
def test_lines_cap_respected(self, mock_tmux):
178+
lines = [f"line{i}" for i in range(50)]
179+
mock_tmux.return_value = _mock_capture(lines)
180+
result = self.tool.forward("sess", lines=5)
181+
assert len(result.splitlines()) == 5
182+
183+
@patch("smolagents.bp_tools_tmux._run_tmux")
184+
def test_buffer_trim_causes_graceful_fallback(self, mock_tmux):
185+
"""Issue #1: if tmux trims old lines, anchor search falls back to full tail."""
186+
lines1 = [f"line{i}" for i in range(20)] + ["prompt$"]
187+
mock_tmux.return_value = _mock_capture(lines1)
188+
self.tool.forward("sess", lines=20)
189+
190+
# Buffer was trimmed — old anchor lines are gone
191+
lines2 = ["totally_new1", "totally_new2", "totally_new3", "prompt2$"]
192+
mock_tmux.return_value = _mock_capture(lines2)
193+
result = self.tool.forward("sess", lines=20)
194+
# Falls back to full tail — we see new content
195+
assert "totally_new1" in result
196+
197+
@patch("smolagents.bp_tools_tmux._run_tmux")
198+
def test_three_consecutive_reads(self, mock_tmux):
199+
"""Fingerprint updates correctly across multiple incremental reads."""
200+
lines1 = ["a", "b", "c", "prompt1$"]
201+
mock_tmux.return_value = _mock_capture(lines1)
202+
r1 = self.tool.forward("sess", lines=20)
203+
assert "a" in r1
204+
205+
lines2 = ["a", "b", "c", "prompt1$", "d", "e", "prompt2$"]
206+
mock_tmux.return_value = _mock_capture(lines2)
207+
r2 = self.tool.forward("sess", lines=20)
208+
assert "d" in r2
209+
assert "a" not in r2
210+
211+
lines3 = ["a", "b", "c", "prompt1$", "d", "e", "prompt2$", "f", "prompt3$"]
212+
mock_tmux.return_value = _mock_capture(lines3)
213+
r3 = self.tool.forward("sess", lines=20)
214+
assert "f" in r3
215+
assert "d" not in r3

0 commit comments

Comments
 (0)