Skip to content

Commit 3f8fdad

Browse files
authored
feat: Paginated view file tool (#566)
# Motivation <!-- Why is this change necessary? --> # Content <!-- Please include a summary of the change --> # Testing <!-- How was the change tested? --> # Please check the following before marking your PR as ready for review - [ x] I have added tests for my changes - [ x] I have updated the documentation or added new documentation as needed --------- Co-authored-by: kopekC <[email protected]>
1 parent 21a3695 commit 3f8fdad

File tree

3 files changed

+259
-13
lines changed

3 files changed

+259
-13
lines changed

src/codegen/extensions/langchain/tools.py

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,21 +45,41 @@ class ViewFileInput(BaseModel):
4545
"""Input for viewing a file."""
4646

4747
filepath: str = Field(..., description="Path to the file relative to workspace root")
48+
start_line: Optional[int] = Field(None, description="Starting line number to view (1-indexed, inclusive)")
49+
end_line: Optional[int] = Field(None, description="Ending line number to view (1-indexed, inclusive)")
50+
max_lines: Optional[int] = Field(None, description="Maximum number of lines to view at once, defaults to 250")
51+
line_numbers: Optional[bool] = Field(True, description="If True, add line numbers to the content (1-indexed)")
4852

4953

5054
class ViewFileTool(BaseTool):
5155
"""Tool for viewing file contents and metadata."""
5256

5357
name: ClassVar[str] = "view_file"
54-
description: ClassVar[str] = "View the contents and metadata of a file in the codebase"
58+
description: ClassVar[str] = """View the contents and metadata of a file in the codebase.
59+
For large files (>250 lines), content will be paginated. Use start_line and end_line to navigate through the file.
60+
The response will indicate if there are more lines available to view."""
5561
args_schema: ClassVar[type[BaseModel]] = ViewFileInput
5662
codebase: Codebase = Field(exclude=True)
5763

5864
def __init__(self, codebase: Codebase) -> None:
5965
super().__init__(codebase=codebase)
6066

61-
def _run(self, filepath: str) -> str:
62-
result = view_file(self.codebase, filepath)
67+
def _run(
68+
self,
69+
filepath: str,
70+
start_line: Optional[int] = None,
71+
end_line: Optional[int] = None,
72+
max_lines: Optional[int] = None,
73+
line_numbers: Optional[bool] = True,
74+
) -> str:
75+
result = view_file(
76+
self.codebase,
77+
filepath,
78+
line_numbers=line_numbers if line_numbers is not None else True,
79+
start_line=start_line,
80+
end_line=end_line,
81+
max_lines=max_lines if max_lines is not None else 250,
82+
)
6383
return result.render()
6484

6585

src/codegen/extensions/tools/view_file.py

Lines changed: 88 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,40 @@ class ViewFileObservation(Observation):
2222
default=None,
2323
description="Number of lines in the file",
2424
)
25+
start_line: Optional[int] = Field(
26+
default=None,
27+
description="Starting line number of the content (1-indexed)",
28+
)
29+
end_line: Optional[int] = Field(
30+
default=None,
31+
description="Ending line number of the content (1-indexed)",
32+
)
33+
has_more: Optional[bool] = Field(
34+
default=None,
35+
description="Whether there are more lines after end_line",
36+
)
37+
max_lines_per_page: Optional[int] = Field(
38+
default=None,
39+
description="Maximum number of lines that can be viewed at once",
40+
)
2541

26-
str_template: ClassVar[str] = "File {filepath} ({line_count} lines)"
42+
str_template: ClassVar[str] = "File {filepath} (showing lines {start_line}-{end_line} of {line_count})"
2743

2844
def render(self) -> str:
29-
return f"""[VIEW FILE]: {self.filepath} ({self.line_count} lines)
30-
{self.content}
31-
"""
45+
"""Render the file view with pagination information if applicable."""
46+
header = f"[VIEW FILE]: {self.filepath}"
47+
if self.line_count is not None:
48+
header += f" ({self.line_count} lines total)"
49+
50+
if self.start_line is not None and self.end_line is not None:
51+
header += f"\nShowing lines {self.start_line}-{self.end_line}"
52+
if self.has_more:
53+
header += f" (more lines available, max {self.max_lines_per_page} lines per page)"
54+
55+
if not self.content:
56+
return f"{header}\n<empty content>"
57+
58+
return f"{header}\n\n{self.content}"
3259

3360

3461
def add_line_numbers(content: str) -> str:
@@ -45,13 +72,23 @@ def add_line_numbers(content: str) -> str:
4572
return "\n".join(f"{i + 1:>{width}}|{line}" for i, line in enumerate(lines))
4673

4774

