indent 0.0.8__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of indent might be problematic. Click here for more details.
- exponent/__init__.py +1 -0
- exponent/cli.py +112 -0
- exponent/commands/cloud_commands.py +85 -0
- exponent/commands/common.py +434 -0
- exponent/commands/config_commands.py +581 -0
- exponent/commands/github_app_commands.py +211 -0
- exponent/commands/listen_commands.py +96 -0
- exponent/commands/run_commands.py +208 -0
- exponent/commands/settings.py +56 -0
- exponent/commands/shell_commands.py +2840 -0
- exponent/commands/theme.py +246 -0
- exponent/commands/types.py +111 -0
- exponent/commands/upgrade.py +29 -0
- exponent/commands/utils.py +236 -0
- exponent/core/config.py +180 -0
- exponent/core/graphql/__init__.py +0 -0
- exponent/core/graphql/client.py +59 -0
- exponent/core/graphql/cloud_config_queries.py +77 -0
- exponent/core/graphql/get_chats_query.py +47 -0
- exponent/core/graphql/github_config_queries.py +56 -0
- exponent/core/graphql/mutations.py +75 -0
- exponent/core/graphql/queries.py +110 -0
- exponent/core/graphql/subscriptions.py +452 -0
- exponent/core/remote_execution/checkpoints.py +212 -0
- exponent/core/remote_execution/cli_rpc_types.py +214 -0
- exponent/core/remote_execution/client.py +545 -0
- exponent/core/remote_execution/code_execution.py +58 -0
- exponent/core/remote_execution/command_execution.py +105 -0
- exponent/core/remote_execution/error_info.py +45 -0
- exponent/core/remote_execution/exceptions.py +10 -0
- exponent/core/remote_execution/file_write.py +410 -0
- exponent/core/remote_execution/files.py +415 -0
- exponent/core/remote_execution/git.py +268 -0
- exponent/core/remote_execution/languages/python_execution.py +239 -0
- exponent/core/remote_execution/languages/shell_streaming.py +221 -0
- exponent/core/remote_execution/languages/types.py +20 -0
- exponent/core/remote_execution/session.py +128 -0
- exponent/core/remote_execution/system_context.py +54 -0
- exponent/core/remote_execution/tool_execution.py +289 -0
- exponent/core/remote_execution/truncation.py +284 -0
- exponent/core/remote_execution/types.py +670 -0
- exponent/core/remote_execution/utils.py +600 -0
- exponent/core/types/__init__.py +0 -0
- exponent/core/types/command_data.py +206 -0
- exponent/core/types/event_types.py +89 -0
- exponent/core/types/generated/__init__.py +0 -0
- exponent/core/types/generated/strategy_info.py +225 -0
- exponent/migration-docs/login.md +112 -0
- exponent/py.typed +4 -0
- exponent/utils/__init__.py +0 -0
- exponent/utils/colors.py +92 -0
- exponent/utils/version.py +289 -0
- indent-0.0.8.dist-info/METADATA +36 -0
- indent-0.0.8.dist-info/RECORD +56 -0
- indent-0.0.8.dist-info/WHEEL +4 -0
- indent-0.0.8.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
from collections.abc import Callable
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from time import time
|
|
5
|
+
|
|
6
|
+
from anyio import Path as AsyncPath
|
|
7
|
+
|
|
8
|
+
from exponent.core.remote_execution import files
|
|
9
|
+
from exponent.core.remote_execution.cli_rpc_types import (
|
|
10
|
+
BashToolInput,
|
|
11
|
+
BashToolResult,
|
|
12
|
+
ErrorToolResult,
|
|
13
|
+
GlobToolInput,
|
|
14
|
+
GlobToolResult,
|
|
15
|
+
GrepToolInput,
|
|
16
|
+
GrepToolResult,
|
|
17
|
+
ListToolInput,
|
|
18
|
+
ListToolResult,
|
|
19
|
+
ReadToolInput,
|
|
20
|
+
ReadToolResult,
|
|
21
|
+
ToolInputType,
|
|
22
|
+
ToolResultType,
|
|
23
|
+
WriteToolInput,
|
|
24
|
+
WriteToolResult,
|
|
25
|
+
)
|
|
26
|
+
from exponent.core.remote_execution.code_execution import (
|
|
27
|
+
execute_code_streaming,
|
|
28
|
+
)
|
|
29
|
+
from exponent.core.remote_execution.file_write import execute_full_file_rewrite
|
|
30
|
+
from exponent.core.remote_execution.truncation import truncate_tool_result
|
|
31
|
+
from exponent.core.remote_execution.types import (
|
|
32
|
+
StreamingCodeExecutionRequest,
|
|
33
|
+
StreamingCodeExecutionResponse,
|
|
34
|
+
)
|
|
35
|
+
from exponent.core.remote_execution.utils import (
|
|
36
|
+
assert_unreachable,
|
|
37
|
+
safe_read_file,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
GREP_MAX_RESULTS = 100
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
async def execute_tool(
|
|
44
|
+
tool_input: ToolInputType, working_directory: str
|
|
45
|
+
) -> ToolResultType:
|
|
46
|
+
if isinstance(tool_input, ReadToolInput):
|
|
47
|
+
return await execute_read_file(tool_input, working_directory)
|
|
48
|
+
elif isinstance(tool_input, WriteToolInput):
|
|
49
|
+
return await execute_write_file(tool_input, working_directory)
|
|
50
|
+
elif isinstance(tool_input, ListToolInput):
|
|
51
|
+
return await execute_list_files(tool_input, working_directory)
|
|
52
|
+
elif isinstance(tool_input, GlobToolInput):
|
|
53
|
+
return await execute_glob_files(tool_input, working_directory)
|
|
54
|
+
elif isinstance(tool_input, GrepToolInput):
|
|
55
|
+
return await execute_grep_files(tool_input, working_directory)
|
|
56
|
+
elif isinstance(tool_input, BashToolInput):
|
|
57
|
+
raise ValueError("Bash tool input should be handled by execute_bash_tool")
|
|
58
|
+
else:
|
|
59
|
+
assert_unreachable(tool_input)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def truncate_result[T: ToolResultType](tool_result: T) -> T:
|
|
63
|
+
return truncate_tool_result(tool_result)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
async def execute_read_file( # noqa: PLR0911
|
|
67
|
+
tool_input: ReadToolInput, working_directory: str
|
|
68
|
+
) -> ReadToolResult | ErrorToolResult:
|
|
69
|
+
# Validate absolute path requirement
|
|
70
|
+
if not tool_input.file_path.startswith("/"):
|
|
71
|
+
return ErrorToolResult(
|
|
72
|
+
error_message=f"File path must be absolute, got relative path: {tool_input.file_path}"
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
# Validate offset and limit
|
|
76
|
+
offset = tool_input.offset if tool_input.offset is not None else 0
|
|
77
|
+
limit = tool_input.limit if tool_input.limit is not None else 2000
|
|
78
|
+
|
|
79
|
+
if offset < 0:
|
|
80
|
+
return ErrorToolResult(
|
|
81
|
+
error_message=f"Offset must be non-negative, got: {offset}"
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
if limit <= 0:
|
|
85
|
+
return ErrorToolResult(error_message=f"Limit must be positive, got: {limit}")
|
|
86
|
+
|
|
87
|
+
file = AsyncPath(working_directory, tool_input.file_path)
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
exists = await file.exists()
|
|
91
|
+
except (OSError, PermissionError) as e:
|
|
92
|
+
return ErrorToolResult(error_message=f"Cannot access file: {e!s}")
|
|
93
|
+
|
|
94
|
+
if not exists:
|
|
95
|
+
return ErrorToolResult(
|
|
96
|
+
error_message="File not found",
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
if await file.is_dir():
|
|
101
|
+
return ErrorToolResult(
|
|
102
|
+
error_message=f"{await file.absolute()} is a directory",
|
|
103
|
+
)
|
|
104
|
+
except (OSError, PermissionError) as e:
|
|
105
|
+
return ErrorToolResult(error_message=f"Cannot check file type: {e!s}")
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
content = await safe_read_file(file)
|
|
109
|
+
except PermissionError:
|
|
110
|
+
return ErrorToolResult(
|
|
111
|
+
error_message=f"Permission denied: cannot read {tool_input.file_path}"
|
|
112
|
+
)
|
|
113
|
+
except UnicodeDecodeError:
|
|
114
|
+
return ErrorToolResult(
|
|
115
|
+
error_message="File appears to be binary or has invalid text encoding"
|
|
116
|
+
)
|
|
117
|
+
except Exception as e: # noqa: BLE001
|
|
118
|
+
return ErrorToolResult(error_message=f"Error reading file: {e!s}")
|
|
119
|
+
|
|
120
|
+
# Handle empty files
|
|
121
|
+
if not content:
|
|
122
|
+
return ReadToolResult(
|
|
123
|
+
content="",
|
|
124
|
+
num_lines=0,
|
|
125
|
+
start_line=0,
|
|
126
|
+
total_lines=0,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
content_lines = content.splitlines(keepends=True)
|
|
130
|
+
total_lines = len(content_lines)
|
|
131
|
+
|
|
132
|
+
# Handle offset beyond file length
|
|
133
|
+
if offset >= total_lines:
|
|
134
|
+
return ReadToolResult(
|
|
135
|
+
content="",
|
|
136
|
+
num_lines=0,
|
|
137
|
+
start_line=offset,
|
|
138
|
+
total_lines=total_lines,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
# Apply offset and limit
|
|
142
|
+
content_lines = content_lines[offset : offset + limit]
|
|
143
|
+
|
|
144
|
+
# Apply character-level truncation at line boundaries to ensure consistency
|
|
145
|
+
# This ensures the content field and num_lines field remain in sync
|
|
146
|
+
CHARACTER_LIMIT = 90_000 # Match the limit in truncation.py
|
|
147
|
+
|
|
148
|
+
# Join lines and check total size
|
|
149
|
+
final_content = "".join(content_lines)
|
|
150
|
+
|
|
151
|
+
if len(final_content) > CHARACTER_LIMIT:
|
|
152
|
+
# Truncate at line boundaries to stay under the limit
|
|
153
|
+
truncated_lines: list[str] = []
|
|
154
|
+
current_size = 0
|
|
155
|
+
truncation_message = "\n[Content truncated due to size limit]"
|
|
156
|
+
truncation_size = len(truncation_message)
|
|
157
|
+
lines_included = 0
|
|
158
|
+
|
|
159
|
+
for line in content_lines:
|
|
160
|
+
# Check if adding this line would exceed the limit (accounting for truncation message)
|
|
161
|
+
if current_size + len(line) + truncation_size > CHARACTER_LIMIT:
|
|
162
|
+
final_content = "".join(truncated_lines) + truncation_message
|
|
163
|
+
break
|
|
164
|
+
truncated_lines.append(line)
|
|
165
|
+
current_size += len(line)
|
|
166
|
+
lines_included += 1
|
|
167
|
+
else:
|
|
168
|
+
# All lines fit (shouldn't happen if we got here, but be safe)
|
|
169
|
+
final_content = "".join(truncated_lines)
|
|
170
|
+
lines_included = len(content_lines)
|
|
171
|
+
|
|
172
|
+
num_lines = lines_included
|
|
173
|
+
else:
|
|
174
|
+
num_lines = len(content_lines)
|
|
175
|
+
|
|
176
|
+
return ReadToolResult(
|
|
177
|
+
content=final_content,
|
|
178
|
+
num_lines=num_lines,
|
|
179
|
+
start_line=offset,
|
|
180
|
+
total_lines=total_lines,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
async def execute_write_file(
|
|
185
|
+
tool_input: WriteToolInput, working_directory: str
|
|
186
|
+
) -> WriteToolResult:
|
|
187
|
+
file_path = tool_input.file_path
|
|
188
|
+
path = Path(working_directory, file_path)
|
|
189
|
+
result = await execute_full_file_rewrite(
|
|
190
|
+
path, tool_input.content, working_directory
|
|
191
|
+
)
|
|
192
|
+
return WriteToolResult(message=result)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
async def execute_list_files(
|
|
196
|
+
tool_input: ListToolInput, working_directory: str
|
|
197
|
+
) -> ListToolResult | ErrorToolResult:
|
|
198
|
+
path = AsyncPath(tool_input.path)
|
|
199
|
+
|
|
200
|
+
try:
|
|
201
|
+
exists = await path.exists()
|
|
202
|
+
except (OSError, PermissionError) as e:
|
|
203
|
+
return ErrorToolResult(error_message=f"Cannot access path: {e!s}")
|
|
204
|
+
|
|
205
|
+
if not exists:
|
|
206
|
+
return ErrorToolResult(error_message=f"Directory not found: {tool_input.path}")
|
|
207
|
+
|
|
208
|
+
try:
|
|
209
|
+
is_dir = await path.is_dir()
|
|
210
|
+
except (OSError, PermissionError) as e:
|
|
211
|
+
return ErrorToolResult(
|
|
212
|
+
error_message=f"Cannot check if path is directory: {e!s}"
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
if not is_dir:
|
|
216
|
+
return ErrorToolResult(
|
|
217
|
+
error_message=f"Path is not a directory: {tool_input.path}"
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
try:
|
|
221
|
+
filenames = [entry.name async for entry in path.iterdir()]
|
|
222
|
+
except (OSError, PermissionError) as e:
|
|
223
|
+
return ErrorToolResult(error_message=f"Cannot list directory contents: {e!s}")
|
|
224
|
+
|
|
225
|
+
return ListToolResult(
|
|
226
|
+
files=[filename for filename in filenames],
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
async def execute_glob_files(
|
|
231
|
+
tool_input: GlobToolInput, working_directory: str
|
|
232
|
+
) -> GlobToolResult:
|
|
233
|
+
# async timer
|
|
234
|
+
start_time = time()
|
|
235
|
+
results = await files.glob(
|
|
236
|
+
path=working_directory if tool_input.path is None else tool_input.path,
|
|
237
|
+
glob_pattern=tool_input.pattern,
|
|
238
|
+
)
|
|
239
|
+
duration_ms = int((time() - start_time) * 1000)
|
|
240
|
+
return GlobToolResult(
|
|
241
|
+
filenames=results,
|
|
242
|
+
duration_ms=duration_ms,
|
|
243
|
+
num_files=len(results),
|
|
244
|
+
truncated=len(results) >= files.GLOB_MAX_COUNT,
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
async def execute_grep_files(
|
|
249
|
+
tool_input: GrepToolInput, working_directory: str
|
|
250
|
+
) -> GrepToolResult:
|
|
251
|
+
results = await files.search_files(
|
|
252
|
+
path_str=working_directory if tool_input.path is None else tool_input.path,
|
|
253
|
+
file_pattern=tool_input.include,
|
|
254
|
+
regex=tool_input.pattern,
|
|
255
|
+
working_directory=working_directory,
|
|
256
|
+
)
|
|
257
|
+
return GrepToolResult(
|
|
258
|
+
matches=results[:GREP_MAX_RESULTS],
|
|
259
|
+
truncated=bool(len(results) > GREP_MAX_RESULTS),
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
async def execute_bash_tool(
|
|
264
|
+
tool_input: BashToolInput, working_directory: str, should_halt: Callable[[], bool]
|
|
265
|
+
) -> BashToolResult:
|
|
266
|
+
start_time = time()
|
|
267
|
+
result = None
|
|
268
|
+
async for result in execute_code_streaming(
|
|
269
|
+
StreamingCodeExecutionRequest(
|
|
270
|
+
language="shell",
|
|
271
|
+
content=tool_input.command,
|
|
272
|
+
timeout=120 if tool_input.timeout is None else tool_input.timeout,
|
|
273
|
+
correlation_id=str(uuid.uuid4()),
|
|
274
|
+
),
|
|
275
|
+
working_directory=working_directory,
|
|
276
|
+
session=None, # type: ignore
|
|
277
|
+
should_halt=should_halt,
|
|
278
|
+
):
|
|
279
|
+
pass
|
|
280
|
+
|
|
281
|
+
assert isinstance(result, StreamingCodeExecutionResponse)
|
|
282
|
+
|
|
283
|
+
return BashToolResult(
|
|
284
|
+
shell_output=result.content,
|
|
285
|
+
exit_code=result.exit_code,
|
|
286
|
+
duration_ms=int((time() - start_time) * 1000),
|
|
287
|
+
timed_out=result.cancelled_for_timeout,
|
|
288
|
+
stopped_by_user=result.halted,
|
|
289
|
+
)
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
"""Generalized truncation framework for tool results."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from typing import Any, TypeVar, cast
|
|
5
|
+
|
|
6
|
+
from msgspec.structs import replace
|
|
7
|
+
|
|
8
|
+
from exponent.core.remote_execution.cli_rpc_types import (
|
|
9
|
+
BashToolResult,
|
|
10
|
+
ErrorToolResult,
|
|
11
|
+
GlobToolResult,
|
|
12
|
+
GrepToolResult,
|
|
13
|
+
ListToolResult,
|
|
14
|
+
ReadToolResult,
|
|
15
|
+
ToolResult,
|
|
16
|
+
WriteToolResult,
|
|
17
|
+
)
|
|
18
|
+
from exponent.core.remote_execution.utils import truncate_output
|
|
19
|
+
|
|
20
|
+
DEFAULT_CHARACTER_LIMIT = 90_000
|
|
21
|
+
DEFAULT_LIST_ITEM_LIMIT = 1000
|
|
22
|
+
DEFAULT_LIST_PREVIEW_ITEMS = 10
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class TruncationStrategy(ABC):
|
|
26
|
+
@abstractmethod
|
|
27
|
+
def should_truncate(self, result: ToolResult) -> bool:
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
@abstractmethod
|
|
31
|
+
def truncate(self, result: ToolResult) -> ToolResult:
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class StringFieldTruncation(TruncationStrategy):
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
field_name: str,
|
|
39
|
+
character_limit: int = DEFAULT_CHARACTER_LIMIT,
|
|
40
|
+
):
|
|
41
|
+
self.field_name = field_name
|
|
42
|
+
self.character_limit = character_limit
|
|
43
|
+
|
|
44
|
+
def should_truncate(self, result: ToolResult) -> bool:
|
|
45
|
+
if hasattr(result, self.field_name):
|
|
46
|
+
value = getattr(result, self.field_name)
|
|
47
|
+
if isinstance(value, str):
|
|
48
|
+
return len(value) > self.character_limit
|
|
49
|
+
return False
|
|
50
|
+
|
|
51
|
+
def truncate(self, result: ToolResult) -> ToolResult:
|
|
52
|
+
if not hasattr(result, self.field_name):
|
|
53
|
+
return result
|
|
54
|
+
|
|
55
|
+
value = getattr(result, self.field_name)
|
|
56
|
+
if not isinstance(value, str):
|
|
57
|
+
return result
|
|
58
|
+
|
|
59
|
+
truncated_value, was_truncated = truncate_output(value, self.character_limit)
|
|
60
|
+
|
|
61
|
+
updates: dict[str, Any] = {self.field_name: truncated_value}
|
|
62
|
+
if hasattr(result, "truncated") and was_truncated:
|
|
63
|
+
updates["truncated"] = True
|
|
64
|
+
|
|
65
|
+
return replace(result, **updates)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class ListFieldTruncation(TruncationStrategy):
|
|
69
|
+
def __init__(
|
|
70
|
+
self,
|
|
71
|
+
field_name: str,
|
|
72
|
+
item_limit: int = DEFAULT_LIST_ITEM_LIMIT,
|
|
73
|
+
preview_items: int = DEFAULT_LIST_PREVIEW_ITEMS,
|
|
74
|
+
):
|
|
75
|
+
self.field_name = field_name
|
|
76
|
+
self.item_limit = item_limit
|
|
77
|
+
self.preview_items = preview_items
|
|
78
|
+
|
|
79
|
+
def should_truncate(self, result: ToolResult) -> bool:
|
|
80
|
+
if hasattr(result, self.field_name):
|
|
81
|
+
value = getattr(result, self.field_name)
|
|
82
|
+
if isinstance(value, list):
|
|
83
|
+
return len(value) > self.item_limit
|
|
84
|
+
return False
|
|
85
|
+
|
|
86
|
+
def truncate(self, result: ToolResult) -> ToolResult:
|
|
87
|
+
if not hasattr(result, self.field_name):
|
|
88
|
+
return result
|
|
89
|
+
|
|
90
|
+
value = getattr(result, self.field_name)
|
|
91
|
+
if not isinstance(value, list):
|
|
92
|
+
return result
|
|
93
|
+
|
|
94
|
+
total_items = len(value)
|
|
95
|
+
if total_items <= self.item_limit:
|
|
96
|
+
return result
|
|
97
|
+
|
|
98
|
+
truncated_count = max(0, total_items - 2 * self.preview_items)
|
|
99
|
+
truncated_list = (
|
|
100
|
+
value[: self.preview_items]
|
|
101
|
+
+ [f"... {truncated_count} items truncated ..."]
|
|
102
|
+
+ value[-self.preview_items :]
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
updates: dict[str, Any] = {self.field_name: truncated_list}
|
|
106
|
+
if hasattr(result, "truncated"):
|
|
107
|
+
updates["truncated"] = True
|
|
108
|
+
|
|
109
|
+
return replace(result, **updates)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class CompositeTruncation(TruncationStrategy):
|
|
113
|
+
def __init__(self, strategies: list[TruncationStrategy]):
|
|
114
|
+
self.strategies = strategies
|
|
115
|
+
|
|
116
|
+
def should_truncate(self, result: ToolResult) -> bool:
|
|
117
|
+
return any(strategy.should_truncate(result) for strategy in self.strategies)
|
|
118
|
+
|
|
119
|
+
def truncate(self, result: ToolResult) -> ToolResult:
|
|
120
|
+
for strategy in self.strategies:
|
|
121
|
+
if strategy.should_truncate(result):
|
|
122
|
+
result = strategy.truncate(result)
|
|
123
|
+
return result
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class StringListTruncation(TruncationStrategy):
|
|
127
|
+
"""Truncation for lists of strings that limits both number of items and individual string length."""
|
|
128
|
+
|
|
129
|
+
def __init__(
|
|
130
|
+
self,
|
|
131
|
+
field_name: str,
|
|
132
|
+
max_items: int = DEFAULT_LIST_ITEM_LIMIT,
|
|
133
|
+
preview_items: int = DEFAULT_LIST_PREVIEW_ITEMS,
|
|
134
|
+
max_item_length: int = 1000,
|
|
135
|
+
):
|
|
136
|
+
self.field_name = field_name
|
|
137
|
+
self.max_items = max_items
|
|
138
|
+
self.preview_items = preview_items
|
|
139
|
+
self.max_item_length = max_item_length
|
|
140
|
+
|
|
141
|
+
def should_truncate(self, result: ToolResult) -> bool:
|
|
142
|
+
if not hasattr(result, self.field_name):
|
|
143
|
+
return False
|
|
144
|
+
|
|
145
|
+
items = getattr(result, self.field_name)
|
|
146
|
+
if not isinstance(items, list):
|
|
147
|
+
return False
|
|
148
|
+
|
|
149
|
+
# Check if we need to truncate number of items
|
|
150
|
+
if len(items) > self.max_items:
|
|
151
|
+
return True
|
|
152
|
+
|
|
153
|
+
# Check if any individual item is too long
|
|
154
|
+
for item in items:
|
|
155
|
+
if isinstance(item, str) and len(item) > self.max_item_length:
|
|
156
|
+
return True
|
|
157
|
+
# Handle dict items (e.g., with metadata like file path and line number)
|
|
158
|
+
elif isinstance(item, dict) and "content" in item:
|
|
159
|
+
if len(item["content"]) > self.max_item_length:
|
|
160
|
+
return True
|
|
161
|
+
|
|
162
|
+
return False
|
|
163
|
+
|
|
164
|
+
def _truncate_item_content(
|
|
165
|
+
self, item: str | dict[str, Any]
|
|
166
|
+
) -> str | dict[str, Any]:
|
|
167
|
+
"""Truncate an individual item's content."""
|
|
168
|
+
if isinstance(item, str):
|
|
169
|
+
if len(item) <= self.max_item_length:
|
|
170
|
+
return item
|
|
171
|
+
# Truncate string item
|
|
172
|
+
truncated, _ = truncate_output(item, self.max_item_length)
|
|
173
|
+
return truncated
|
|
174
|
+
elif isinstance(item, dict) and "content" in item:
|
|
175
|
+
# Handle dict-style items (e.g., with metadata like file path and line number)
|
|
176
|
+
if len(item["content"]) <= self.max_item_length:
|
|
177
|
+
return item
|
|
178
|
+
truncated_content, _ = truncate_output(
|
|
179
|
+
item["content"], self.max_item_length
|
|
180
|
+
)
|
|
181
|
+
return {**item, "content": truncated_content}
|
|
182
|
+
else:
|
|
183
|
+
return item
|
|
184
|
+
|
|
185
|
+
def truncate(self, result: ToolResult) -> ToolResult:
|
|
186
|
+
if not hasattr(result, self.field_name):
|
|
187
|
+
return result
|
|
188
|
+
|
|
189
|
+
items = getattr(result, self.field_name)
|
|
190
|
+
if not isinstance(items, list):
|
|
191
|
+
return result
|
|
192
|
+
|
|
193
|
+
# First, truncate individual item contents
|
|
194
|
+
truncated_items = [self._truncate_item_content(item) for item in items]
|
|
195
|
+
|
|
196
|
+
# Then, limit the number of items if needed
|
|
197
|
+
total_items = len(truncated_items)
|
|
198
|
+
if total_items > self.max_items:
|
|
199
|
+
truncated_count = max(0, total_items - 2 * self.preview_items)
|
|
200
|
+
final_items = (
|
|
201
|
+
truncated_items[: self.preview_items]
|
|
202
|
+
+ [f"... {truncated_count} items truncated ..."]
|
|
203
|
+
+ truncated_items[-self.preview_items :]
|
|
204
|
+
)
|
|
205
|
+
else:
|
|
206
|
+
final_items = truncated_items
|
|
207
|
+
|
|
208
|
+
updates: dict[str, Any] = {self.field_name: final_items}
|
|
209
|
+
if hasattr(result, "truncated"):
|
|
210
|
+
updates["truncated"] = True
|
|
211
|
+
|
|
212
|
+
return replace(result, **updates)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
TRUNCATION_REGISTRY: dict[type[ToolResult], TruncationStrategy] = {
|
|
216
|
+
ReadToolResult: StringFieldTruncation("content"),
|
|
217
|
+
WriteToolResult: StringFieldTruncation("message"),
|
|
218
|
+
BashToolResult: StringFieldTruncation("shell_output"),
|
|
219
|
+
GrepToolResult: StringListTruncation("matches"),
|
|
220
|
+
GlobToolResult: StringListTruncation("filenames", max_item_length=4096),
|
|
221
|
+
ListToolResult: StringListTruncation("files", max_item_length=4096),
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
T = TypeVar("T", bound=ToolResult)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def truncate_tool_result(result: T) -> T:
|
|
229
|
+
if isinstance(result, ErrorToolResult):
|
|
230
|
+
return cast(T, result)
|
|
231
|
+
|
|
232
|
+
result_type = type(result)
|
|
233
|
+
if result_type in TRUNCATION_REGISTRY:
|
|
234
|
+
strategy = TRUNCATION_REGISTRY[result_type]
|
|
235
|
+
if strategy.should_truncate(result):
|
|
236
|
+
return cast(T, strategy.truncate(result))
|
|
237
|
+
|
|
238
|
+
return result
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def register_truncation_strategy(
|
|
242
|
+
result_type: type[ToolResult],
|
|
243
|
+
strategy: TruncationStrategy,
|
|
244
|
+
) -> None:
|
|
245
|
+
TRUNCATION_REGISTRY[result_type] = strategy
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def configure_truncation_limits(
|
|
249
|
+
character_limit: int | None = None,
|
|
250
|
+
list_item_limit: int | None = None,
|
|
251
|
+
list_preview_items: int | None = None,
|
|
252
|
+
string_item_length: int | None = None,
|
|
253
|
+
) -> None:
|
|
254
|
+
for strategy in TRUNCATION_REGISTRY.values():
|
|
255
|
+
if isinstance(strategy, StringFieldTruncation) and character_limit is not None:
|
|
256
|
+
strategy.character_limit = character_limit
|
|
257
|
+
elif isinstance(strategy, ListFieldTruncation):
|
|
258
|
+
if list_item_limit is not None:
|
|
259
|
+
strategy.item_limit = list_item_limit
|
|
260
|
+
if list_preview_items is not None:
|
|
261
|
+
strategy.preview_items = list_preview_items
|
|
262
|
+
elif isinstance(strategy, StringListTruncation):
|
|
263
|
+
if list_item_limit is not None:
|
|
264
|
+
strategy.max_items = list_item_limit
|
|
265
|
+
if list_preview_items is not None:
|
|
266
|
+
strategy.preview_items = list_preview_items
|
|
267
|
+
if string_item_length is not None:
|
|
268
|
+
strategy.max_item_length = string_item_length
|
|
269
|
+
elif isinstance(strategy, CompositeTruncation):
|
|
270
|
+
for sub_strategy in strategy.strategies:
|
|
271
|
+
if isinstance(sub_strategy, StringFieldTruncation) and character_limit:
|
|
272
|
+
sub_strategy.character_limit = character_limit
|
|
273
|
+
elif isinstance(sub_strategy, ListFieldTruncation):
|
|
274
|
+
if list_item_limit is not None:
|
|
275
|
+
sub_strategy.item_limit = list_item_limit
|
|
276
|
+
if list_preview_items is not None:
|
|
277
|
+
sub_strategy.preview_items = list_preview_items
|
|
278
|
+
elif isinstance(sub_strategy, StringListTruncation):
|
|
279
|
+
if list_item_limit is not None:
|
|
280
|
+
sub_strategy.max_items = list_item_limit
|
|
281
|
+
if list_preview_items is not None:
|
|
282
|
+
sub_strategy.preview_items = list_preview_items
|
|
283
|
+
if string_item_length is not None:
|
|
284
|
+
sub_strategy.max_item_length = string_item_length
|