readyagentsdev 0.8.2__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.
- readyagents/__init__.py +38 -0
- readyagents/__main__.py +4 -0
- readyagents/audit.py +67 -0
- readyagents/cli.py +1050 -0
- readyagents/config.py +264 -0
- readyagents/errors.py +129 -0
- readyagents/llm/__init__.py +11 -0
- readyagents/llm/anthropic_provider.py +72 -0
- readyagents/llm/base.py +57 -0
- readyagents/llm/cache.py +86 -0
- readyagents/llm/openai_compat.py +12 -0
- readyagents/llm/openai_provider.py +70 -0
- readyagents/llm/registry.py +112 -0
- readyagents/llm/resilience.py +179 -0
- readyagents/llm/tool_calls.py +286 -0
- readyagents/logging.py +162 -0
- readyagents/mcp/__init__.py +43 -0
- readyagents/mcp/builtin.py +674 -0
- readyagents/mcp/client.py +253 -0
- readyagents/mcp/http.py +585 -0
- readyagents/mcp/run_api.py +1077 -0
- readyagents/mcp/server.py +246 -0
- readyagents/notify.py +63 -0
- readyagents/packs/__init__.py +26 -0
- readyagents/packs/loader.py +157 -0
- readyagents/packs/protocol.py +55 -0
- readyagents/policy.py +127 -0
- readyagents/py.typed +1 -0
- readyagents/report.py +88 -0
- readyagents/scaffold.py +410 -0
- readyagents/secrets.py +120 -0
- readyagents/testing/__init__.py +17 -0
- readyagents/testing/eval.py +219 -0
- readyagents/testing/helpers.py +128 -0
- readyagents/testing/recorded.py +68 -0
- readyagents/tools/__init__.py +67 -0
- readyagents/workflow/__init__.py +3 -0
- readyagents/workflow/cancellation.py +88 -0
- readyagents/workflow/conditions.py +279 -0
- readyagents/workflow/engine.py +354 -0
- readyagents/workflow/nodes.py +944 -0
- readyagents/workflow/runner.py +375 -0
- readyagents/workflow/schema.py +287 -0
- readyagents/workflow/state.py +472 -0
- readyagents/workflow/structured.py +103 -0
- readyagents/workflow/templates.py +125 -0
- readyagentsdev-0.8.2.dist-info/METADATA +215 -0
- readyagentsdev-0.8.2.dist-info/RECORD +51 -0
- readyagentsdev-0.8.2.dist-info/WHEEL +4 -0
- readyagentsdev-0.8.2.dist-info/entry_points.txt +2 -0
- readyagentsdev-0.8.2.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,674 @@
|
|
|
1
|
+
"""Python-native tools that work with zero extra servers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
import http.client
|
|
7
|
+
import ipaddress
|
|
8
|
+
import json
|
|
9
|
+
import operator
|
|
10
|
+
import os
|
|
11
|
+
import socket
|
|
12
|
+
import ssl
|
|
13
|
+
from datetime import UTC, datetime
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
from urllib.parse import ParseResult, urljoin, urlparse
|
|
17
|
+
from uuid import uuid4
|
|
18
|
+
|
|
19
|
+
from readyagents import __version__
|
|
20
|
+
from readyagents.errors import ToolError
|
|
21
|
+
from readyagents.tools import FunctionTool, Tool
|
|
22
|
+
|
|
23
|
+
_BINOPS: dict[type, Any] = {
|
|
24
|
+
ast.Add: operator.add,
|
|
25
|
+
ast.Sub: operator.sub,
|
|
26
|
+
ast.Mult: operator.mul,
|
|
27
|
+
ast.Div: operator.truediv,
|
|
28
|
+
ast.FloorDiv: operator.floordiv,
|
|
29
|
+
ast.Mod: operator.mod,
|
|
30
|
+
ast.Pow: operator.pow,
|
|
31
|
+
}
|
|
32
|
+
_UNARY: dict[type, Any] = {
|
|
33
|
+
ast.UAdd: operator.pos,
|
|
34
|
+
ast.USub: operator.neg,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
_HTTP_TIMEOUT = 20
|
|
38
|
+
_MAX_HTTP_BYTES = 1_000_000
|
|
39
|
+
_MAX_HTTP_REDIRECTS = 5
|
|
40
|
+
_MAX_POW_EXP = 32
|
|
41
|
+
_MAX_FILE_BYTES = 1_000_000
|
|
42
|
+
_MAX_LIST_DIR_ENTRIES = 500
|
|
43
|
+
_MAX_JSON_BYTES = 1_000_000
|
|
44
|
+
_MAX_CALC_CHARS = 256
|
|
45
|
+
_JSON_REFUSED_SEGMENTS = frozenset({"constructor", "prototype"})
|
|
46
|
+
_BLOCKED_HOST_NAMES = frozenset(
|
|
47
|
+
{
|
|
48
|
+
"localhost",
|
|
49
|
+
"localhost.localdomain",
|
|
50
|
+
"ip6-localhost",
|
|
51
|
+
"ip6-loopback",
|
|
52
|
+
"metadata.google.internal",
|
|
53
|
+
}
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def builtin_tools(*, allow_http: bool, workspace: Path) -> list[Tool]:
|
|
58
|
+
workspace = Path(workspace).resolve()
|
|
59
|
+
tools: list[Tool] = [
|
|
60
|
+
FunctionTool(
|
|
61
|
+
name="now",
|
|
62
|
+
description="Current UTC time as ISO-8601.",
|
|
63
|
+
schema={"type": "object", "properties": {}},
|
|
64
|
+
handler=tool_now,
|
|
65
|
+
),
|
|
66
|
+
FunctionTool(
|
|
67
|
+
name="calc",
|
|
68
|
+
description="Evaluate a safe arithmetic expression (e.g. '2 + 2 * 10').",
|
|
69
|
+
schema={
|
|
70
|
+
"type": "object",
|
|
71
|
+
"properties": {"expression": {"type": "string"}},
|
|
72
|
+
"required": ["expression"],
|
|
73
|
+
},
|
|
74
|
+
handler=tool_calc,
|
|
75
|
+
),
|
|
76
|
+
FunctionTool(
|
|
77
|
+
name="json_get",
|
|
78
|
+
description="Extract a dotted path from JSON text or an object.",
|
|
79
|
+
schema={
|
|
80
|
+
"type": "object",
|
|
81
|
+
"properties": {
|
|
82
|
+
"data": {},
|
|
83
|
+
"path": {"type": "string"},
|
|
84
|
+
},
|
|
85
|
+
"required": ["data", "path"],
|
|
86
|
+
},
|
|
87
|
+
handler=tool_json_get,
|
|
88
|
+
),
|
|
89
|
+
FunctionTool(
|
|
90
|
+
name="json_set",
|
|
91
|
+
description="Set a dotted path in JSON text or an object and return the document.",
|
|
92
|
+
schema={
|
|
93
|
+
"type": "object",
|
|
94
|
+
"properties": {
|
|
95
|
+
"data": {},
|
|
96
|
+
"path": {"type": "string"},
|
|
97
|
+
"value": {},
|
|
98
|
+
},
|
|
99
|
+
"required": ["data", "path", "value"],
|
|
100
|
+
},
|
|
101
|
+
handler=tool_json_set,
|
|
102
|
+
),
|
|
103
|
+
FunctionTool(
|
|
104
|
+
name="json_merge",
|
|
105
|
+
description="Merge a JSON object at a dotted path (empty path merges at the root).",
|
|
106
|
+
schema={
|
|
107
|
+
"type": "object",
|
|
108
|
+
"properties": {
|
|
109
|
+
"data": {},
|
|
110
|
+
"path": {"type": "string"},
|
|
111
|
+
"value": {},
|
|
112
|
+
},
|
|
113
|
+
"required": ["data", "path", "value"],
|
|
114
|
+
},
|
|
115
|
+
handler=tool_json_merge,
|
|
116
|
+
),
|
|
117
|
+
FunctionTool(
|
|
118
|
+
name="list_dir",
|
|
119
|
+
description="List a directory sandboxed to the workflow workspace (dotfiles skipped).",
|
|
120
|
+
schema={
|
|
121
|
+
"type": "object",
|
|
122
|
+
"properties": {
|
|
123
|
+
"path": {"type": "string"},
|
|
124
|
+
"include_hidden": {"type": "boolean"},
|
|
125
|
+
"max_entries": {"type": "integer"},
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
handler=lambda path=".", include_hidden=False, max_entries=200: tool_list_dir(
|
|
129
|
+
path,
|
|
130
|
+
workspace=workspace,
|
|
131
|
+
include_hidden=include_hidden,
|
|
132
|
+
max_entries=max_entries,
|
|
133
|
+
),
|
|
134
|
+
),
|
|
135
|
+
FunctionTool(
|
|
136
|
+
name="read_file",
|
|
137
|
+
description="Read a UTF-8 text file sandboxed to the workflow workspace.",
|
|
138
|
+
schema={
|
|
139
|
+
"type": "object",
|
|
140
|
+
"properties": {"path": {"type": "string"}},
|
|
141
|
+
"required": ["path"],
|
|
142
|
+
},
|
|
143
|
+
handler=lambda path: tool_read_file(path, workspace=workspace),
|
|
144
|
+
),
|
|
145
|
+
FunctionTool(
|
|
146
|
+
name="write_file",
|
|
147
|
+
description="Write a UTF-8 text file sandboxed to the workflow workspace.",
|
|
148
|
+
schema={
|
|
149
|
+
"type": "object",
|
|
150
|
+
"properties": {
|
|
151
|
+
"path": {"type": "string"},
|
|
152
|
+
"content": {"type": "string"},
|
|
153
|
+
},
|
|
154
|
+
"required": ["path", "content"],
|
|
155
|
+
},
|
|
156
|
+
handler=lambda path, content: tool_write_file(path, content, workspace=workspace),
|
|
157
|
+
),
|
|
158
|
+
FunctionTool(
|
|
159
|
+
name="http_get",
|
|
160
|
+
description="HTTP GET a URL. Disabled unless allow_http is enabled.",
|
|
161
|
+
schema={
|
|
162
|
+
"type": "object",
|
|
163
|
+
"properties": {"url": {"type": "string"}},
|
|
164
|
+
"required": ["url"],
|
|
165
|
+
},
|
|
166
|
+
handler=lambda url: tool_http_get(url, allow_http=allow_http),
|
|
167
|
+
),
|
|
168
|
+
]
|
|
169
|
+
return tools
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def tool_now() -> str:
|
|
173
|
+
return datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def tool_calc(expression: str | int | float) -> int | float:
|
|
177
|
+
if isinstance(expression, (int, float)):
|
|
178
|
+
return expression
|
|
179
|
+
text = str(expression).strip()
|
|
180
|
+
if not text:
|
|
181
|
+
raise ToolError("calc: empty expression")
|
|
182
|
+
if len(text) > _MAX_CALC_CHARS:
|
|
183
|
+
raise ToolError(f"calc: expression too long (max {_MAX_CALC_CHARS} characters)")
|
|
184
|
+
try:
|
|
185
|
+
tree = ast.parse(text, mode="eval")
|
|
186
|
+
except SyntaxError as exc:
|
|
187
|
+
raise ToolError(f"calc: invalid expression: {exc}") from exc
|
|
188
|
+
return _eval_ast(tree.body)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _eval_ast(node: ast.AST) -> int | float:
|
|
192
|
+
if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
|
|
193
|
+
if isinstance(node.value, bool):
|
|
194
|
+
raise ToolError("calc: only numbers and + - * / // % ** are allowed")
|
|
195
|
+
return node.value
|
|
196
|
+
if isinstance(node, ast.UnaryOp) and type(node.op) in _UNARY:
|
|
197
|
+
return _UNARY[type(node.op)](_eval_ast(node.operand))
|
|
198
|
+
if isinstance(node, ast.BinOp) and type(node.op) in _BINOPS:
|
|
199
|
+
left = _eval_ast(node.left)
|
|
200
|
+
right = _eval_ast(node.right)
|
|
201
|
+
if isinstance(node.op, ast.Pow) and abs(right) > _MAX_POW_EXP:
|
|
202
|
+
raise ToolError("calc: exponent too large")
|
|
203
|
+
try:
|
|
204
|
+
return _BINOPS[type(node.op)](left, right)
|
|
205
|
+
except ZeroDivisionError as exc:
|
|
206
|
+
raise ToolError("calc: division by zero") from exc
|
|
207
|
+
if isinstance(node, ast.Expr):
|
|
208
|
+
return _eval_ast(node.value)
|
|
209
|
+
raise ToolError("calc: only numbers and + - * / // % ** are allowed")
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def tool_json_get(data: Any, path: str) -> Any:
|
|
213
|
+
current: Any = _parse_json_input(data, tool="json_get")
|
|
214
|
+
for part in str(path).split("."):
|
|
215
|
+
if part == "":
|
|
216
|
+
continue
|
|
217
|
+
if isinstance(current, dict):
|
|
218
|
+
if part not in current:
|
|
219
|
+
raise ToolError(f"json_get: path not found: {path}")
|
|
220
|
+
current = current[part]
|
|
221
|
+
continue
|
|
222
|
+
if isinstance(current, list):
|
|
223
|
+
try:
|
|
224
|
+
current = current[int(part)]
|
|
225
|
+
except (ValueError, IndexError) as exc:
|
|
226
|
+
raise ToolError(f"json_get: path not found: {path}") from exc
|
|
227
|
+
continue
|
|
228
|
+
raise ToolError(f"json_get: path not found: {path}")
|
|
229
|
+
return current
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def tool_json_set(data: Any, path: str, value: Any) -> Any:
|
|
233
|
+
doc = _json_clone(_parse_json_input(data, tool="json_set"), tool="json_set")
|
|
234
|
+
parsed = _json_clone(_parse_json_value(value, tool="json_set"), tool="json_set")
|
|
235
|
+
parts = _json_path_parts(path, tool="json_set")
|
|
236
|
+
result = _json_assign(doc, parts, parsed, tool="json_set")
|
|
237
|
+
_assert_json_size(result, tool="json_set")
|
|
238
|
+
return result
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def tool_json_merge(data: Any, path: str, value: Any) -> Any:
|
|
242
|
+
doc = _json_clone(_parse_json_input(data, tool="json_merge"), tool="json_merge")
|
|
243
|
+
parsed = _json_clone(_parse_json_value(value, tool="json_merge"), tool="json_merge")
|
|
244
|
+
if not isinstance(parsed, dict):
|
|
245
|
+
raise ToolError("json_merge: value must be an object")
|
|
246
|
+
parts = _json_path_parts(path, tool="json_merge", allow_root=True)
|
|
247
|
+
result = _json_merge_at(doc, parts, parsed, tool="json_merge")
|
|
248
|
+
_assert_json_size(result, tool="json_merge")
|
|
249
|
+
return result
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _parse_json_input(data: Any, *, tool: str) -> Any:
|
|
253
|
+
current: Any = data
|
|
254
|
+
if isinstance(current, (bytes, bytearray)):
|
|
255
|
+
if len(current) > _MAX_JSON_BYTES:
|
|
256
|
+
raise ToolError(f"{tool}: data too large (max {_MAX_JSON_BYTES} bytes)")
|
|
257
|
+
current = current.decode("utf-8")
|
|
258
|
+
if isinstance(current, str):
|
|
259
|
+
if len(current.encode("utf-8")) > _MAX_JSON_BYTES:
|
|
260
|
+
raise ToolError(f"{tool}: data too large (max {_MAX_JSON_BYTES} bytes)")
|
|
261
|
+
text = current.strip()
|
|
262
|
+
if text:
|
|
263
|
+
try:
|
|
264
|
+
current = json.loads(text)
|
|
265
|
+
except json.JSONDecodeError as exc:
|
|
266
|
+
raise ToolError(f"{tool}: data is not valid JSON: {exc}") from exc
|
|
267
|
+
return current
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _parse_json_value(value: Any, *, tool: str) -> Any:
|
|
271
|
+
current: Any = value
|
|
272
|
+
if isinstance(current, (bytes, bytearray)):
|
|
273
|
+
if len(current) > _MAX_JSON_BYTES:
|
|
274
|
+
raise ToolError(f"{tool}: value too large (max {_MAX_JSON_BYTES} bytes)")
|
|
275
|
+
current = current.decode("utf-8")
|
|
276
|
+
if isinstance(current, str):
|
|
277
|
+
if len(current.encode("utf-8")) > _MAX_JSON_BYTES:
|
|
278
|
+
raise ToolError(f"{tool}: value too large (max {_MAX_JSON_BYTES} bytes)")
|
|
279
|
+
text = current.strip()
|
|
280
|
+
if text:
|
|
281
|
+
try:
|
|
282
|
+
return json.loads(text)
|
|
283
|
+
except json.JSONDecodeError:
|
|
284
|
+
return current
|
|
285
|
+
return current
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _json_clone(data: Any, *, tool: str) -> Any:
|
|
289
|
+
try:
|
|
290
|
+
return json.loads(json.dumps(data, ensure_ascii=False))
|
|
291
|
+
except (TypeError, ValueError, json.JSONDecodeError) as exc:
|
|
292
|
+
raise ToolError(f"{tool}: data is not valid JSON: {exc}") from exc
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _assert_json_size(data: Any, *, tool: str) -> None:
|
|
296
|
+
try:
|
|
297
|
+
blob = json.dumps(data, ensure_ascii=False)
|
|
298
|
+
except (TypeError, ValueError) as exc:
|
|
299
|
+
raise ToolError(f"{tool}: result is not valid JSON: {exc}") from exc
|
|
300
|
+
if len(blob.encode("utf-8")) > _MAX_JSON_BYTES:
|
|
301
|
+
raise ToolError(f"{tool}: result too large (max {_MAX_JSON_BYTES} bytes)")
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _json_path_parts(path: str, *, tool: str, allow_root: bool = False) -> list[str]:
|
|
305
|
+
text = str(path)
|
|
306
|
+
if allow_root and text in {"", "."}:
|
|
307
|
+
return []
|
|
308
|
+
parts = text.split(".")
|
|
309
|
+
for part in parts:
|
|
310
|
+
if part == "":
|
|
311
|
+
raise ToolError(f"{tool}: empty path segment")
|
|
312
|
+
if part.startswith("__") or part in _JSON_REFUSED_SEGMENTS:
|
|
313
|
+
raise ToolError(f"{tool}: refused path segment: {part}")
|
|
314
|
+
return parts
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _json_assign(doc: Any, parts: list[str], value: Any, *, tool: str) -> Any:
|
|
318
|
+
if not parts:
|
|
319
|
+
raise ToolError(f"{tool}: path is required")
|
|
320
|
+
if not isinstance(doc, (dict, list)):
|
|
321
|
+
raise ToolError(f"{tool}: data must be an object or array")
|
|
322
|
+
parent = _json_walk_parent(doc, parts[:-1], tool=tool)
|
|
323
|
+
_json_put(parent, parts[-1], value, tool=tool)
|
|
324
|
+
return doc
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _json_merge_at(doc: Any, parts: list[str], value: dict[str, Any], *, tool: str) -> Any:
|
|
328
|
+
if not parts:
|
|
329
|
+
if not isinstance(doc, dict):
|
|
330
|
+
raise ToolError(f"{tool}: target is not an object")
|
|
331
|
+
doc.update(value)
|
|
332
|
+
return doc
|
|
333
|
+
if not isinstance(doc, (dict, list)):
|
|
334
|
+
raise ToolError(f"{tool}: data must be an object or array")
|
|
335
|
+
parent = _json_walk_parent(doc, parts[:-1], tool=tool)
|
|
336
|
+
target = _json_child_object(parent, parts[-1], tool=tool)
|
|
337
|
+
target.update(value)
|
|
338
|
+
return doc
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _json_walk_parent(doc: Any, parts: list[str], *, tool: str) -> Any:
|
|
342
|
+
current = doc
|
|
343
|
+
for part in parts:
|
|
344
|
+
current = _json_ensure_container(current, part, tool=tool)
|
|
345
|
+
return current
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _json_ensure_container(current: Any, part: str, *, tool: str) -> Any:
|
|
349
|
+
if isinstance(current, dict):
|
|
350
|
+
child = current.get(part, None)
|
|
351
|
+
if child is None:
|
|
352
|
+
current[part] = {}
|
|
353
|
+
return current[part]
|
|
354
|
+
if not isinstance(child, (dict, list)):
|
|
355
|
+
raise ToolError(f"{tool}: cannot descend into non-container at {part}")
|
|
356
|
+
return child
|
|
357
|
+
if isinstance(current, list):
|
|
358
|
+
idx = _json_list_index(current, part, tool=tool)
|
|
359
|
+
child = current[idx]
|
|
360
|
+
if child is None:
|
|
361
|
+
current[idx] = {}
|
|
362
|
+
return current[idx]
|
|
363
|
+
if not isinstance(child, (dict, list)):
|
|
364
|
+
raise ToolError(f"{tool}: cannot descend into non-container at {part}")
|
|
365
|
+
return child
|
|
366
|
+
raise ToolError(f"{tool}: cannot descend into non-container at {part}")
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _json_put(parent: Any, part: str, value: Any, *, tool: str) -> None:
|
|
370
|
+
if isinstance(parent, dict):
|
|
371
|
+
parent[part] = value
|
|
372
|
+
return
|
|
373
|
+
if isinstance(parent, list):
|
|
374
|
+
parent[_json_list_index(parent, part, tool=tool)] = value
|
|
375
|
+
return
|
|
376
|
+
raise ToolError(f"{tool}: cannot set path")
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _json_child_object(parent: Any, part: str, *, tool: str) -> dict[str, Any]:
|
|
380
|
+
if isinstance(parent, dict):
|
|
381
|
+
child = parent.get(part, None)
|
|
382
|
+
if child is None:
|
|
383
|
+
parent[part] = {}
|
|
384
|
+
return parent[part]
|
|
385
|
+
if not isinstance(child, dict):
|
|
386
|
+
raise ToolError(f"{tool}: target is not an object")
|
|
387
|
+
return child
|
|
388
|
+
if isinstance(parent, list):
|
|
389
|
+
idx = _json_list_index(parent, part, tool=tool)
|
|
390
|
+
child = parent[idx]
|
|
391
|
+
if child is None:
|
|
392
|
+
parent[idx] = {}
|
|
393
|
+
return parent[idx]
|
|
394
|
+
if not isinstance(child, dict):
|
|
395
|
+
raise ToolError(f"{tool}: target is not an object")
|
|
396
|
+
return child
|
|
397
|
+
raise ToolError(f"{tool}: target is not an object")
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _json_list_index(seq: list[Any], part: str, *, tool: str) -> int:
|
|
401
|
+
try:
|
|
402
|
+
idx = int(part)
|
|
403
|
+
seq[idx]
|
|
404
|
+
except (ValueError, IndexError) as exc:
|
|
405
|
+
raise ToolError(f"{tool}: path not found: {part}") from exc
|
|
406
|
+
return idx
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def tool_http_get(url: str, *, allow_http: bool) -> str:
|
|
410
|
+
if not allow_http:
|
|
411
|
+
raise ToolError(
|
|
412
|
+
"http_get is disabled. Set READYAGENTS_ALLOW_HTTP=1 or set allow_http: true "
|
|
413
|
+
"on the workflow if you intend to fetch URLs."
|
|
414
|
+
)
|
|
415
|
+
current = url.strip() if isinstance(url, str) else ""
|
|
416
|
+
for _ in range(_MAX_HTTP_REDIRECTS + 1):
|
|
417
|
+
parsed = _assert_public_http_url(current)
|
|
418
|
+
assert parsed.hostname is not None
|
|
419
|
+
ips = _resolve_public_ips(parsed.hostname)
|
|
420
|
+
port = parsed.port or (443 if parsed.scheme == "https" else 80)
|
|
421
|
+
path = parsed.path or "/"
|
|
422
|
+
if parsed.query:
|
|
423
|
+
path = f"{path}?{parsed.query}"
|
|
424
|
+
last_err: Exception | None = None
|
|
425
|
+
status = 0
|
|
426
|
+
body = b""
|
|
427
|
+
location: str | None = None
|
|
428
|
+
for ip in ips:
|
|
429
|
+
try:
|
|
430
|
+
status, body, location = _http_exchange(
|
|
431
|
+
parsed.scheme, parsed.hostname, ip, port, path
|
|
432
|
+
)
|
|
433
|
+
last_err = None
|
|
434
|
+
break
|
|
435
|
+
except (TimeoutError, OSError) as exc:
|
|
436
|
+
last_err = exc
|
|
437
|
+
if last_err is not None:
|
|
438
|
+
raise ToolError(f"http_get failed: {last_err}") from last_err
|
|
439
|
+
if status in {301, 302, 303, 307, 308} and location:
|
|
440
|
+
current = urljoin(current, location)
|
|
441
|
+
continue
|
|
442
|
+
if status >= 400:
|
|
443
|
+
raise ToolError(f"http_get failed: HTTP Error {status}:")
|
|
444
|
+
if len(body) > _MAX_HTTP_BYTES:
|
|
445
|
+
raise ToolError("http_get: response too large")
|
|
446
|
+
return body.decode("utf-8", errors="replace")
|
|
447
|
+
raise ToolError("http_get: too many redirects")
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _http_exchange(
|
|
451
|
+
scheme: str,
|
|
452
|
+
hostname: str,
|
|
453
|
+
ip: str,
|
|
454
|
+
port: int,
|
|
455
|
+
path: str,
|
|
456
|
+
*,
|
|
457
|
+
method: str = "GET",
|
|
458
|
+
body: bytes | None = None,
|
|
459
|
+
headers: dict[str, str] | None = None,
|
|
460
|
+
timeout: float | None = None,
|
|
461
|
+
) -> tuple[int, bytes, str | None]:
|
|
462
|
+
timeout = _HTTP_TIMEOUT if timeout is None else timeout
|
|
463
|
+
req_headers = {"User-Agent": f"readyagents/{__version__}"}
|
|
464
|
+
if headers:
|
|
465
|
+
req_headers.update(headers)
|
|
466
|
+
if scheme == "https":
|
|
467
|
+
ctx = ssl.create_default_context()
|
|
468
|
+
conn: http.client.HTTPConnection = http.client.HTTPSConnection(
|
|
469
|
+
hostname, port, timeout=timeout, context=ctx
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
def connect() -> None:
|
|
473
|
+
sock = socket.create_connection((ip, port), timeout)
|
|
474
|
+
conn.sock = ctx.wrap_socket(sock, server_hostname=hostname)
|
|
475
|
+
|
|
476
|
+
conn.connect = connect # type: ignore[method-assign]
|
|
477
|
+
else:
|
|
478
|
+
conn = http.client.HTTPConnection(hostname, port, timeout=timeout)
|
|
479
|
+
|
|
480
|
+
def connect() -> None:
|
|
481
|
+
conn.sock = socket.create_connection((ip, port), timeout)
|
|
482
|
+
|
|
483
|
+
conn.connect = connect # type: ignore[method-assign]
|
|
484
|
+
try:
|
|
485
|
+
if body is None:
|
|
486
|
+
conn.request(method, path, headers=req_headers)
|
|
487
|
+
else:
|
|
488
|
+
conn.request(method, path, body=body, headers=req_headers)
|
|
489
|
+
resp = conn.getresponse()
|
|
490
|
+
payload = resp.read(_MAX_HTTP_BYTES + 1)
|
|
491
|
+
return resp.status, payload, resp.getheader("Location")
|
|
492
|
+
finally:
|
|
493
|
+
conn.close()
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def _assert_public_http_url(url: object, *, kind: str = "http_get") -> ParseResult:
|
|
497
|
+
if not isinstance(url, str) or not url.strip():
|
|
498
|
+
raise ToolError(f"{kind}: url must start with http:// or https://")
|
|
499
|
+
parsed = urlparse(url.strip())
|
|
500
|
+
if parsed.scheme not in {"http", "https"}:
|
|
501
|
+
raise ToolError(f"{kind}: url must start with http:// or https://")
|
|
502
|
+
if parsed.username is not None or parsed.password is not None:
|
|
503
|
+
raise ToolError(f"{kind}: URLs with userinfo are not allowed")
|
|
504
|
+
if not parsed.hostname:
|
|
505
|
+
raise ToolError(f"{kind}: URL must include a host")
|
|
506
|
+
return parsed
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def _resolve_public_ips(host: str, *, kind: str = "http_get") -> list[str]:
|
|
510
|
+
name = host.strip().lower().rstrip(".")
|
|
511
|
+
not_allowed = (
|
|
512
|
+
f"{kind}: host '{host}' is not allowed "
|
|
513
|
+
"(loopback, private, link-local, or metadata addresses)"
|
|
514
|
+
)
|
|
515
|
+
if name in _BLOCKED_HOST_NAMES or name.endswith(".localhost") or name.endswith(".local"):
|
|
516
|
+
raise ToolError(not_allowed)
|
|
517
|
+
try:
|
|
518
|
+
ip = ipaddress.ip_address(name)
|
|
519
|
+
except ValueError:
|
|
520
|
+
ip = None
|
|
521
|
+
if ip is not None:
|
|
522
|
+
if _ip_is_blocked(ip):
|
|
523
|
+
raise ToolError(not_allowed)
|
|
524
|
+
return [str(ip)]
|
|
525
|
+
try:
|
|
526
|
+
infos = socket.getaddrinfo(name, None, type=socket.SOCK_STREAM)
|
|
527
|
+
except socket.gaierror as exc:
|
|
528
|
+
raise ToolError(f"{kind}: could not resolve host '{host}'") from exc
|
|
529
|
+
if not infos:
|
|
530
|
+
raise ToolError(f"{kind}: could not resolve host '{host}'")
|
|
531
|
+
ips: list[str] = []
|
|
532
|
+
seen: set[str] = set()
|
|
533
|
+
for info in infos:
|
|
534
|
+
addr = info[4][0]
|
|
535
|
+
try:
|
|
536
|
+
parsed_ip = ipaddress.ip_address(addr)
|
|
537
|
+
except ValueError:
|
|
538
|
+
continue
|
|
539
|
+
if _ip_is_blocked(parsed_ip):
|
|
540
|
+
raise ToolError(not_allowed)
|
|
541
|
+
text = str(parsed_ip)
|
|
542
|
+
if text not in seen:
|
|
543
|
+
seen.add(text)
|
|
544
|
+
ips.append(text)
|
|
545
|
+
if not ips:
|
|
546
|
+
raise ToolError(f"{kind}: could not resolve host '{host}'")
|
|
547
|
+
return ips
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def _ip_is_blocked(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
|
|
551
|
+
if ip.version == 6 and ip.ipv4_mapped is not None:
|
|
552
|
+
ip = ip.ipv4_mapped
|
|
553
|
+
return bool(
|
|
554
|
+
ip.is_private
|
|
555
|
+
or ip.is_loopback
|
|
556
|
+
or ip.is_link_local
|
|
557
|
+
or ip.is_multicast
|
|
558
|
+
or ip.is_reserved
|
|
559
|
+
or ip.is_unspecified
|
|
560
|
+
or not ip.is_global
|
|
561
|
+
)
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def tool_list_dir(
|
|
565
|
+
path: str = ".",
|
|
566
|
+
*,
|
|
567
|
+
workspace: Path,
|
|
568
|
+
include_hidden: bool = False,
|
|
569
|
+
max_entries: int = 200,
|
|
570
|
+
) -> list[dict[str, Any]]:
|
|
571
|
+
"""List a workspace directory. Dotfiles skipped unless include_hidden."""
|
|
572
|
+
target = _sandbox_dir(path, workspace)
|
|
573
|
+
try:
|
|
574
|
+
cap = int(max_entries)
|
|
575
|
+
except (TypeError, ValueError) as extra:
|
|
576
|
+
raise ToolError(f"list_dir: max_entries must be an integer: {extra}") from extra
|
|
577
|
+
if cap < 1:
|
|
578
|
+
raise ToolError("list_dir: max_entries must be >= 1")
|
|
579
|
+
cap = min(cap, _MAX_LIST_DIR_ENTRIES)
|
|
580
|
+
hidden = bool(include_hidden)
|
|
581
|
+
rows: list[dict[str, Any]] = []
|
|
582
|
+
try:
|
|
583
|
+
names = sorted(target.iterdir(), key=lambda p: p.name.lower())
|
|
584
|
+
except OSError as extra:
|
|
585
|
+
raise ToolError(f"list_dir: could not list {path}: {extra}") from extra
|
|
586
|
+
for child in names:
|
|
587
|
+
if not hidden and child.name.startswith("."):
|
|
588
|
+
continue
|
|
589
|
+
try:
|
|
590
|
+
resolved_child = child.resolve()
|
|
591
|
+
if not resolved_child.is_relative_to(workspace):
|
|
592
|
+
continue
|
|
593
|
+
is_dir = resolved_child.is_dir()
|
|
594
|
+
is_file = resolved_child.is_file()
|
|
595
|
+
size = resolved_child.stat().st_size if is_file else 0
|
|
596
|
+
except OSError:
|
|
597
|
+
continue
|
|
598
|
+
kind = "dir" if is_dir else "file" if is_file else "other"
|
|
599
|
+
rows.append({"name": child.name, "type": kind, "size": int(size)})
|
|
600
|
+
if len(rows) >= cap:
|
|
601
|
+
break
|
|
602
|
+
return rows
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def _sandbox_dir(path: str, workspace: Path) -> Path:
|
|
606
|
+
"""Resolve a directory path inside workspace (workspace root is allowed)."""
|
|
607
|
+
workspace = Path(workspace).resolve()
|
|
608
|
+
text = str(path).strip() or "."
|
|
609
|
+
if "\x00" in text:
|
|
610
|
+
raise ToolError("Path contains invalid characters")
|
|
611
|
+
if text == "..":
|
|
612
|
+
raise ToolError("Path must stay inside the workspace")
|
|
613
|
+
candidate = Path(text)
|
|
614
|
+
if not candidate.is_absolute():
|
|
615
|
+
candidate = workspace / candidate
|
|
616
|
+
resolved = candidate.resolve()
|
|
617
|
+
if not resolved.is_relative_to(workspace):
|
|
618
|
+
raise ToolError(f"Path '{path}' is outside the workspace")
|
|
619
|
+
if not resolved.is_dir():
|
|
620
|
+
raise ToolError(f"list_dir: not a directory: {path}")
|
|
621
|
+
return resolved
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def tool_read_file(path: str, *, workspace: Path) -> str:
|
|
625
|
+
target = _sandbox_path(path, workspace)
|
|
626
|
+
if not target.is_file():
|
|
627
|
+
raise ToolError(f"read_file: not a file: {path}")
|
|
628
|
+
try:
|
|
629
|
+
size = target.stat().st_size
|
|
630
|
+
if size > _MAX_FILE_BYTES:
|
|
631
|
+
raise ToolError(f"read_file: file too large ({size} bytes, max {_MAX_FILE_BYTES})")
|
|
632
|
+
return target.read_text(encoding="utf-8")
|
|
633
|
+
except ToolError:
|
|
634
|
+
raise
|
|
635
|
+
except (OSError, UnicodeError) as exc:
|
|
636
|
+
raise ToolError(f"read_file: could not read {path}: {exc}") from exc
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def tool_write_file(path: str, content: str, *, workspace: Path) -> str:
|
|
640
|
+
target = _sandbox_path(path, workspace)
|
|
641
|
+
if target.exists() and not target.is_file():
|
|
642
|
+
raise ToolError(f"write_file: not a file: {path}")
|
|
643
|
+
payload = str(content)
|
|
644
|
+
size = len(payload.encode("utf-8"))
|
|
645
|
+
if size > _MAX_FILE_BYTES:
|
|
646
|
+
raise ToolError(f"write_file: content too large ({size} bytes, max {_MAX_FILE_BYTES})")
|
|
647
|
+
try:
|
|
648
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
649
|
+
tmp = target.parent / f".{target.name}.{uuid4().hex}.tmp"
|
|
650
|
+
try:
|
|
651
|
+
tmp.write_text(payload, encoding="utf-8")
|
|
652
|
+
os.replace(tmp, target)
|
|
653
|
+
finally:
|
|
654
|
+
if tmp.exists():
|
|
655
|
+
tmp.unlink(missing_ok=True)
|
|
656
|
+
except OSError as exc:
|
|
657
|
+
raise ToolError(f"write_file: could not write {path}: {exc}") from exc
|
|
658
|
+
return str(target)
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def _sandbox_path(path: str, workspace: Path) -> Path:
|
|
662
|
+
workspace = Path(workspace).resolve()
|
|
663
|
+
text = str(path).strip()
|
|
664
|
+
if not text or text in {".", ".."}:
|
|
665
|
+
raise ToolError("Path must be a file inside the workspace")
|
|
666
|
+
if "\x00" in text:
|
|
667
|
+
raise ToolError("Path contains invalid characters")
|
|
668
|
+
candidate = Path(text)
|
|
669
|
+
if not candidate.is_absolute():
|
|
670
|
+
candidate = workspace / candidate
|
|
671
|
+
resolved = candidate.resolve()
|
|
672
|
+
if not resolved.is_relative_to(workspace) or resolved == workspace:
|
|
673
|
+
raise ToolError(f"Path '{path}' is outside the workspace")
|
|
674
|
+
return resolved
|