48-
def view_file(codebase: Codebase, filepath: str, line_numbers: bool = True) -> ViewFileObservation:
75+
def view_file(
76+
codebase: Codebase,
77+
filepath: str,
78+
line_numbers: bool = True,
79+
start_line: Optional[int] = None,
80+
end_line: Optional[int] = None,
81+
max_lines: int = 250,
82+
) -> ViewFileObservation:
4983
"""View the contents and metadata of a file.
5084
5185
Args:
5286
codebase: The codebase to operate on
5387
filepath: Path to the file relative to workspace root
5488
line_numbers: If True, add line numbers to the content (1-indexed)
89+
start_line: Starting line number to view (1-indexed, inclusive)
90+
end_line: Ending line number to view (1-indexed, inclusive)
91+
max_lines: Maximum number of lines to view at once, defaults to 250
5592
"""
5693
try:
5794
file = codebase.get_file(filepath)
@@ -62,15 +99,56 @@ def view_file(codebase: Codebase, filepath: str, line_numbers: bool = True) -> V
6299
filepath=filepath,
63100
content="",
64101
line_count=0,
102+
start_line=start_line,
103+
end_line=end_line,
104+
has_more=False,
105+
max_lines_per_page=max_lines,
65106
)
66107

67-
content = file.content
68-
if line_numbers:
69-
content = add_line_numbers(content)
108+
# Split content into lines and get total line count
109+
lines = file.content.splitlines()
110+
total_lines = len(lines)
111+
112+
# If no start_line specified, start from beginning
113+
if start_line is None:
114+
start_line = 1
115+
116+
# Ensure start_line is within bounds
117+
start_line = max(1, min(start_line, total_lines))
118+
119+
# If no end_line specified, show up to max_lines from start
120+
if end_line is None:
121+
end_line = min(start_line + max_lines - 1, total_lines)
122+
else:
123+
# Ensure end_line is within bounds and doesn't exceed max_lines from start
124+
end_line = min(end_line, total_lines, start_line + max_lines - 1)
70125

