tsugite-cli 0.3.3__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.
Files changed (101) hide show
  1. tsugite/__init__.py +6 -0
  2. tsugite/agent_composition.py +163 -0
  3. tsugite/agent_inheritance.py +479 -0
  4. tsugite/agent_preparation.py +236 -0
  5. tsugite/agent_runner/__init__.py +45 -0
  6. tsugite/agent_runner/helpers.py +106 -0
  7. tsugite/agent_runner/history_integration.py +248 -0
  8. tsugite/agent_runner/metrics.py +100 -0
  9. tsugite/agent_runner/runner.py +1879 -0
  10. tsugite/agent_runner/validation.py +70 -0
  11. tsugite/agent_utils.py +167 -0
  12. tsugite/attachments/__init__.py +65 -0
  13. tsugite/attachments/auto_context.py +199 -0
  14. tsugite/attachments/base.py +34 -0
  15. tsugite/attachments/file.py +51 -0
  16. tsugite/attachments/inline.py +31 -0
  17. tsugite/attachments/storage.py +178 -0
  18. tsugite/attachments/url.py +59 -0
  19. tsugite/attachments/youtube.py +101 -0
  20. tsugite/benchmark/__init__.py +62 -0
  21. tsugite/benchmark/config.py +183 -0
  22. tsugite/benchmark/core.py +292 -0
  23. tsugite/benchmark/discovery.py +377 -0
  24. tsugite/benchmark/evaluators.py +671 -0
  25. tsugite/benchmark/execution.py +657 -0
  26. tsugite/benchmark/metrics.py +204 -0
  27. tsugite/benchmark/reports.py +420 -0
  28. tsugite/benchmark/utils.py +288 -0
  29. tsugite/builtin_agents/chat-assistant.md +53 -0
  30. tsugite/builtin_agents/default.md +140 -0
  31. tsugite/builtin_agents.py +5 -0
  32. tsugite/cache.py +195 -0
  33. tsugite/cli/__init__.py +1042 -0
  34. tsugite/cli/agents.py +148 -0
  35. tsugite/cli/attachments.py +193 -0
  36. tsugite/cli/benchmark.py +663 -0
  37. tsugite/cli/cache.py +113 -0
  38. tsugite/cli/config.py +272 -0
  39. tsugite/cli/helpers.py +534 -0
  40. tsugite/cli/history.py +193 -0
  41. tsugite/cli/init.py +387 -0
  42. tsugite/cli/mcp.py +193 -0
  43. tsugite/cli/tools.py +419 -0
  44. tsugite/config.py +204 -0
  45. tsugite/console.py +48 -0
  46. tsugite/constants.py +21 -0
  47. tsugite/core/__init__.py +19 -0
  48. tsugite/core/agent.py +774 -0
  49. tsugite/core/executor.py +300 -0
  50. tsugite/core/memory.py +67 -0
  51. tsugite/core/tools.py +271 -0
  52. tsugite/docker_cli.py +270 -0
  53. tsugite/events/__init__.py +55 -0
  54. tsugite/events/base.py +46 -0
  55. tsugite/events/bus.py +62 -0
  56. tsugite/events/events.py +224 -0
  57. tsugite/exceptions.py +40 -0
  58. tsugite/history/__init__.py +29 -0
  59. tsugite/history/index.py +210 -0
  60. tsugite/history/models.py +106 -0
  61. tsugite/history/storage.py +157 -0
  62. tsugite/mcp_client.py +219 -0
  63. tsugite/mcp_config.py +174 -0
  64. tsugite/md_agents.py +751 -0
  65. tsugite/models.py +257 -0
  66. tsugite/renderer.py +151 -0
  67. tsugite/shell_tool_config.py +265 -0
  68. tsugite/templates/assistant.md +14 -0
  69. tsugite/tools/__init__.py +265 -0
  70. tsugite/tools/agents.py +312 -0
  71. tsugite/tools/edit_strategies.py +393 -0
  72. tsugite/tools/fs.py +329 -0
  73. tsugite/tools/http.py +239 -0
  74. tsugite/tools/interactive.py +430 -0
  75. tsugite/tools/shell.py +129 -0
  76. tsugite/tools/shell_tools.py +214 -0
  77. tsugite/tools/tasks.py +339 -0
  78. tsugite/tsugite.py +7 -0
  79. tsugite/ui/__init__.py +46 -0
  80. tsugite/ui/base.py +638 -0
  81. tsugite/ui/chat.py +265 -0
  82. tsugite/ui/chat.tcss +92 -0
  83. tsugite/ui/chat_history.py +286 -0
  84. tsugite/ui/helpers.py +102 -0
  85. tsugite/ui/jsonl.py +125 -0
  86. tsugite/ui/live_template.py +529 -0
  87. tsugite/ui/plain.py +419 -0
  88. tsugite/ui/textual_chat.py +642 -0
  89. tsugite/ui/textual_handler.py +225 -0
  90. tsugite/ui/widgets/__init__.py +6 -0
  91. tsugite/ui/widgets/base_scroll_log.py +27 -0
  92. tsugite/ui/widgets/message_list.py +121 -0
  93. tsugite/ui/widgets/thought_log.py +80 -0
  94. tsugite/ui_context.py +90 -0
  95. tsugite/utils.py +367 -0
  96. tsugite/xdg.py +104 -0
  97. tsugite_cli-0.3.3.dist-info/METADATA +325 -0
  98. tsugite_cli-0.3.3.dist-info/RECORD +101 -0
  99. tsugite_cli-0.3.3.dist-info/WHEEL +4 -0
  100. tsugite_cli-0.3.3.dist-info/entry_points.txt +5 -0
  101. tsugite_cli-0.3.3.dist-info/licenses/LICENSE +235 -0
