hanzo-mcp 0.6.13__py3-none-any.whl → 0.7.1__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 hanzo-mcp might be problematic. Click here for more details.

Files changed (62) hide show
  1. hanzo_mcp/analytics/__init__.py +5 -0
  2. hanzo_mcp/analytics/posthog_analytics.py +364 -0
  3. hanzo_mcp/cli.py +3 -3
  4. hanzo_mcp/cli_enhanced.py +3 -3
  5. hanzo_mcp/config/settings.py +1 -1
  6. hanzo_mcp/config/tool_config.py +18 -4
  7. hanzo_mcp/server.py +34 -1
  8. hanzo_mcp/tools/__init__.py +65 -2
  9. hanzo_mcp/tools/agent/__init__.py +84 -3
  10. hanzo_mcp/tools/agent/agent_tool.py +102 -4
  11. hanzo_mcp/tools/agent/agent_tool_v2.py +492 -0
  12. hanzo_mcp/tools/agent/clarification_protocol.py +220 -0
  13. hanzo_mcp/tools/agent/clarification_tool.py +68 -0
  14. hanzo_mcp/tools/agent/claude_cli_tool.py +125 -0
  15. hanzo_mcp/tools/agent/claude_desktop_auth.py +508 -0
  16. hanzo_mcp/tools/agent/cli_agent_base.py +191 -0
  17. hanzo_mcp/tools/agent/code_auth.py +436 -0
  18. hanzo_mcp/tools/agent/code_auth_tool.py +194 -0
  19. hanzo_mcp/tools/agent/codex_cli_tool.py +123 -0
  20. hanzo_mcp/tools/agent/critic_tool.py +376 -0
  21. hanzo_mcp/tools/agent/gemini_cli_tool.py +128 -0
  22. hanzo_mcp/tools/agent/grok_cli_tool.py +128 -0
  23. hanzo_mcp/tools/agent/iching_tool.py +380 -0
  24. hanzo_mcp/tools/agent/network_tool.py +273 -0
  25. hanzo_mcp/tools/agent/prompt.py +62 -20
  26. hanzo_mcp/tools/agent/review_tool.py +433 -0
  27. hanzo_mcp/tools/agent/swarm_tool.py +535 -0
  28. hanzo_mcp/tools/agent/swarm_tool_v2.py +654 -0
  29. hanzo_mcp/tools/common/base.py +1 -0
  30. hanzo_mcp/tools/common/batch_tool.py +102 -10
  31. hanzo_mcp/tools/common/fastmcp_pagination.py +369 -0
  32. hanzo_mcp/tools/common/forgiving_edit.py +243 -0
  33. hanzo_mcp/tools/common/paginated_base.py +230 -0
  34. hanzo_mcp/tools/common/paginated_response.py +307 -0
  35. hanzo_mcp/tools/common/pagination.py +226 -0
  36. hanzo_mcp/tools/common/tool_list.py +3 -0
  37. hanzo_mcp/tools/common/truncate.py +101 -0
  38. hanzo_mcp/tools/filesystem/__init__.py +29 -0
  39. hanzo_mcp/tools/filesystem/ast_multi_edit.py +562 -0
  40. hanzo_mcp/tools/filesystem/directory_tree_paginated.py +338 -0
  41. hanzo_mcp/tools/lsp/__init__.py +5 -0
  42. hanzo_mcp/tools/lsp/lsp_tool.py +512 -0
  43. hanzo_mcp/tools/memory/__init__.py +76 -0
  44. hanzo_mcp/tools/memory/knowledge_tools.py +518 -0
  45. hanzo_mcp/tools/memory/memory_tools.py +456 -0
  46. hanzo_mcp/tools/search/__init__.py +6 -0
  47. hanzo_mcp/tools/search/find_tool.py +581 -0
  48. hanzo_mcp/tools/search/unified_search.py +953 -0
  49. hanzo_mcp/tools/shell/__init__.py +5 -0
  50. hanzo_mcp/tools/shell/auto_background.py +203 -0
  51. hanzo_mcp/tools/shell/base_process.py +53 -27
  52. hanzo_mcp/tools/shell/bash_tool.py +17 -33
  53. hanzo_mcp/tools/shell/npx_tool.py +15 -32
  54. hanzo_mcp/tools/shell/streaming_command.py +594 -0
  55. hanzo_mcp/tools/shell/uvx_tool.py +15 -32
  56. hanzo_mcp/types.py +23 -0
  57. {hanzo_mcp-0.6.13.dist-info → hanzo_mcp-0.7.1.dist-info}/METADATA +229 -71
  58. {hanzo_mcp-0.6.13.dist-info → hanzo_mcp-0.7.1.dist-info}/RECORD +61 -24
  59. hanzo_mcp-0.6.13.dist-info/licenses/LICENSE +0 -21
  60. {hanzo_mcp-0.6.13.dist-info → hanzo_mcp-0.7.1.dist-info}/WHEEL +0 -0
  61. {hanzo_mcp-0.6.13.dist-info → hanzo_mcp-0.7.1.dist-info}/entry_points.txt +0 -0
  62. {hanzo_mcp-0.6.13.dist-info → hanzo_mcp-0.7.1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,307 @@
1
+ """Automatic pagination response wrapper for MCP tools.
2
+
3
+ This module provides utilities to automatically paginate tool responses
4
+ when they exceed token limits.
5
+ """
6
+
7
+ import json
8
+ from typing import Any, Dict, List, Optional, Union
9
+ from hanzo_mcp.tools.common.truncate import estimate_tokens
10
+ from hanzo_mcp.tools.common.pagination import CursorManager
11
+
12
+
13
+ class AutoPaginatedResponse:
14
+ """Automatically paginate responses that exceed token limits."""
15
+
16
+ # MCP token limit with safety buffer
17
+ MAX_TOKENS = 20000 # Leave 5k buffer from 25k limit
18
+
19
+ @staticmethod
20
+ def create_response(
21
+ content: Union[str, Dict[str, Any], List[Any]],
22
+ cursor: Optional[str] = None,
23
+ max_tokens: int = MAX_TOKENS
24
+ ) -> Dict[str, Any]:
25
+ """Create a response that automatically paginates if too large.
26
+
27
+ Args:
28
+ content: The content to return
29
+ cursor: Optional cursor from request
30
+ max_tokens: Maximum tokens allowed in response
31
+
32
+ Returns:
33
+ Dict with content and optional nextCursor
34
+ """
35
+ # Handle different content types
36
+ if isinstance(content, str):
37
+ return AutoPaginatedResponse._handle_string_response(
38
+ content, cursor, max_tokens
39
+ )
40
+ elif isinstance(content, list):
41
+ return AutoPaginatedResponse._handle_list_response(
42
+ content, cursor, max_tokens
43
+ )
44
+ elif isinstance(content, dict):
45
+ # If dict already has pagination info, return as-is
46
+ if "nextCursor" in content or "cursor" in content:
47
+ return content
48
+ # Otherwise treat as single item
49
+ return AutoPaginatedResponse._handle_dict_response(
50
+ content, cursor, max_tokens
51
+ )
52
+ else:
53
+ # Convert to string for other types
54
+ return AutoPaginatedResponse._handle_string_response(
55
+ str(content), cursor, max_tokens
56
+ )
57
+
58
+ @staticmethod
59
+ def _handle_string_response(
60
+ content: str,
61
+ cursor: Optional[str],
62
+ max_tokens: int
63
+ ) -> Dict[str, Any]:
64
+ """Handle pagination for string responses."""
65
+ # Parse cursor to get offset
66
+ offset = 0
67
+ if cursor:
68
+ cursor_data = CursorManager.parse_cursor(cursor)
69
+ if cursor_data and "offset" in cursor_data:
70
+ offset = cursor_data["offset"]
71
+
72
+ # For strings, paginate by lines
73
+ lines = content.split('\n')
74
+
75
+ if offset >= len(lines):
76
+ return {"content": "", "message": "No more content"}
77
+
78
+ # Build response line by line, checking tokens
79
+ result_lines = []
80
+ current_tokens = 0
81
+ line_index = offset
82
+
83
+ # Add header if this is a continuation
84
+ if offset > 0:
85
+ header = f"[Continued from line {offset + 1}]\n"
86
+ current_tokens = estimate_tokens(header)
87
+ result_lines.append(header)
88
+
89
+ while line_index < len(lines):
90
+ line = lines[line_index]
91
+ line_tokens = estimate_tokens(line + "\n")
92
+
93
+ # Check if adding this line would exceed limit
94
+ if current_tokens + line_tokens > max_tokens:
95
+ # Need to paginate
96
+ if not result_lines:
97
+ # Single line too long, truncate it
98
+ truncated_line = line[:1000] + "... [line truncated]"
99
+ result_lines.append(truncated_line)
100
+ line_index += 1
101
+ break
102
+
103
+ result_lines.append(line)
104
+ current_tokens += line_tokens
105
+ line_index += 1
106
+
107
+ # Build response
108
+ response = {
109
+ "content": '\n'.join(result_lines)
110
+ }
111
+
112
+ # Add pagination info
113
+ if line_index < len(lines):
114
+ response["nextCursor"] = CursorManager.create_offset_cursor(line_index)
115
+ response["pagination_info"] = {
116
+ "current_lines": f"{offset + 1}-{line_index}",
117
+ "total_lines": len(lines),
118
+ "has_more": True
119
+ }
120
+ else:
121
+ response["pagination_info"] = {
122
+ "current_lines": f"{offset + 1}-{len(lines)}",
123
+ "total_lines": len(lines),
124
+ "has_more": False
125
+ }
126
+
127
+ return response
128
+
129
+ @staticmethod
130
+ def _handle_list_response(
131
+ items: List[Any],
132
+ cursor: Optional[str],
133
+ max_tokens: int
134
+ ) -> Dict[str, Any]:
135
+ """Handle pagination for list responses."""
136
+ # Parse cursor to get offset
137
+ offset = 0
138
+ if cursor:
139
+ cursor_data = CursorManager.parse_cursor(cursor)
140
+ if cursor_data and "offset" in cursor_data:
141
+ offset = cursor_data["offset"]
142
+
143
+ if offset >= len(items):
144
+ return {"items": [], "message": "No more items"}
145
+
146
+ # Build response item by item, checking tokens
147
+ result_items = []
148
+ current_tokens = 100 # Base overhead
149
+ item_index = offset
150
+
151
+ # Add header if continuation
152
+ header_obj = {}
153
+ if offset > 0:
154
+ header_obj["continuation_from"] = offset
155
+ current_tokens += 50
156
+
157
+ while item_index < len(items):
158
+ item = items[item_index]
159
+
160
+ # Estimate tokens for this item
161
+ item_str = json.dumps(item) if not isinstance(item, str) else item
162
+ item_tokens = estimate_tokens(item_str)
163
+
164
+ # Check if adding this item would exceed limit
165
+ if current_tokens + item_tokens > max_tokens:
166
+ if not result_items:
167
+ # Single item too large, truncate it
168
+ if isinstance(item, str):
169
+ truncated = item[:5000] + "... [truncated]"
170
+ result_items.append(truncated)
171
+ else:
172
+ result_items.append({"error": "Item too large", "index": item_index})
173
+ item_index += 1
174
+ break
175
+
176
+ result_items.append(item)
177
+ current_tokens += item_tokens
178
+ item_index += 1
179
+
180
+ # Build response
181
+ response = {
182
+ "items": result_items
183
+ }
184
+
185
+ if header_obj:
186
+ response.update(header_obj)
187
+
188
+ # Add pagination info
189
+ if item_index < len(items):
190
+ response["nextCursor"] = CursorManager.create_offset_cursor(item_index)
191
+ response["pagination_info"] = {
192
+ "returned_items": len(result_items),
193
+ "total_items": len(items),
194
+ "has_more": True,
195
+ "next_index": item_index
196
+ }
197
+ else:
198
+ response["pagination_info"] = {
199
+ "returned_items": len(result_items),
200
+ "total_items": len(items),
201
+ "has_more": False
202
+ }
203
+
204
+ return response
205
+
206
+ @staticmethod
207
+ def _handle_dict_response(
208
+ content: Dict[str, Any],
209
+ cursor: Optional[str],
210
+ max_tokens: int
211
+ ) -> Dict[str, Any]:
212
+ """Handle pagination for dict responses."""
213
+ # For dicts, check if it's too large as-is
214
+ content_str = json.dumps(content, indent=2)
215
+ content_tokens = estimate_tokens(content_str)
216
+
217
+ if content_tokens <= max_tokens:
218
+ # Fits within limit
219
+ return content
220
+
221
+ # Too large - need to paginate
222
+ # Strategy: Convert to key-value pairs and paginate
223
+ items = list(content.items())
224
+ offset = 0
225
+
226
+ if cursor:
227
+ cursor_data = CursorManager.parse_cursor(cursor)
228
+ if cursor_data and "offset" in cursor_data:
229
+ offset = cursor_data["offset"]
230
+
231
+ if offset >= len(items):
232
+ return {"content": {}, "message": "No more content"}
233
+
234
+ # Build paginated dict
235
+ result = {}
236
+ current_tokens = 100 # Base overhead
237
+
238
+ for i in range(offset, len(items)):
239
+ key, value = items[i]
240
+
241
+ # Estimate tokens for this entry
242
+ entry_str = json.dumps({key: value})
243
+ entry_tokens = estimate_tokens(entry_str)
244
+
245
+ if current_tokens + entry_tokens > max_tokens:
246
+ if not result:
247
+ # Single entry too large
248
+ result[key] = "[Value too large - use specific key access]"
249
+ break
250
+
251
+ result[key] = value
252
+ current_tokens += entry_tokens
253
+
254
+ # Wrap in response
255
+ response = {
256
+ "content": result
257
+ }
258
+
259
+ # Add pagination info
260
+ processed = offset + len(result)
261
+ if processed < len(items):
262
+ response["nextCursor"] = CursorManager.create_offset_cursor(processed)
263
+ response["pagination_info"] = {
264
+ "keys_returned": len(result),
265
+ "total_keys": len(items),
266
+ "has_more": True
267
+ }
268
+ else:
269
+ response["pagination_info"] = {
270
+ "keys_returned": len(result),
271
+ "total_keys": len(items),
272
+ "has_more": False
273
+ }
274
+
275
+ return response
276
+
277
+
278
+ def paginate_if_needed(
279
+ response: Any,
280
+ cursor: Optional[str] = None,
281
+ force_pagination: bool = False
282
+ ) -> Union[str, Dict[str, Any]]:
283
+ """Wrap a response with automatic pagination if needed.
284
+
285
+ Args:
286
+ response: The response to potentially paginate
287
+ cursor: Optional cursor from request
288
+ force_pagination: Force pagination even for small responses
289
+
290
+ Returns:
291
+ Original response if small enough, otherwise paginated dict
292
+ """
293
+ # Quick check - if response is already paginated, return as-is
294
+ if isinstance(response, dict) and ("nextCursor" in response or "pagination_info" in response):
295
+ return response
296
+
297
+ # For small responses, don't paginate unless forced
298
+ if not force_pagination:
299
+ try:
300
+ response_str = json.dumps(response) if not isinstance(response, str) else response
301
+ if len(response_str) < 10000: # Quick heuristic
302
+ return response
303
+ except:
304
+ pass
305
+
306
+ # Create paginated response
307
+ return AutoPaginatedResponse.create_response(response, cursor)
@@ -0,0 +1,226 @@
1
+ """Pagination utilities for MCP tools.
2
+
3
+ This module provides utilities for implementing cursor-based pagination
4
+ according to the MCP pagination protocol.
5
+ """
6
+
7
+ import base64
8
+ import json
9
+ from typing import Any, Dict, List, Optional, Tuple, TypeVar, Generic
10
+ from dataclasses import dataclass
11
+
12
+
13
+ T = TypeVar('T')
14
+
15
+
16
+ @dataclass
17
+ class PaginationParams:
18
+ """Parameters for pagination."""
19
+ cursor: Optional[str] = None
20
+ page_size: int = 100 # Default page size
21
+
22
+
23
+ @dataclass
24
+ class PaginatedResponse(Generic[T]):
25
+ """A paginated response containing items and optional next cursor."""
26
+ items: List[T]
27
+ next_cursor: Optional[str] = None
28
+
29
+ def to_dict(self, items_key: str = "items") -> Dict[str, Any]:
30
+ """Convert to dictionary format for MCP response.
31
+
32
+ Args:
33
+ items_key: The key to use for items in the response
34
+
35
+ Returns:
36
+ Dictionary with items and optional nextCursor
37
+ """
38
+ result = {items_key: self.items}
39
+ if self.next_cursor:
40
+ result["nextCursor"] = self.next_cursor
41
+ return result
42
+
43
+
44
+ class CursorManager:
45
+ """Manages cursor creation and parsing for pagination."""
46
+
47
+ @staticmethod
48
+ def create_cursor(data: Dict[str, Any]) -> str:
49
+ """Create an opaque cursor from data.
50
+
51
+ Args:
52
+ data: Data to encode in the cursor
53
+
54
+ Returns:
55
+ Base64-encoded cursor string
56
+ """
57
+ json_data = json.dumps(data, separators=(',', ':'))
58
+ return base64.b64encode(json_data.encode()).decode()
59
+
60
+ @staticmethod
61
+ def parse_cursor(cursor: str) -> Optional[Dict[str, Any]]:
62
+ """Parse a cursor string back to data.
63
+
64
+ Args:
65
+ cursor: Base64-encoded cursor string
66
+
67
+ Returns:
68
+ Decoded data or None if invalid
69
+ """
70
+ try:
71
+ decoded = base64.b64decode(cursor.encode()).decode()
72
+ return json.loads(decoded)
73
+ except (ValueError, json.JSONDecodeError):
74
+ return None
75
+
76
+ @staticmethod
77
+ def create_offset_cursor(offset: int) -> str:
78
+ """Create a cursor for offset-based pagination.
79
+
80
+ Args:
81
+ offset: The offset for the next page
82
+
83
+ Returns:
84
+ Cursor string
85
+ """
86
+ return CursorManager.create_cursor({"offset": offset})
87
+
88
+ @staticmethod
89
+ def parse_offset_cursor(cursor: Optional[str]) -> int:
90
+ """Parse an offset cursor.
91
+
92
+ Args:
93
+ cursor: Cursor string or None
94
+
95
+ Returns:
96
+ Offset value (0 if cursor is None or invalid)
97
+ """
98
+ if not cursor:
99
+ return 0
100
+
101
+ data = CursorManager.parse_cursor(cursor)
102
+ if not data or "offset" not in data:
103
+ return 0
104
+
105
+ return int(data["offset"])
106
+
107
+
108
+ class Paginator(Generic[T]):
109
+ """Generic paginator for any list of items."""
110
+
111
+ def __init__(self, items: List[T], page_size: int = 100):
112
+ """Initialize paginator.
113
+
114
+ Args:
115
+ items: List of items to paginate
116
+ page_size: Number of items per page
117
+ """
118
+ self.items = items
119
+ self.page_size = page_size
120
+
121
+ def get_page(self, cursor: Optional[str] = None) -> PaginatedResponse[T]:
122
+ """Get a page of results.
123
+
124
+ Args:
125
+ cursor: Optional cursor for the page
126
+
127
+ Returns:
128
+ Paginated response with items and next cursor
129
+ """
130
+ offset = CursorManager.parse_offset_cursor(cursor)
131
+
132
+ # Get the page of items
133
+ start = offset
134
+ end = min(start + self.page_size, len(self.items))
135
+ page_items = self.items[start:end]
136
+
137
+ # Create next cursor if there are more items
138
+ next_cursor = None
139
+ if end < len(self.items):
140
+ next_cursor = CursorManager.create_offset_cursor(end)
141
+
142
+ return PaginatedResponse(items=page_items, next_cursor=next_cursor)
143
+
144
+
145
+ class StreamPaginator(Generic[T]):
146
+ """Paginator for streaming/generator-based results."""
147
+
148
+ def __init__(self, page_size: int = 100):
149
+ """Initialize stream paginator.
150
+
151
+ Args:
152
+ page_size: Number of items per page
153
+ """
154
+ self.page_size = page_size
155
+
156
+ def paginate_stream(
157
+ self,
158
+ stream_generator,
159
+ cursor: Optional[str] = None
160
+ ) -> PaginatedResponse[T]:
161
+ """Paginate results from a stream/generator.
162
+
163
+ Args:
164
+ stream_generator: Generator function that yields items
165
+ cursor: Optional cursor for resuming
166
+
167
+ Returns:
168
+ Paginated response
169
+ """
170
+ items = []
171
+ skip_count = 0
172
+
173
+ # Parse cursor to get skip count
174
+ if cursor:
175
+ cursor_data = CursorManager.parse_cursor(cursor)
176
+ if cursor_data and "skip" in cursor_data:
177
+ skip_count = cursor_data["skip"]
178
+
179
+ # Skip items based on cursor
180
+ item_count = 0
181
+ for item in stream_generator():
182
+ if item_count < skip_count:
183
+ item_count += 1
184
+ continue
185
+
186
+ items.append(item)
187
+ if len(items) >= self.page_size:
188
+ # We have a full page, create cursor for next page
189
+ next_cursor = CursorManager.create_cursor({
190
+ "skip": skip_count + len(items)
191
+ })
192
+ return PaginatedResponse(items=items, next_cursor=next_cursor)
193
+
194
+ # No more items
195
+ return PaginatedResponse(items=items, next_cursor=None)
196
+
197
+
198
+ def paginate_list(
199
+ items: List[T],
200
+ cursor: Optional[str] = None,
201
+ page_size: int = 100
202
+ ) -> PaginatedResponse[T]:
203
+ """Convenience function to paginate a list.
204
+
205
+ Args:
206
+ items: List to paginate
207
+ cursor: Optional cursor
208
+ page_size: Items per page
209
+
210
+ Returns:
211
+ Paginated response
212
+ """
213
+ paginator = Paginator(items, page_size)
214
+ return paginator.get_page(cursor)
215
+
216
+
217
+ def validate_cursor(cursor: str) -> bool:
218
+ """Validate that a cursor is properly formatted.
219
+
220
+ Args:
221
+ cursor: Cursor string to validate
222
+
223
+ Returns:
224
+ True if valid, False otherwise
225
+ """
226
+ return CursorManager.parse_cursor(cursor) is not None
@@ -64,6 +64,7 @@ class ToolListTool(BaseTool):
64
64
  ],
