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.
- tsugite/__init__.py +6 -0
- tsugite/agent_composition.py +163 -0
- tsugite/agent_inheritance.py +479 -0
- tsugite/agent_preparation.py +236 -0
- tsugite/agent_runner/__init__.py +45 -0
- tsugite/agent_runner/helpers.py +106 -0
- tsugite/agent_runner/history_integration.py +248 -0
- tsugite/agent_runner/metrics.py +100 -0
- tsugite/agent_runner/runner.py +1879 -0
- tsugite/agent_runner/validation.py +70 -0
- tsugite/agent_utils.py +167 -0
- tsugite/attachments/__init__.py +65 -0
- tsugite/attachments/auto_context.py +199 -0
- tsugite/attachments/base.py +34 -0
- tsugite/attachments/file.py +51 -0
- tsugite/attachments/inline.py +31 -0
- tsugite/attachments/storage.py +178 -0
- tsugite/attachments/url.py +59 -0
- tsugite/attachments/youtube.py +101 -0
- tsugite/benchmark/__init__.py +62 -0
- tsugite/benchmark/config.py +183 -0
- tsugite/benchmark/core.py +292 -0
- tsugite/benchmark/discovery.py +377 -0
- tsugite/benchmark/evaluators.py +671 -0
- tsugite/benchmark/execution.py +657 -0
- tsugite/benchmark/metrics.py +204 -0
- tsugite/benchmark/reports.py +420 -0
- tsugite/benchmark/utils.py +288 -0
- tsugite/builtin_agents/chat-assistant.md +53 -0
- tsugite/builtin_agents/default.md +140 -0
- tsugite/builtin_agents.py +5 -0
- tsugite/cache.py +195 -0
- tsugite/cli/__init__.py +1042 -0
- tsugite/cli/agents.py +148 -0
- tsugite/cli/attachments.py +193 -0
- tsugite/cli/benchmark.py +663 -0
- tsugite/cli/cache.py +113 -0
- tsugite/cli/config.py +272 -0
- tsugite/cli/helpers.py +534 -0
- tsugite/cli/history.py +193 -0
- tsugite/cli/init.py +387 -0
- tsugite/cli/mcp.py +193 -0
- tsugite/cli/tools.py +419 -0
- tsugite/config.py +204 -0
- tsugite/console.py +48 -0
- tsugite/constants.py +21 -0
- tsugite/core/__init__.py +19 -0
- tsugite/core/agent.py +774 -0
- tsugite/core/executor.py +300 -0
- tsugite/core/memory.py +67 -0
- tsugite/core/tools.py +271 -0
- tsugite/docker_cli.py +270 -0
- tsugite/events/__init__.py +55 -0
- tsugite/events/base.py +46 -0
- tsugite/events/bus.py +62 -0
- tsugite/events/events.py +224 -0
- tsugite/exceptions.py +40 -0
- tsugite/history/__init__.py +29 -0
- tsugite/history/index.py +210 -0
- tsugite/history/models.py +106 -0
- tsugite/history/storage.py +157 -0
- tsugite/mcp_client.py +219 -0
- tsugite/mcp_config.py +174 -0
- tsugite/md_agents.py +751 -0
- tsugite/models.py +257 -0
- tsugite/renderer.py +151 -0
- tsugite/shell_tool_config.py +265 -0
- tsugite/templates/assistant.md +14 -0
- tsugite/tools/__init__.py +265 -0
- tsugite/tools/agents.py +312 -0
- tsugite/tools/edit_strategies.py +393 -0
- tsugite/tools/fs.py +329 -0
- tsugite/tools/http.py +239 -0
- tsugite/tools/interactive.py +430 -0
- tsugite/tools/shell.py +129 -0
- tsugite/tools/shell_tools.py +214 -0
- tsugite/tools/tasks.py +339 -0
- tsugite/tsugite.py +7 -0
- tsugite/ui/__init__.py +46 -0
- tsugite/ui/base.py +638 -0
- tsugite/ui/chat.py +265 -0
- tsugite/ui/chat.tcss +92 -0
- tsugite/ui/chat_history.py +286 -0
- tsugite/ui/helpers.py +102 -0
- tsugite/ui/jsonl.py +125 -0
- tsugite/ui/live_template.py +529 -0
- tsugite/ui/plain.py +419 -0
- tsugite/ui/textual_chat.py +642 -0
- tsugite/ui/textual_handler.py +225 -0
- tsugite/ui/widgets/__init__.py +6 -0
- tsugite/ui/widgets/base_scroll_log.py +27 -0
- tsugite/ui/widgets/message_list.py +121 -0
- tsugite/ui/widgets/thought_log.py +80 -0
- tsugite/ui_context.py +90 -0
- tsugite/utils.py +367 -0
- tsugite/xdg.py +104 -0
- tsugite_cli-0.3.3.dist-info/METADATA +325 -0
- tsugite_cli-0.3.3.dist-info/RECORD +101 -0
- tsugite_cli-0.3.3.dist-info/WHEEL +4 -0
- tsugite_cli-0.3.3.dist-info/entry_points.txt +5 -0
- 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
|