71-
return ViewFileObservation(
126+
# Extract the requested lines (convert to 0-based indexing)
127+
content_lines = lines[start_line - 1 : end_line]
128+
content = "\n".join(content_lines)
129+
130+
# Add line numbers if requested
131+
if line_numbers:
132+
# Pass the actual line numbers for proper numbering
133+
numbered_lines = []
134+
width = len(str(total_lines)) # Use total_lines for consistent width
135+
for i, line in enumerate(content_lines, start=start_line):
136+
numbered_lines.append(f"{i:>{width}}|{line}")
137+
content = "\n".join(numbered_lines)
138+
139+
# Create base observation with common fields
140+
observation = ViewFileObservation(
72141
status="success",
73142
filepath=file.filepath,
74143
content=content,
75-
line_count=len(content.splitlines()),
144+
line_count=total_lines,
76145
)
146+
147+
# Only include pagination fields if file exceeds max_lines
148+
if total_lines > max_lines:
149+
observation.start_line = start_line
150+
observation.end_line = end_line
151+
observation.has_more = end_line < total_lines
152+
observation.max_lines_per_page = max_lines
153+
154+
return observation

tests/unit/codegen/extensions/test_tools.py

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,12 +40,160 @@ def greet(self):
4040
yield codebase
4141

4242

43+
@pytest.fixture
44+
def large_codebase(tmpdir):
45+
"""Create a codebase with a large file for pagination testing."""
46+
# Create a large file with predictable content
47+
large_file_lines = []
48+
# Add imports at the top
49+
large_file_lines.extend(
50+
[
51+
"from __future__ import annotations",
52+
"import sys",
53+
"import os",
54+
"from typing import List, Optional, Dict",
55+
"",
56+
"# Constants",
57+
"MAX_ITEMS = 100",
58+
"DEBUG = False",
59+
"",
60+
"# Main class definition",
61+
"class LargeClass:",
62+
]
63+
)
64+
65+
# Add methods with incrementing numbers
66+
for i in range(1, 401): # This will create a 400+ line file
67+
if i % 20 == 0:
68+
# Add some class methods periodically
69+
large_file_lines.extend([" @classmethod", f" def class_method_{i}(cls) -> None:", f" print('Class method {i}')", " return None", ""])
70+
else:
71+
# Add regular methods
72+
large_file_lines.extend(
73+
[
74+
f" def method_{i}(self, param_{i}: int) -> str:",
75+
f" # Method {i} does something interesting",
76+
f" value = param_{i} * {i}",
77+
f" return f'Method {i} computed: {{value}}'",
78+
"",
79+
]
80+
)
81+
82+
large_file_content = "\n".join(large_file_lines)
83+
84+
files = {
85+
"src/main.py": """
86+
def hello():
87+
print("Hello, world!")
88+
""",
89+
"src/large_file.py": large_file_content,
90+
}
91+
92+
with get_codebase_session(tmpdir=tmpdir, files=files) as codebase:
93+
yield codebase
94+
95+
4396
def test_view_file(codebase):
4497
"""Test viewing a file."""
98+
# Test basic file viewing
4599
result = view_file(codebase, "src/main.py")
46100
assert result.status == "success"
47101
assert result.filepath == "src/main.py"
48102
assert "hello()" in result.content
103+
# For small files, pagination fields should not be present
104+
assert result.start_line is None
105+
assert result.end_line is None
106+
assert result.has_more is None
107+
assert result.max_lines_per_page is None
108+
109+
110+
def test_view_file_pagination(large_codebase):
111+
"""Test viewing a file with pagination."""
112+
# Test default pagination (should show first max_lines lines)
113+
result = view_file(large_codebase, "src/large_file.py")
114+
assert result.status == "success"
115+
assert result.start_line == 1
116+
assert result.end_line == 250 # Default max_lines
117+
assert result.has_more is True
118+
assert result.max_lines_per_page == 250
119+
assert "from __future__ import annotations" in result.content # First line
120+
assert "def method_1" in result.content # Early method
121+
assert "def method_251" not in result.content # Method after page 1
122+
123+
# Test custom pagination range
124+
result = view_file(large_codebase, "src/large_file.py", start_line=200, end_line=250)
125+
assert result.status == "success"
126+
assert result.start_line == 200
127+
assert result.end_line == 250
128+
assert result.has_more is True
129+
assert "def method_39" in result.content # Regular method before class method
130+
assert "def class_method_40" in result.content # Class method at 40
131+
assert "def method_41" in result.content # Regular method after class method
132+
assert "from __future__ import annotations" not in result.content # Before range
133+
assert "def method_251" not in result.content # After range
134+
135+
# Test viewing end of file
136+
result = view_file(large_codebase, "src/large_file.py", start_line=350)
137+
assert result.status == "success"
138+
assert result.start_line == 350
139+
assert result.has_more is True # File has 2010 lines, so there should be more content
140+
assert "def method_69" in result.content # Regular method
141+
assert "def class_method_80" in result.content # Class method at 80
142+
assert result.end_line == 599 # Should show 250 lines from start (350 to 599)
143+
144+
# Test custom max_lines
145+
result = view_file(large_codebase, "src/large_file.py", max_lines=100)
146+
assert result.status == "success"
147+
assert result.start_line == 1
148+
assert result.end_line == 100
149+
assert result.has_more is True
150+
assert result.max_lines_per_page == 100
151+
assert "from __future__ import annotations" in result.content
152+
assert len(result.content.splitlines()) <= 100
153+
154+
# Test line numbers display
155+
result = view_file(large_codebase, "src/large_file.py", start_line=198, end_line=202, line_numbers=True)
156+
assert result.status == "success"
157+
assert "198|" in result.content
158+
assert "199|" in result.content
159+
assert "200|" in result.content
160+
assert "201|" in result.content
161+
assert "202|" in result.content
162+
163+
# Test without line numbers
164+
result = view_file(large_codebase, "src/large_file.py", start_line=198, end_line=202, line_numbers=False)
165+
assert result.status == "success"
166+
assert "198|" not in result.content
167+
assert "199|" not in result.content
168+
169+
170+
def test_view_file_pagination_edge_cases(large_codebase):
171+
"""Test edge cases for file pagination."""
172+
# Test start_line > end_line (should respect provided end_line)
173+
result = view_file(large_codebase, "src/large_file.py", start_line=200, end_line=100)
174+
assert result.status == "success"
175+
assert result.start_line == 200
176+
assert result.end_line == 100 # Should respect provided end_line
177+
assert result.content == "" # No content since end_line < start_line
178+
179+
# Test start_line > file length (should adjust to valid range)
180+
result = view_file(large_codebase, "src/large_file.py", start_line=2000)
181+
assert result.status == "success"
182+
assert result.start_line == 2000 # Should use provided start_line
183+
assert result.end_line == 2010 # Should adjust to total lines
184+
assert result.has_more is False
185+
186+
# Test end_line > file length (should truncate to file length)
187+
result = view_file(large_codebase, "src/large_file.py", start_line=200, end_line=2000)
188+
assert result.status == "success"
189+
assert result.start_line == 200
190+
assert result.end_line == min(200 + 250 - 1, 2010) # Should respect max_lines and file length
191+
192+
# Test negative start_line (should default to 1)
193+
result = view_file(large_codebase, "src/large_file.py", start_line=-10)
194+
assert result.status == "success"
195+
assert result.start_line == 1
196+
assert result.end_line == 250
49197

50198

51199
def test_list_directory(codebase):

0 commit comments

Comments
 (0)