65
65
  "shell": [
66
66
  ("run_command", "Execute shell commands (--background option)"),
67
+ ("streaming_command", "Run commands with disk-based output streaming"),
67
68
  ("processes", "List background processes"),
68
69
  ("pkill", "Kill background processes"),
69
70
  ("logs", "View process logs"),
@@ -78,6 +79,8 @@ class ToolListTool(BaseTool):
78
79
  "ai": [
79
80
  ("llm", "LLM interface (query/consensus/list/models/enable/disable)"),
80
81
  ("agent", "AI agents (run/start/call/stop/list with A2A support)"),
82
+ ("swarm", "Parallel agent execution across multiple files"),
83
+ ("hierarchical_swarm", "Hierarchical agent teams with Claude Code integration"),
81
84
  ("mcp", "MCP servers (list/add/remove/enable/disable/restart)"),
82
85
  ],
83
86
  "config": [
@@ -0,0 +1,101 @@
1
+ """Response truncation utilities for MCP tools.
2
+
3
+ This module provides utilities to ensure MCP tool responses don't exceed token limits.
4
+ """
5
+
6
+ import tiktoken
7
+
8
+
9
+ def estimate_tokens(text: str, model: str = "gpt-4") -> int:
10
+ """Estimate the number of tokens in a text string.
11
+
12
+ Args:
13
+ text: The text to estimate tokens for
14
+ model: The model to use for token estimation (default: gpt-4)
15
+
16
+ Returns:
17
+ Estimated number of tokens
18
+ """
19
+ try:
20
+ # Try to get the encoding for the specific model
21
+ encoding = tiktoken.encoding_for_model(model)
22
+ except KeyError:
23
+ # Fall back to cl100k_base which is used by newer models
24
+ encoding = tiktoken.get_encoding("cl100k_base")
25
+
26
+ return len(encoding.encode(text))
27
+
28
+
29
+ def truncate_response(
30
+ response: str,
31
+ max_tokens: int = 20000,
32
+ truncation_message: str = "\n\n[Response truncated due to length. Please use pagination, filtering, or limit parameters to see more.]"
33
+ ) -> str:
34
+ """Truncate a response to fit within token limits.
35
+
36
+ Args:
37
+ response: The response text to truncate
38
+ max_tokens: Maximum number of tokens allowed (default: 20000)
39
+ truncation_message: Message to append when truncating
40
+
41
+ Returns:
42
+ Truncated response if needed, original response otherwise
43
+ """
44
+ # Quick check - if response is short, no need to count tokens
45
+ if len(response) < max_tokens * 2: # Rough estimate: 1 token ≈ 2-4 chars
46
+ return response
47
+
48
+ # Estimate tokens
49
+ token_count = estimate_tokens(response)
50
+
51
+ # If within limit, return as-is
52
+ if token_count <= max_tokens:
53
+ return response
54
+
55
+ # Need to truncate
56
+ # Binary search to find the right truncation point
57
+ left, right = 0, len(response)
58
+ truncation_msg_tokens = estimate_tokens(truncation_message)
59
+ target_tokens = max_tokens - truncation_msg_tokens
60
+
61
+ while left < right - 1:
62
+ mid = (left + right) // 2
63
+ mid_tokens = estimate_tokens(response[:mid])
64
+
65
+ if mid_tokens <= target_tokens:
66
+ left = mid
67
+ else:
68
+ right = mid
69
+
70
+ # Find a good break point (newline or space)
71
+ truncate_at = left
72
+ for i in range(min(100, left), -1, -1):
73
+ if response[left - i] in '\n ':
74
+ truncate_at = left - i
75
+ break
76
+
77
+ return response[:truncate_at] + truncation_message
78
+
79
+
80
+ def truncate_lines(
81
+ response: str,
82
+ max_lines: int = 1000,
83
+ truncation_message: str = "\n\n[Response truncated to {max_lines} lines. Please use pagination or filtering to see more.]"
84
+ ) -> str:
85
+ """Truncate a response by number of lines.
86
+
87
+ Args:
88
+ response: The response text to truncate
89
+ max_lines: Maximum number of lines allowed (default: 1000)
90
+ truncation_message: Message template to append when truncating
91
+
92
+ Returns:
93
+ Truncated response if needed, original response otherwise
94
+ """
95
+ lines = response.split('\n')
96
+
97
+ if len(lines) <= max_lines:
98
+ return response
99
+
100
+ truncated = '\n'.join(lines[:max_lines])
101
+ return truncated + truncation_message.format(max_lines=max_lines)