tsugite/tools/fs.py ADDED
@@ -0,0 +1,329 @@
1
+ """File system tools for Tsugite agents."""
2
+
3
+ from pathlib import Path
4
+ from typing import Any, Dict, List, Optional
5
+
6
+ from ..tools import tool
7
+ from ..utils import standardize_error_message
8
+
9
+
10
+ @tool
11
+ def read_file(path: str, start_line: Optional[int] = None, end_line: Optional[int] = None) -> str:
12
+ """Read content from a file, optionally with line range.
13
+
14
+ Args:
15
+ path: Path to the file to read
16
+ start_line: Starting line number (1-indexed, 0 also accepted). If provided, returns numbered lines.
17
+ end_line: Ending line number (1-indexed, inclusive). Defaults to end of file.
18
+
19
+ Returns:
20
+ If start_line is None: Full file content as plain text
21
+ If start_line is provided: Numbered lines in format "LINE_NUM: content"
22
+ """
23
+ file_path = Path(path)
24
+
25
+ if not file_path.exists():
26
+ raise FileNotFoundError(f"File not found: {path}")
27
+
28
+ if file_path.is_dir():
29
+ raise IsADirectoryError(f"Path is a directory: {path}")
30
+
31
+ try:
32
+ # If no line range specified, read entire file (backward compatible)
33
+ if start_line is None:
34
+ return file_path.read_text(encoding="utf-8")
35
+
36
+ # Line range mode - return numbered lines
37
+ # Accept 0-based indexing (treat 0 as 1 for convenience)
38
+ if start_line < 0:
39
+ raise ValueError("start_line must be >= 0")
40
+ if start_line == 0:
41
+ start_line = 1
42
+
43
+ if end_line is not None and end_line < start_line:
44
+ raise ValueError(f"end_line ({end_line}) must be >= start_line ({start_line})")
45
+
46
+ lines = file_path.read_text(encoding="utf-8").splitlines()
47
+ total_lines = len(lines)
48
+
49
+ # Adjust end_line if not specified or beyond file length
50
+ if end_line is None:
51
+ end_line = total_lines
52
+ else:
53
+ end_line = min(end_line, total_lines)
54
+
55
+ # Extract requested range (convert to 0-indexed)
56
+ start_idx = start_line - 1
57
+ end_idx = end_line
58
+
59
+ if start_idx >= total_lines:
60
+ return f"File only has {total_lines} lines, but start_line is {start_line}"
61
+
62
+ selected_lines = lines[start_idx:end_idx]
63
+
64
+ # Format with line numbers
65
+ formatted_lines = [f"{i + start_line}: {line}" for i, line in enumerate(selected_lines)]
66
+
67
+ return "\n".join(formatted_lines)
68
+
69
+ except Exception as e:
70
+ if isinstance(e, (FileNotFoundError, IsADirectoryError, ValueError)):
71
+ raise
72
+ raise RuntimeError(standardize_error_message("read", f"file {path}", e)) from e
73
+
74
+
75
+ @tool
76
+ def write_file(path: str, content: str) -> str:
77
+ """Write content to a file.
78
+
79
+ Args:
80
+ path: Path to the file to write
81
+ content: Content to write to the file
82
+ """
83
+ file_path = Path(path)
84
+ file_path.parent.mkdir(parents=True, exist_ok=True)
85
+
86
+ try:
87
+ file_path.write_text(content, encoding="utf-8")
88
+ return f"Successfully wrote {len(content)} characters to {path}"
89
+ except Exception as e:
90
+ raise RuntimeError(standardize_error_message("write", f"file {path}", e)) from e
91
+
92
+
93
+ @tool
94
+ def list_files(path: str = ".", pattern: str = "*") -> List[str]:
95
+ """List files in a directory with optional pattern matching.
96
+
97
+ Args:
98
+ path: Directory path to list files from
99
+ pattern: Glob pattern to match files
100
+ """
101
+ dir_path = Path(path)
102
+
103
+ if not dir_path.exists():
104
+ raise FileNotFoundError(f"Directory not found: {path}")
105
+
106
+ if not dir_path.is_dir():
107
+ raise NotADirectoryError(f"Path is not a directory: {path}")
108
+
109
+ try:
110
+ files = []
111
+ for item in dir_path.glob(pattern):
112
+ if item.is_file():
113
+ files.append(str(item.relative_to(dir_path)))
114
+
115
+ return sorted(files)
116
+ except Exception as e:
117
+ raise RuntimeError(standardize_error_message("list files in", f"directory {path}", e)) from e
118
+
119
+
120
+ @tool
121
+ def file_exists(path: str) -> bool:
122
+ """Check if a file exists.
123
+
124
+ Args:
125
+ path: Path to check for existence
126
+ """
127
+ return Path(path).exists()
128
+
129
+
130
+ @tool
131
+ def create_directory(path: str) -> str:
132
+ """Create a directory and any necessary parent directories.
133
+
134
+ Args:
135
+ path: Directory path to create
136
+ """
137
+ dir_path = Path(path)
138
+
139
+ try:
140
+ dir_path.mkdir(parents=True, exist_ok=True)
141
+ return f"Successfully created directory: {path}"
142
+ except Exception as e:
143
+ raise RuntimeError(standardize_error_message("create", f"directory {path}", e)) from e
144
+
145
+
146
+ @tool
147
+ def get_file_info(path: str) -> Dict[str, Any]:
148
+ """Get file metadata without reading full content.
149
+
150
+ Args:
151
+ path: Path to the file
152
+
153
+ Returns:
154
+ Dictionary with file metadata:
155
+ - line_count: Total number of lines
156
+ - size_bytes: File size in bytes
157
+ - last_modified: Last modification timestamp (ISO format)
158
+ - exists: Whether file exists
159
+ - is_directory: Whether path is a directory
160
+ """
161
+ import datetime
162
+
163
+ file_path = Path(path)
164
+
165
+ info = {
166
+ "exists": file_path.exists(),
167
+ "is_directory": False,
168
+ "line_count": 0,
169
+ "size_bytes": 0,
170
+ "last_modified": None,
171
+ }
172
+
173
+ if not file_path.exists():
174
+ return info
175
+
176
+ info["is_directory"] = file_path.is_dir()
177
+
178
+ if file_path.is_dir():
179
+ return info
180
+
181
+ try:
182
+ # Get file stats
183
+ stats = file_path.stat()
184
+ info["size_bytes"] = stats.st_size
185
+ info["last_modified"] = datetime.datetime.fromtimestamp(stats.st_mtime).isoformat()
186
+
187
+ # Count lines
188
+ content = file_path.read_text(encoding="utf-8")
189
+ info["line_count"] = len(content.splitlines())
190
+
191
+ return info
192
+
193
+ except Exception as e:
194
+ raise RuntimeError(standardize_error_message("get info for", f"file {path}", e)) from e
195
+
196
+
197
+ @tool
198
+ def edit_file(
199
+ path: str,
200
+ old_string: Optional[str] = None,
201
+ new_string: Optional[str] = None,
202
+ expected_replacements: int = 1,
203
+ edits: Optional[List[Dict[str, Any]]] = None,
204
+ ) -> str:
205
+ """Edit a file with single or multiple replacements.
206
+
207
+ Two modes of operation:
208
+
209
+ **Single edit mode** - Use old_string and new_string:
210
+ - Applies one replacement with smart matching strategies
211
+ - Strategies: exact, line-trimmed, block-anchor, whitespace-normalized, indentation-flexible
212
+
213
+ **Batch edit mode** - Use edits parameter:
214
+ - Apply multiple edits sequentially
215
+ - Atomic: if any edit fails, none are applied
216
+ - Each edit operates on the result of the previous edit
217
+
218
+ Args:
219
+ path: Path to the file to edit
220
+ old_string: Text to find (for single edit mode)
221
+ new_string: Replacement text (for single edit mode)
222
+ expected_replacements: Expected match count (default: 1, for single edit mode)
223
+ edits: List of edit dicts (for batch edit mode)
224
+ Each dict: {"old_string": str, "new_string": str, "expected_replacements": int}
225
+
226
+ Returns:
227
+ Success message with number of replacements made
228
+
229
+ Examples:
230
+ Single edit:
231
+ edit_file("config.py", old_string="DEBUG = True", new_string="DEBUG = False")
232
+
233
+ Batch edits:
234
+ edit_file("config.py", edits=[
235
+ {"old_string": "DEBUG = True", "new_string": "DEBUG = False"},
236
+ {"old_string": "TIMEOUT = 30", "new_string": "TIMEOUT = 60"}
237
+ ])
238
+
239
+ Raises:
240
+ ValueError: If parameters are invalid or conflicting
241
+ RuntimeError: If edits fail
242
+ """
243
+ from .edit_strategies import apply_replacement, preserve_line_ending
244
+
245
+ # Validate mode selection
246
+ single_mode = old_string is not None
247
+ batch_mode = edits is not None
248
+
249
+ if single_mode and batch_mode:
250
+ raise ValueError("Provide either old_string/new_string OR edits, not both")
251
+ if not single_mode and not batch_mode:
252
+ raise ValueError("Must provide either old_string/new_string OR edits")
253
+
254
+ if single_mode and new_string is None:
255
+ raise ValueError("new_string is required when using old_string")
256
+
257
+ # Validate file
258
+ file_path = Path(path)
259
+
260
+ if not file_path.exists():
261
+ raise FileNotFoundError(f"File not found: {path}")
262
+
263
+ if file_path.is_dir():
264
+ raise IsADirectoryError(f"Path is a directory: {path}")
265
+
266
+ try:
267
+ # Read original content
268
+ original_content = file_path.read_text(encoding="utf-8")
269
+
270
+ # Normalize to \n for processing
271
+ current_content = original_content.replace("\r\n", "\n")
272
+
273
+ if single_mode:
274
+ # Single edit mode
275
+ normalized_old = old_string.replace("\r\n", "\n")
276
+ normalized_new = new_string.replace("\r\n", "\n")
277
+
278
+ new_content, match_count, error = apply_replacement(
279
+ current_content, normalized_old, normalized_new, expected_replacements
280
+ )
281
+
282
+ if error:
283
+ raise RuntimeError(f"Failed to edit {path}: {error}")
284
+
285
+ total_edits = 1
286
+ total_replacements = match_count
287
+
288
+ else:
289
+ # Batch edit mode
290
+ if not edits:
291
+ raise ValueError("edits list cannot be empty")
292
+
293
+ total_replacements = 0
294
+ for i, edit in enumerate(edits, 1):
295
+ if "old_string" not in edit or "new_string" not in edit:
296
+ raise ValueError(f"Edit #{i} missing required 'old_string' or 'new_string'")
297
+
298
+ old_str = edit["old_string"].replace("\r\n", "\n")
299
+ new_str = edit["new_string"].replace("\r\n", "\n")
300
+ expected = edit.get("expected_replacements", 1)
301
+
302
+ current_content, match_count, error = apply_replacement(current_content, old_str, new_str, expected)
303
+
304
+ if error:
305
+ raise RuntimeError(f"Edit #{i} failed: {error}")
306
+
307
+ total_replacements += match_count
308
+
309
+ new_content = current_content
310
+ total_edits = len(edits)
311
+
312
+ # Restore original line endings
313
+ final_content = preserve_line_ending(original_content, new_content)
314
+
315
+ # Create parent directories if needed
316
+ file_path.parent.mkdir(parents=True, exist_ok=True)
317
+
318
+ # Write updated content
319
+ file_path.write_text(final_content, encoding="utf-8")
320
+
321
+ if batch_mode:
322
+ return f"Successfully applied {total_edits} edit(s) to {path} ({total_replacements} total replacements)"
323
+ else:
324
+ return f"Successfully edited {path}: {total_replacements} replacement(s) made"
325
+
326
+ except Exception as e:
327
+ if isinstance(e, (FileNotFoundError, IsADirectoryError, RuntimeError, ValueError)):
328
+ raise
329
+ raise RuntimeError(standardize_error_message("edit", f"file {path}", e)) from e
tsugite/tools/http.py ADDED
@@ -0,0 +1,239 @@
1
+ """HTTP client tools for Tsugite agents."""
2
+
3
+ import json
4
+ from typing import Any, Dict, Optional, Union
5
+
6
+ import httpx
7
+ from ddgs import DDGS
8
+
9
+ from tsugite.tools import tool
10
+
11
+
12
+ @tool
13
+ def fetch_json(
14
+ url: str,
15
+ method: str = "GET",
16
+ headers: Optional[Dict[str, str]] = None,
17
+ timeout: int = 30,
18
+ ) -> Union[Dict[str, Any], list]:
19
+ """Fetch JSON data from a URL.
20
+
21
+ Args:
22
+ url: URL to fetch from
23
+ method: HTTP method (GET, POST, PUT, DELETE)
24
+ headers: Optional HTTP headers
25
+ timeout: Request timeout in seconds
26
+ """
27
+ try:
28
+ with httpx.Client(timeout=timeout) as client:
29
+ response = client.request(
30
+ method=method.upper(),
31
+ url=url,
32
+ headers=headers or {},
33
+ )
34
+ response.raise_for_status()
35
+
36
+ try:
37
+ return response.json()
38
+ except json.JSONDecodeError as e:
39
+ raise RuntimeError(f"Invalid JSON response: {e}") from e
40
+
41
+ except httpx.TimeoutException as exc:
42
+ raise RuntimeError(f"Request timed out after {timeout} seconds") from exc
43
+ except httpx.HTTPStatusError as e:
44
+ raise RuntimeError(f"HTTP error {e.response.status_code}: {e.response.text}") from e
45
+ except Exception as e:
46
+ raise RuntimeError(f"Request failed: {e}") from e
47
+
48
+
49
+ @tool
50
+ def fetch_text(
51
+ url: str,
52
+ method: str = "GET",
53
+ headers: Optional[Dict[str, str]] = None,
54
+ timeout: int = 30,
55
+ ) -> str:
56
+ """Fetch text content from a URL.
57
+
58
+ Args:
59
+ url: URL to fetch from
60
+ method: HTTP method
61
+ headers: Optional HTTP headers
62
+ timeout: Request timeout in seconds
63
+
64
+ Returns:
65
+ Response text content
66
+
67
+ Raises:
68
+ RuntimeError: If request fails
69
+ """
70
+ try:
71
+ with httpx.Client(timeout=timeout) as client:
72
+ response = client.request(
73
+ method=method.upper(),
74
+ url=url,
75
+ headers=headers or {},
76
+ )
77
+ response.raise_for_status()
78
+ return response.text
79
+
80
+ except httpx.TimeoutException as exc:
81
+ raise RuntimeError(f"Request timed out after {timeout} seconds") from exc
82
+ except httpx.HTTPStatusError as e:
83
+ raise RuntimeError(f"HTTP error {e.response.status_code}: {e.response.text}") from e
84
+ except Exception as e:
85
+ raise RuntimeError(f"Request failed: {e}") from e
86
+
87
+
88
+ @tool
89
+ def post_json(
90
+ url: str,
91
+ data: Dict[str, Any],
92
+ headers: Optional[Dict[str, str]] = None,
93
+ timeout: int = 30,
94
+ ) -> Union[Dict[str, Any], str]:
95
+ """Send JSON data via POST request.
96
+
97
+ Args:
98
+ url: URL to post to
99
+ data: JSON data to send
100
+ headers: Optional HTTP headers
101
+ timeout: Request timeout in seconds
102
+
103
+ Returns:
104
+ Response as JSON dict/list or text if not JSON
105
+
106
+ Raises:
107
+ RuntimeError: If request fails
108
+ """
109
+ try:
110
+ request_headers = {"Content-Type": "application/json"}
111
+ if headers:
112
+ request_headers.update(headers)
113
+
114
+ with httpx.Client(timeout=timeout) as client:
115
+ response = client.post(
116
+ url=url,
117
+ json=data,
118
+ headers=request_headers,
119
+ )
120
+ response.raise_for_status()
121
+
122
+ # Try to return JSON, fall back to text
123
+ try:
124
+ return response.json()
125
+ except json.JSONDecodeError:
126
+ return response.text
127
+
128
+ except httpx.TimeoutException as exc:
129
+ raise RuntimeError(f"Request timed out after {timeout} seconds") from exc
130
+ except httpx.HTTPStatusError as e:
131
+ raise RuntimeError(f"HTTP error {e.response.status_code}: {e.response.text}") from e
132
+ except Exception as e:
133
+ raise RuntimeError(f"Request failed: {e}") from e
134
+
135
+
136
+ @tool
137
+ def download_file(url: str, local_path: str, timeout: int = 60) -> str:
138
+ """Download a file from URL to local path.
139
+
140
+ Args:
141
+ url: URL to download from
142
+ local_path: Local file path to save to
143
+ timeout: Request timeout in seconds
144
+
145
+ Returns:
146
+ Success message with file size
147
+
148
+ Raises:
149
+ RuntimeError: If download fails
150
+ """
151
+ try:
152
+ with httpx.Client(timeout=timeout) as client:
153
+ with client.stream("GET", url) as response:
154
+ response.raise_for_status()
155
+
156
+ total_size = 0
157
+ with open(local_path, "wb") as f:
158
+ for chunk in response.iter_bytes(chunk_size=8192):
159
+ f.write(chunk)
160
+ total_size += len(chunk)
161
+
162
+ return f"Downloaded {total_size} bytes to {local_path}"
163
+
164
+ except httpx.TimeoutException as exc:
165
+ raise RuntimeError(f"Download timed out after {timeout} seconds") from exc
166
+ except httpx.HTTPStatusError as e:
167
+ raise RuntimeError(f"HTTP error {e.response.status_code}") from e
168
+ except OSError as e:
169
+ raise RuntimeError(f"File write error: {e}") from e
170
+ except Exception as e:
171
+ raise RuntimeError(f"Download failed: {e}") from e
172
+
173
+
174
+ @tool
175
+ def check_url(url: str, timeout: int = 10) -> Dict[str, Any]:
176
+ """Check if a URL is accessible and return basic info.
177
+
178
+ Args:
179
+ url: URL to check
180
+ timeout: Request timeout in seconds
181
+
182
+ Returns:
183
+ Dictionary with status info
184
+
185
+ Raises:
186
+ RuntimeError: If check fails
187
+ """
188
+ try:
189
+ with httpx.Client(timeout=timeout) as client:
190
+ response = client.head(url, follow_redirects=True)
191
+
192
+ return {
193
+ "url": str(response.url),
194
+ "status_code": response.status_code,
195
+ "headers": dict(response.headers),
196
+ "accessible": response.status_code < 400,
197
+ "content_type": response.headers.get("content-type", "unknown"),
198
+ "content_length": response.headers.get("content-length", "unknown"),
199
+ }
200
+
201
+ except Exception as e:
202
+ return {
203
+ "url": url,
204
+ "accessible": False,
205
+ "error": str(e),
206
+ }
207
+
208
+
209
+ @tool
210
+ def web_search(query: str, max_results: int = 5) -> list[Dict[str, str]]:
211
+ """Search the web using DuckDuckGo and return results.
212
+
213
+ Args:
214
+ query: Search query string
215
+ max_results: Maximum number of results to return (default: 5)
216
+
217
+ Returns:
218
+ List of search result dictionaries with title, url, and snippet
219
+
220
+ Raises:
221
+ RuntimeError: If search fails
222
+ """
223
+ try:
224
+ results = []
225
+ with DDGS() as ddgs:
226
+ search_results = ddgs.text(query, max_results=max_results)
227
+ for result in search_results:
228
+ results.append(
229
+ {
230
+ "title": result.get("title", ""),
231
+ "url": result.get("href", ""),
232
+ "snippet": result.get("body", ""),
233
+ }
234
+ )
235
+
236
+ return results
237
+
238
+ except Exception as e:
239
+ raise RuntimeError(f"Web search failed: {e}") from e