minimalcode 1.5.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.
- minimalcode/__init__.py +3 -0
- minimalcode/__main__.py +3 -0
- minimalcode/agent.py +1084 -0
- minimalcode/cli.py +1520 -0
- minimalcode/clipboard.py +212 -0
- minimalcode/config.py +1065 -0
- minimalcode/defaults.json +491 -0
- minimalcode/errors.py +31 -0
- minimalcode/highlight/__init__.py +47 -0
- minimalcode/highlight/code.py +261 -0
- minimalcode/highlight/color.py +58 -0
- minimalcode/highlight/markdown.py +710 -0
- minimalcode/highlight/palette.py +73 -0
- minimalcode/highlight/sanitize.py +175 -0
- minimalcode/highlight/thinking.py +71 -0
- minimalcode/httpclient.py +278 -0
- minimalcode/importer.py +253 -0
- minimalcode/prompt.py +195 -0
- minimalcode/provider/__init__.py +28 -0
- minimalcode/provider/anthropic.py +805 -0
- minimalcode/provider/anthropic_env.py +161 -0
- minimalcode/provider/chat.py +394 -0
- minimalcode/provider/client.py +243 -0
- minimalcode/provider/common.py +176 -0
- minimalcode/provider/deepseek_web.py +1158 -0
- minimalcode/provider/factory.py +63 -0
- minimalcode/provider/responses.py +692 -0
- minimalcode/provider/sha3_wasm_bg.wasm +0 -0
- minimalcode/provider/transport.py +241 -0
- minimalcode/provider/types.py +54 -0
- minimalcode/sandbox/__init__.py +64 -0
- minimalcode/sandbox/audit.py +120 -0
- minimalcode/sandbox/backends.py +343 -0
- minimalcode/sandbox/execution.py +435 -0
- minimalcode/sandbox/network.py +793 -0
- minimalcode/sandbox/policy.py +253 -0
- minimalcode/sandbox/runner.py +347 -0
- minimalcode/sandbox/sensitive.py +286 -0
- minimalcode/session.py +465 -0
- minimalcode/shell.py +333 -0
- minimalcode/subagent.py +120 -0
- minimalcode/tools/__init__.py +46 -0
- minimalcode/tools/files.py +1139 -0
- minimalcode/tools/process.py +164 -0
- minimalcode/tools/remote.py +74 -0
- minimalcode/tools/runtime.py +219 -0
- minimalcode/tools/schemas.py +418 -0
- minimalcode/tools/search.py +521 -0
- minimalcode/tools/search_tools.py +583 -0
- minimalcode/tools/validation.py +99 -0
- minimalcode/vision.py +231 -0
- minimalcode/web.py +238 -0
- minimalcode-1.5.2.dist-info/METADATA +646 -0
- minimalcode-1.5.2.dist-info/RECORD +58 -0
- minimalcode-1.5.2.dist-info/WHEEL +5 -0
- minimalcode-1.5.2.dist-info/entry_points.txt +2 -0
- minimalcode-1.5.2.dist-info/licenses/LICENSE +201 -0
- minimalcode-1.5.2.dist-info/top_level.txt +1 -0
minimalcode/vision.py
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import math
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any
|
|
7
|
+
from urllib.parse import urlsplit
|
|
8
|
+
|
|
9
|
+
import httpx2
|
|
10
|
+
|
|
11
|
+
from .config import provider_api_key
|
|
12
|
+
from .errors import ToolError
|
|
13
|
+
from .httpclient import (
|
|
14
|
+
build_client,
|
|
15
|
+
build_headers,
|
|
16
|
+
join_url_path,
|
|
17
|
+
merge_headers,
|
|
18
|
+
post_json,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
# The provider judges formats by content, not by file name, so the tool does the
|
|
22
|
+
# same and labels the data URL with the detected type.
|
|
23
|
+
SUPPORTED_IMAGE_FORMATS = "JPEG, PNG, GIF, or WebP"
|
|
24
|
+
_DETAIL_LEVELS = {"low", "high", "original", "auto"}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def image_media_type(data: bytes) -> str | None:
|
|
28
|
+
"""Return the MIME type of a supported image, detected from its bytes."""
|
|
29
|
+
if not isinstance(data, (bytes, bytearray)):
|
|
30
|
+
return None
|
|
31
|
+
if data.startswith(b"\x89PNG\r\n\x1a\n"):
|
|
32
|
+
return "image/png"
|
|
33
|
+
if data.startswith(b"\xff\xd8\xff"):
|
|
34
|
+
return "image/jpeg"
|
|
35
|
+
if data.startswith((b"GIF87a", b"GIF89a")):
|
|
36
|
+
return "image/gif"
|
|
37
|
+
if len(data) >= 12 and data[:4] == b"RIFF" and data[8:12] == b"WEBP":
|
|
38
|
+
return "image/webp"
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _message_text(value: Any) -> str:
|
|
43
|
+
if isinstance(value, str):
|
|
44
|
+
return value
|
|
45
|
+
if isinstance(value, list):
|
|
46
|
+
parts: list[str] = []
|
|
47
|
+
for item in value:
|
|
48
|
+
if isinstance(item, str):
|
|
49
|
+
parts.append(item)
|
|
50
|
+
elif isinstance(item, dict) and isinstance(item.get("text"), str):
|
|
51
|
+
parts.append(item["text"])
|
|
52
|
+
return "".join(parts)
|
|
53
|
+
return ""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class ProviderVisionClient:
|
|
57
|
+
"""Provider-hosted image understanding over an OpenAI-compatible endpoint."""
|
|
58
|
+
|
|
59
|
+
def __init__(
|
|
60
|
+
self,
|
|
61
|
+
name: str,
|
|
62
|
+
config: dict[str, Any],
|
|
63
|
+
*,
|
|
64
|
+
model: str | None = None,
|
|
65
|
+
transport: httpx2.BaseTransport | None = None,
|
|
66
|
+
):
|
|
67
|
+
self.name = name
|
|
68
|
+
self.enabled = False
|
|
69
|
+
self.model = ""
|
|
70
|
+
self.detail = "auto"
|
|
71
|
+
self.max_image_bytes = 0
|
|
72
|
+
self.max_chars = 0
|
|
73
|
+
self._endpoint = ""
|
|
74
|
+
self._client: httpx2.Client | None = None
|
|
75
|
+
|
|
76
|
+
vision = config.get("vision")
|
|
77
|
+
if not isinstance(vision, dict):
|
|
78
|
+
return
|
|
79
|
+
enabled = vision.get("enabled", False)
|
|
80
|
+
if not isinstance(enabled, bool):
|
|
81
|
+
raise ToolError(f"provider {name!r} vision.enabled must be a boolean")
|
|
82
|
+
if not enabled:
|
|
83
|
+
return
|
|
84
|
+
|
|
85
|
+
base_url = str(vision.get("base_url") or config.get("base_url") or "").rstrip(
|
|
86
|
+
"/"
|
|
87
|
+
)
|
|
88
|
+
try:
|
|
89
|
+
parsed = urlsplit(base_url)
|
|
90
|
+
hostname = parsed.hostname
|
|
91
|
+
_ = parsed.port
|
|
92
|
+
except ValueError as exc:
|
|
93
|
+
raise ToolError(
|
|
94
|
+
f"provider {name!r} has an invalid vision.base_url: {exc}"
|
|
95
|
+
) from exc
|
|
96
|
+
if parsed.scheme not in {"http", "https"} or not hostname:
|
|
97
|
+
raise ToolError(f"provider {name!r} has an invalid vision.base_url")
|
|
98
|
+
|
|
99
|
+
chosen_model = str(vision.get("model") or model or config.get("model") or "")
|
|
100
|
+
if not chosen_model:
|
|
101
|
+
raise ToolError(f"provider {name!r} vision requires a model")
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
timeout = float(vision.get("timeout_seconds", 120))
|
|
105
|
+
self.max_image_bytes = int(vision.get("max_image_bytes", 32 * 1024 * 1024))
|
|
106
|
+
self.max_chars = int(vision.get("max_chars", 8000))
|
|
107
|
+
except (TypeError, ValueError, OverflowError) as exc:
|
|
108
|
+
raise ToolError(
|
|
109
|
+
f"provider {name!r} has invalid vision numeric settings"
|
|
110
|
+
) from exc
|
|
111
|
+
if not math.isfinite(timeout) or timeout <= 0:
|
|
112
|
+
raise ToolError(f"provider {name!r} has an invalid vision timeout")
|
|
113
|
+
if self.max_image_bytes < 1 or self.max_chars < 1:
|
|
114
|
+
raise ToolError(f"provider {name!r} has invalid vision limits")
|
|
115
|
+
|
|
116
|
+
detail = str(vision.get("detail", "auto"))
|
|
117
|
+
if detail not in _DETAIL_LEVELS:
|
|
118
|
+
raise ToolError(
|
|
119
|
+
f"provider {name!r} vision.detail must be one of "
|
|
120
|
+
f"{', '.join(sorted(_DETAIL_LEVELS))}"
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
api_key = provider_api_key(vision) or provider_api_key(config)
|
|
124
|
+
self._client = build_client(
|
|
125
|
+
base_url=base_url,
|
|
126
|
+
headers=build_headers(
|
|
127
|
+
api_key, merge_headers(config.get("headers"), vision.get("headers"))
|
|
128
|
+
),
|
|
129
|
+
timeout=timeout,
|
|
130
|
+
transport=transport,
|
|
131
|
+
trust_env=vision.get("trust_env", config.get("trust_env")),
|
|
132
|
+
)
|
|
133
|
+
self._timeout_seconds = timeout
|
|
134
|
+
self._endpoint = (
|
|
135
|
+
base_url
|
|
136
|
+
if urlsplit(base_url).path.rstrip("/").endswith("/chat/completions")
|
|
137
|
+
else join_url_path(base_url, "chat/completions")
|
|
138
|
+
)
|
|
139
|
+
self.model = chosen_model
|
|
140
|
+
self.detail = detail
|
|
141
|
+
self.enabled = True
|
|
142
|
+
|
|
143
|
+
def close(self) -> None:
|
|
144
|
+
if self._client is not None:
|
|
145
|
+
self._client.close()
|
|
146
|
+
|
|
147
|
+
def describe(
|
|
148
|
+
self,
|
|
149
|
+
media_type: str,
|
|
150
|
+
data: bytes,
|
|
151
|
+
*,
|
|
152
|
+
question: str | None = None,
|
|
153
|
+
) -> str:
|
|
154
|
+
if not self.enabled or self._client is None:
|
|
155
|
+
raise ToolError(f"read_image is not enabled for provider {self.name!r}")
|
|
156
|
+
if not isinstance(data, bytes) or not data:
|
|
157
|
+
raise ToolError("image data must be non-empty bytes")
|
|
158
|
+
if len(data) > self.max_image_bytes:
|
|
159
|
+
raise ToolError(
|
|
160
|
+
f"image is too large: {len(data)} bytes exceeds "
|
|
161
|
+
f"vision.max_image_bytes ({self.max_image_bytes})"
|
|
162
|
+
)
|
|
163
|
+
actual_type = image_media_type(data)
|
|
164
|
+
if actual_type is None:
|
|
165
|
+
raise ToolError(
|
|
166
|
+
f"image data is not one of the supported formats "
|
|
167
|
+
f"({SUPPORTED_IMAGE_FORMATS})"
|
|
168
|
+
)
|
|
169
|
+
if question is not None and not isinstance(question, str):
|
|
170
|
+
raise ToolError("question must be a string or null")
|
|
171
|
+
prompt = (question or "").strip() or (
|
|
172
|
+
"Describe this image in detail and transcribe all visible text verbatim."
|
|
173
|
+
)
|
|
174
|
+
encoded = base64.b64encode(data).decode("ascii")
|
|
175
|
+
payload = {
|
|
176
|
+
"model": self.model,
|
|
177
|
+
"messages": [
|
|
178
|
+
{
|
|
179
|
+
"role": "user",
|
|
180
|
+
"content": [
|
|
181
|
+
{"type": "text", "text": prompt},
|
|
182
|
+
{
|
|
183
|
+
"type": "image_url",
|
|
184
|
+
"image_url": {
|
|
185
|
+
"url": f"data:{actual_type};base64,{encoded}",
|
|
186
|
+
"detail": self.detail,
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
],
|
|
190
|
+
}
|
|
191
|
+
],
|
|
192
|
+
}
|
|
193
|
+
body = post_json(
|
|
194
|
+
self._client,
|
|
195
|
+
self._endpoint,
|
|
196
|
+
payload,
|
|
197
|
+
label="vision",
|
|
198
|
+
timeout=self._timeout_seconds,
|
|
199
|
+
deadline=time.monotonic() + self._timeout_seconds,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
text = self._parse_text(body)
|
|
203
|
+
if not text.strip():
|
|
204
|
+
raise ToolError("provider returned an empty image description")
|
|
205
|
+
if len(text) > self.max_chars:
|
|
206
|
+
text = text[: self.max_chars] + "\n<description truncated>"
|
|
207
|
+
return text
|
|
208
|
+
|
|
209
|
+
@staticmethod
|
|
210
|
+
def _parse_text(body: Any) -> str:
|
|
211
|
+
if not isinstance(body, dict):
|
|
212
|
+
raise ToolError("provider vision response must be an object")
|
|
213
|
+
if body.get("error"):
|
|
214
|
+
raise ToolError(f"provider vision response failed: {body['error']}")
|
|
215
|
+
choices = body.get("choices")
|
|
216
|
+
if not isinstance(choices, list) or not choices:
|
|
217
|
+
raise ToolError("provider vision response has no choices")
|
|
218
|
+
choice = choices[0]
|
|
219
|
+
if not isinstance(choice, dict):
|
|
220
|
+
raise ToolError("provider vision response choice must be an object")
|
|
221
|
+
message = choice.get("message")
|
|
222
|
+
if not isinstance(message, dict):
|
|
223
|
+
raise ToolError("provider vision response has no message")
|
|
224
|
+
return _message_text(message.get("content"))
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
__all__ = [
|
|
228
|
+
"SUPPORTED_IMAGE_FORMATS",
|
|
229
|
+
"ProviderVisionClient",
|
|
230
|
+
"image_media_type",
|
|
231
|
+
]
|
minimalcode/web.py
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
import time
|
|
5
|
+
from typing import Any
|
|
6
|
+
from urllib.parse import urlsplit
|
|
7
|
+
|
|
8
|
+
import httpx2
|
|
9
|
+
|
|
10
|
+
from .config import provider_api_key
|
|
11
|
+
from .errors import ToolError
|
|
12
|
+
from .httpclient import (
|
|
13
|
+
build_client,
|
|
14
|
+
build_headers,
|
|
15
|
+
join_url_path,
|
|
16
|
+
merge_headers,
|
|
17
|
+
post_json,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
_MAX_NESTED_ITEMS = 32
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ProviderWebClient:
|
|
24
|
+
"""Configurable provider-hosted web tools."""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
name: str,
|
|
29
|
+
config: dict[str, Any],
|
|
30
|
+
*,
|
|
31
|
+
transport: httpx2.BaseTransport | None = None,
|
|
32
|
+
):
|
|
33
|
+
self.name = name
|
|
34
|
+
self.enabled = False
|
|
35
|
+
self.default_count = 0
|
|
36
|
+
self.max_count = 0
|
|
37
|
+
self.max_content_chars = 0
|
|
38
|
+
self._search_path = ""
|
|
39
|
+
self._fetch_path = ""
|
|
40
|
+
self._client: httpx2.Client | None = None
|
|
41
|
+
|
|
42
|
+
web = config.get("web_tools")
|
|
43
|
+
if not isinstance(web, dict):
|
|
44
|
+
return
|
|
45
|
+
enabled = web.get("enabled", False)
|
|
46
|
+
if not isinstance(enabled, bool):
|
|
47
|
+
raise ToolError(f"provider {name!r} web_tools.enabled must be a boolean")
|
|
48
|
+
if not enabled:
|
|
49
|
+
return
|
|
50
|
+
|
|
51
|
+
base_url = str(web.get("base_url") or "").rstrip("/")
|
|
52
|
+
try:
|
|
53
|
+
parsed = urlsplit(base_url)
|
|
54
|
+
hostname = parsed.hostname
|
|
55
|
+
_ = parsed.port
|
|
56
|
+
except ValueError as exc:
|
|
57
|
+
raise ToolError(
|
|
58
|
+
f"provider {name!r} has an invalid web_tools.base_url: {exc}"
|
|
59
|
+
) from exc
|
|
60
|
+
if parsed.scheme not in {"http", "https"} or not hostname:
|
|
61
|
+
raise ToolError(f"provider {name!r} has an invalid web_tools.base_url")
|
|
62
|
+
required = (
|
|
63
|
+
web.get("search_path"),
|
|
64
|
+
web.get("fetch_path"),
|
|
65
|
+
web.get("timeout_seconds"),
|
|
66
|
+
web.get("default_count"),
|
|
67
|
+
web.get("max_count"),
|
|
68
|
+
web.get("max_content_chars"),
|
|
69
|
+
)
|
|
70
|
+
if any(value in (None, "") for value in required):
|
|
71
|
+
raise ToolError(
|
|
72
|
+
f"provider {name!r} web_tools requires paths, timeout, "
|
|
73
|
+
"counts, and max_content_chars"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
timeout = float(web["timeout_seconds"])
|
|
78
|
+
self.default_count = int(web["default_count"])
|
|
79
|
+
self.max_count = int(web["max_count"])
|
|
80
|
+
self.max_content_chars = int(web["max_content_chars"])
|
|
81
|
+
except (TypeError, ValueError, OverflowError) as exc:
|
|
82
|
+
raise ToolError(
|
|
83
|
+
f"provider {name!r} has invalid web_tools numeric settings"
|
|
84
|
+
) from exc
|
|
85
|
+
if not math.isfinite(timeout) or timeout <= 0:
|
|
86
|
+
raise ToolError(f"provider {name!r} has invalid web_tools timeout")
|
|
87
|
+
if self.default_count < 1 or self.max_count < self.default_count:
|
|
88
|
+
raise ToolError(f"provider {name!r} has invalid web result limits")
|
|
89
|
+
if self.max_content_chars < 1:
|
|
90
|
+
raise ToolError(f"provider {name!r} has invalid max_content_chars")
|
|
91
|
+
self._timeout_seconds = timeout
|
|
92
|
+
|
|
93
|
+
api_key = provider_api_key(web) or provider_api_key(config)
|
|
94
|
+
self._client = build_client(
|
|
95
|
+
base_url=base_url,
|
|
96
|
+
headers=build_headers(
|
|
97
|
+
api_key, merge_headers(config.get("headers"), web.get("headers"))
|
|
98
|
+
),
|
|
99
|
+
timeout=timeout,
|
|
100
|
+
transport=transport,
|
|
101
|
+
trust_env=web.get("trust_env", config.get("trust_env")),
|
|
102
|
+
)
|
|
103
|
+
self._endpoint = base_url
|
|
104
|
+
self._search_path = str(web["search_path"]).strip("/")
|
|
105
|
+
self._fetch_path = str(web["fetch_path"]).strip("/")
|
|
106
|
+
self.enabled = True
|
|
107
|
+
|
|
108
|
+
def close(self) -> None:
|
|
109
|
+
if self._client is not None:
|
|
110
|
+
self._client.close()
|
|
111
|
+
|
|
112
|
+
def _post(self, path: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
113
|
+
if not self.enabled or self._client is None:
|
|
114
|
+
raise ToolError(f"web tools are not enabled for provider {self.name!r}")
|
|
115
|
+
return post_json(
|
|
116
|
+
self._client,
|
|
117
|
+
join_url_path(self._endpoint, path),
|
|
118
|
+
payload,
|
|
119
|
+
label="web",
|
|
120
|
+
timeout=self._timeout_seconds,
|
|
121
|
+
deadline=time.monotonic() + self._timeout_seconds,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
def search(
|
|
125
|
+
self,
|
|
126
|
+
query: str,
|
|
127
|
+
count: int | None = None,
|
|
128
|
+
need_content: bool = False,
|
|
129
|
+
ori_question: str | None = None,
|
|
130
|
+
localization: str | None = None,
|
|
131
|
+
) -> dict[str, Any]:
|
|
132
|
+
if not self.enabled:
|
|
133
|
+
raise ToolError(f"web tools are not enabled for provider {self.name!r}")
|
|
134
|
+
if not isinstance(query, str) or not query.strip() or len(query) > 128:
|
|
135
|
+
raise ToolError("query must contain 1-128 characters")
|
|
136
|
+
if count is not None and (
|
|
137
|
+
not isinstance(count, int) or isinstance(count, bool)
|
|
138
|
+
):
|
|
139
|
+
raise ToolError("count must be an integer")
|
|
140
|
+
if not isinstance(need_content, bool):
|
|
141
|
+
raise ToolError("need_content must be a boolean")
|
|
142
|
+
if ori_question is not None and not isinstance(ori_question, str):
|
|
143
|
+
raise ToolError("ori_question must be a string or null")
|
|
144
|
+
if localization is not None and not isinstance(localization, str):
|
|
145
|
+
raise ToolError("localization must be a string or null")
|
|
146
|
+
effective_count = self.default_count if count is None else count
|
|
147
|
+
if not 1 <= effective_count <= self.max_count:
|
|
148
|
+
raise ToolError(f"count must be from 1 to {self.max_count}")
|
|
149
|
+
payload: dict[str, Any] = {
|
|
150
|
+
"query": query.strip(),
|
|
151
|
+
"count": effective_count,
|
|
152
|
+
"need_content": need_content,
|
|
153
|
+
}
|
|
154
|
+
if ori_question:
|
|
155
|
+
payload["ori_question"] = ori_question
|
|
156
|
+
if localization:
|
|
157
|
+
payload["localization"] = localization
|
|
158
|
+
data = self._post(self._search_path, payload)
|
|
159
|
+
results = data.get("results", [])
|
|
160
|
+
if not isinstance(results, list):
|
|
161
|
+
raise ToolError("provider web search results must be a list")
|
|
162
|
+
# Provider output is untrusted: keep only object results, cap the count,
|
|
163
|
+
# and bound every text field so one search cannot flood the context.
|
|
164
|
+
objects = [item for item in results if isinstance(item, dict)]
|
|
165
|
+
normalized = [
|
|
166
|
+
self._normalize_result(item)
|
|
167
|
+
for item in objects[: min(effective_count, self.max_count)]
|
|
168
|
+
]
|
|
169
|
+
return {"status": "success", "results": normalized}
|
|
170
|
+
|
|
171
|
+
def _normalize_result(self, item: dict[str, Any]) -> dict[str, Any]:
|
|
172
|
+
return {key: self._normalize_value(value) for key, value in item.items()}
|
|
173
|
+
|
|
174
|
+
def _normalize_value(self, value: Any, depth: int = 0) -> Any:
|
|
175
|
+
if isinstance(value, str):
|
|
176
|
+
if len(value) > self.max_content_chars:
|
|
177
|
+
return value[: self.max_content_chars] + "\n<content truncated>"
|
|
178
|
+
return value
|
|
179
|
+
if depth >= 32:
|
|
180
|
+
return "<nested value omitted>"
|
|
181
|
+
if isinstance(value, dict):
|
|
182
|
+
items = list(value.items())
|
|
183
|
+
normalized = {
|
|
184
|
+
key: self._normalize_value(item, depth + 1)
|
|
185
|
+
for key, item in items[:_MAX_NESTED_ITEMS]
|
|
186
|
+
}
|
|
187
|
+
if len(items) > _MAX_NESTED_ITEMS:
|
|
188
|
+
normalized.setdefault("<truncated>", "<more object keys omitted>")
|
|
189
|
+
return normalized
|
|
190
|
+
if isinstance(value, list):
|
|
191
|
+
normalized = [
|
|
192
|
+
self._normalize_value(item, depth + 1)
|
|
193
|
+
for item in value[:_MAX_NESTED_ITEMS]
|
|
194
|
+
]
|
|
195
|
+
if len(value) > _MAX_NESTED_ITEMS:
|
|
196
|
+
normalized.append("<more list items omitted>")
|
|
197
|
+
return normalized
|
|
198
|
+
return value
|
|
199
|
+
|
|
200
|
+
def fetch(self, url: str) -> dict[str, Any]:
|
|
201
|
+
if not self.enabled:
|
|
202
|
+
raise ToolError(f"web tools are not enabled for provider {self.name!r}")
|
|
203
|
+
try:
|
|
204
|
+
parsed = urlsplit(url) if isinstance(url, str) else None
|
|
205
|
+
hostname = parsed.hostname if parsed is not None else None
|
|
206
|
+
if parsed is not None:
|
|
207
|
+
_ = parsed.port
|
|
208
|
+
except ValueError as exc:
|
|
209
|
+
raise ToolError(
|
|
210
|
+
f"url must be an absolute HTTP or HTTPS URL: {exc}"
|
|
211
|
+
) from exc
|
|
212
|
+
if parsed is None or parsed.scheme not in {"http", "https"} or not hostname:
|
|
213
|
+
raise ToolError("url must be an absolute HTTP or HTTPS URL")
|
|
214
|
+
data = self._post(self._fetch_path, {"url": url})
|
|
215
|
+
title = data.get("title")
|
|
216
|
+
if title is None:
|
|
217
|
+
title = ""
|
|
218
|
+
content = data.get("content")
|
|
219
|
+
if content is None:
|
|
220
|
+
# An explicit JSON null means "no readable content", the same way a
|
|
221
|
+
# null title is tolerated above.
|
|
222
|
+
content = ""
|
|
223
|
+
if not isinstance(title, str):
|
|
224
|
+
raise ToolError("provider web fetch title must be a string")
|
|
225
|
+
if not isinstance(content, str):
|
|
226
|
+
raise ToolError("provider web fetch content must be a string")
|
|
227
|
+
title_truncated = len(title) > self.max_content_chars
|
|
228
|
+
if title_truncated:
|
|
229
|
+
title = title[: self.max_content_chars] + "...<title truncated>"
|
|
230
|
+
truncated = len(content) > self.max_content_chars
|
|
231
|
+
if truncated:
|
|
232
|
+
content = content[: self.max_content_chars] + "\n<content truncated>"
|
|
233
|
+
return {
|
|
234
|
+
"status": "success",
|
|
235
|
+
"title": title,
|
|
236
|
+
"content": content,
|
|
237
|
+
"truncated": truncated or title_truncated,
|
|
238
|
+
}
|