voidx 1.0.0__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 (126) hide show
  1. voidx/__init__.py +3 -0
  2. voidx/agent/__init__.py +0 -0
  3. voidx/agent/agents.py +439 -0
  4. voidx/agent/attachments.py +235 -0
  5. voidx/agent/graph.py +463 -0
  6. voidx/agent/graph_components/__init__.py +1 -0
  7. voidx/agent/graph_components/compaction.py +268 -0
  8. voidx/agent/graph_components/permissions.py +139 -0
  9. voidx/agent/graph_components/run_loop.py +532 -0
  10. voidx/agent/graph_components/runtime.py +14 -0
  11. voidx/agent/graph_components/streaming.py +351 -0
  12. voidx/agent/graph_components/subagent.py +278 -0
  13. voidx/agent/graph_components/tool_execution.py +208 -0
  14. voidx/agent/runtime_context.py +368 -0
  15. voidx/agent/slash.py +466 -0
  16. voidx/agent/slash_components/__init__.py +1 -0
  17. voidx/agent/slash_components/code_ide.py +68 -0
  18. voidx/agent/slash_components/lsp.py +105 -0
  19. voidx/agent/slash_components/mcp.py +332 -0
  20. voidx/agent/slash_components/model.py +419 -0
  21. voidx/agent/slash_components/runtime.py +55 -0
  22. voidx/agent/slash_components/skills.py +94 -0
  23. voidx/agent/state.py +32 -0
  24. voidx/agent/task_state.py +278 -0
  25. voidx/agent/tool_filters.py +27 -0
  26. voidx/config.py +707 -0
  27. voidx/llm/__init__.py +0 -0
  28. voidx/llm/catalog.py +188 -0
  29. voidx/llm/compaction.py +267 -0
  30. voidx/llm/context.py +43 -0
  31. voidx/llm/instruction.py +220 -0
  32. voidx/llm/provider.py +312 -0
  33. voidx/llm/usage.py +341 -0
  34. voidx/lsp/__init__.py +30 -0
  35. voidx/lsp/client.py +259 -0
  36. voidx/lsp/config.py +172 -0
  37. voidx/lsp/detector.py +512 -0
  38. voidx/lsp/errors.py +19 -0
  39. voidx/lsp/manager.py +280 -0
  40. voidx/lsp/schema.py +179 -0
  41. voidx/lsp/service.py +103 -0
  42. voidx/main.py +154 -0
  43. voidx/mcp/__init__.py +33 -0
  44. voidx/mcp/client.py +458 -0
  45. voidx/mcp/manager.py +267 -0
  46. voidx/mcp/schema.py +112 -0
  47. voidx/mcp/tool.py +122 -0
  48. voidx/mcp_servers/__init__.py +1 -0
  49. voidx/mcp_servers/web.py +104 -0
  50. voidx/memory/__init__.py +0 -0
  51. voidx/memory/context_frames.py +188 -0
  52. voidx/memory/model_profiles.py +98 -0
  53. voidx/memory/runtime_state.py +240 -0
  54. voidx/memory/session.py +272 -0
  55. voidx/memory/store.py +245 -0
  56. voidx/memory/transcript.py +137 -0
  57. voidx/permission/__init__.py +28 -0
  58. voidx/permission/engine.py +430 -0
  59. voidx/permission/evaluate.py +114 -0
  60. voidx/permission/sandbox.py +280 -0
  61. voidx/permission/schema.py +24 -0
  62. voidx/permission/service.py +314 -0
  63. voidx/permission/wildcard.py +34 -0
  64. voidx/skills/__init__.py +18 -0
  65. voidx/skills/bundled/superpowers/receiving-code-review/SKILL.md +30 -0
  66. voidx/skills/bundled/superpowers/requesting-code-review/SKILL.md +27 -0
  67. voidx/skills/bundled/superpowers/systematic-debugging/SKILL.md +36 -0
  68. voidx/skills/bundled/superpowers/test-driven-development/SKILL.md +33 -0
  69. voidx/skills/bundled/superpowers/verification-before-completion/SKILL.md +31 -0
  70. voidx/skills/bundled/superpowers/writing-plans/SKILL.md +27 -0
  71. voidx/skills/policy.py +97 -0
  72. voidx/skills/registry.py +162 -0
  73. voidx/skills/schema.py +47 -0
  74. voidx/skills/service.py +199 -0
  75. voidx/tools/__init__.py +0 -0
  76. voidx/tools/agent.py +81 -0
  77. voidx/tools/base.py +86 -0
  78. voidx/tools/bash.py +105 -0
  79. voidx/tools/file_ops.py +193 -0
  80. voidx/tools/lsp.py +155 -0
  81. voidx/tools/registry.py +104 -0
  82. voidx/tools/repomap.py +238 -0
  83. voidx/tools/search.py +162 -0
  84. voidx/tools/task_status.py +57 -0
  85. voidx/tools/task_tracker.py +81 -0
  86. voidx/tools/todo.py +82 -0
  87. voidx/tools/web_content.py +357 -0
  88. voidx/tools/web_mcp.py +107 -0
  89. voidx/tools/webfetch.py +155 -0
  90. voidx/tools/websearch.py +276 -0
  91. voidx/ui/__init__.py +0 -0
  92. voidx/ui/app.py +1033 -0
  93. voidx/ui/app_components/__init__.py +1 -0
  94. voidx/ui/app_components/clipboard_image.py +245 -0
  95. voidx/ui/app_components/commands.py +18 -0
  96. voidx/ui/app_components/controls.py +29 -0
  97. voidx/ui/app_components/file_picker.py +115 -0
  98. voidx/ui/app_components/formatting.py +187 -0
  99. voidx/ui/app_components/git_changes.py +51 -0
  100. voidx/ui/app_components/rendering.py +1169 -0
  101. voidx/ui/browse.py +160 -0
  102. voidx/ui/capture.py +169 -0
  103. voidx/ui/code_ide.py +251 -0
  104. voidx/ui/commands.py +83 -0
  105. voidx/ui/console.py +381 -0
  106. voidx/ui/console_components/__init__.py +1 -0
  107. voidx/ui/console_components/formatting.py +96 -0
  108. voidx/ui/console_components/streaming.py +253 -0
  109. voidx/ui/diff.py +331 -0
  110. voidx/ui/dock.py +372 -0
  111. voidx/ui/dock_components/__init__.py +1 -0
  112. voidx/ui/dock_components/formatting.py +123 -0
  113. voidx/ui/dock_components/nodes.py +401 -0
  114. voidx/ui/dock_components/state.py +51 -0
  115. voidx/ui/event_components/__init__.py +1 -0
  116. voidx/ui/event_components/schema.py +249 -0
  117. voidx/ui/events.py +341 -0
  118. voidx/ui/session_changes.py +163 -0
  119. voidx/ui/startup.py +161 -0
  120. voidx/ui/transcript.py +148 -0
  121. voidx/ui/tree.py +316 -0
  122. voidx-1.0.0.dist-info/METADATA +59 -0
  123. voidx-1.0.0.dist-info/RECORD +126 -0
  124. voidx-1.0.0.dist-info/WHEEL +5 -0
  125. voidx-1.0.0.dist-info/entry_points.txt +2 -0
  126. voidx-1.0.0.dist-info/top_level.txt +1 -0
voidx/tools/todo.py ADDED
@@ -0,0 +1,82 @@
1
+ """TodoWrite tool — stateful task list, Claude Code aligned.
2
+
3
+ Each call REPLACES the entire list. Status persists across calls via tracker.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from pydantic import BaseModel, Field
9
+
10
+ from voidx.tools.base import BaseTool, model_to_json_schema, ToolContext, ToolResult
11
+
12
+
13
+ class TodoItem(BaseModel):
14
+ content: str = Field(description="Task description, one sentence.")
15
+ status: str = Field(
16
+ default="pending",
17
+ description="pending | in_progress | completed | cancelled"
18
+ )
19
+
20
+
21
+ class TodoInput(BaseModel):
22
+ todos: list[TodoItem] = Field(
23
+ description="Full todo list — replaces the previous list entirely. Include ALL items."
24
+ )
25
+
26
+
27
+ class TodoWriteTool(BaseTool):
28
+ id = "todo"
29
+ description = (
30
+ "Create and manage a task list. Each call REPLACES the entire list — "
31
+ "pass the full updated list. Items:[{id, status, content}] "
32
+ "Status: pending → in_progress → completed."
33
+ )
34
+
35
+ def __init__(self, tracker=None):
36
+ super().__init__()
37
+ self._tracker = tracker
38
+
39
+ def parameters_schema(self) -> dict:
40
+ return model_to_json_schema(TodoInput)
41
+
42
+ async def execute(self, args: dict, ctx: ToolContext) -> ToolResult:
43
+ inp = TodoInput.model_validate(args)
44
+
45
+ total = len(inp.todos)
46
+ done = sum(1 for t in inp.todos if t.status == "completed")
47
+ in_progress = sum(1 for t in inp.todos if t.status == "in_progress")
48
+ pending = sum(1 for t in inp.todos if t.status == "pending")
49
+ cancelled = sum(1 for t in inp.todos if t.status == "cancelled")
50
+
51
+ # Store in tracker if available
52
+ if self._tracker:
53
+ self._tracker._todos = inp.todos
54
+
55
+ # Format with icons and progress
56
+ ICONS = {"pending": "○", "in_progress": "◐", "completed": "●", "cancelled": "✕"}
57
+ lines = []
58
+
59
+ # Progress bar
60
+ if total > 0:
61
+ pct = done / total
62
+ bar_len = 20
63
+ filled = int(bar_len * pct)
64
+ bar = "█" * filled + "░" * (bar_len - filled)
65
+ lines.append(f"[{bar}] {done}/{total} done")
66
+
67
+ # Group by status
68
+ for status in ["in_progress", "pending", "completed", "cancelled"]:
69
+ items = [t for t in inp.todos if t.status == status]
70
+ if not items:
71
+ continue
72
+ for item in items:
73
+ lines.append(f" {ICONS[item.status]} {item.content}")
74
+
75
+ return ToolResult(
76
+ title=f"Todo: {done}/{total} done · {in_progress} active · {pending} pending",
77
+ output="\n".join(lines),
78
+ metadata={
79
+ "total": total, "done": done, "in_progress": in_progress,
80
+ "pending": pending, "cancelled": cancelled,
81
+ },
82
+ )
@@ -0,0 +1,357 @@
1
+ """Shared helpers for web search/fetch tools."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ import html
7
+ import re
8
+ import time
9
+ from dataclasses import dataclass
10
+ from html.parser import HTMLParser
11
+ from typing import Any
12
+ from urllib.parse import parse_qsl, parse_qs, urlencode, unquote, urlparse, urlunparse
13
+
14
+ from voidx.tools.base import ToolResult
15
+
16
+ _TRACKING_PARAMS = {
17
+ "fbclid",
18
+ "gclid",
19
+ "igshid",
20
+ "mc_cid",
21
+ "mc_eid",
22
+ "msclkid",
23
+ "ref",
24
+ "spm",
25
+ }
26
+ _SKIP_TAGS = {"script", "style", "noscript", "svg", "nav", "footer", "header", "form"}
27
+ _BLOCK_TAGS = {
28
+ "article",
29
+ "aside",
30
+ "blockquote",
31
+ "dd",
32
+ "div",
33
+ "dl",
34
+ "dt",
35
+ "figcaption",
36
+ "figure",
37
+ "li",
38
+ "main",
39
+ "ol",
40
+ "p",
41
+ "pre",
42
+ "section",
43
+ "table",
44
+ "tbody",
45
+ "td",
46
+ "th",
47
+ "thead",
48
+ "tr",
49
+ "ul",
50
+ }
51
+ _STOP_WORDS = {
52
+ "about",
53
+ "after",
54
+ "before",
55
+ "content",
56
+ "extract",
57
+ "from",
58
+ "only",
59
+ "page",
60
+ "show",
61
+ "this",
62
+ "with",
63
+ }
64
+
65
+
66
+ @dataclass
67
+ class _CacheEntry:
68
+ value: Any
69
+ expires_at: float
70
+
71
+
72
+ class WebToolCache:
73
+ def __init__(self, max_entries: int = 256) -> None:
74
+ self._items: dict[str, _CacheEntry] = {}
75
+ self._max_entries = max_entries
76
+
77
+ def get(self, key: str) -> Any | None:
78
+ entry = self._items.get(key)
79
+ if entry is None:
80
+ return None
81
+ if entry.expires_at < time.monotonic():
82
+ self._items.pop(key, None)
83
+ return None
84
+ return copy.deepcopy(entry.value)
85
+
86
+ def set(self, key: str, value: Any, ttl_seconds: float) -> None:
87
+ if len(self._items) >= self._max_entries:
88
+ oldest = min(self._items, key=lambda item: self._items[item].expires_at)
89
+ self._items.pop(oldest, None)
90
+ self._items[key] = _CacheEntry(copy.deepcopy(value), time.monotonic() + ttl_seconds)
91
+
92
+ def clear(self) -> None:
93
+ self._items.clear()
94
+
95
+
96
+ WEB_TOOL_CACHE = WebToolCache()
97
+
98
+
99
+ def cached_tool_result(result: ToolResult) -> ToolResult:
100
+ metadata = dict(result.metadata or {})
101
+ metadata["cached"] = True
102
+ return result.model_copy(deep=True, update={"metadata": metadata})
103
+
104
+
105
+ def canonicalize_url(url: str) -> str:
106
+ url = _decode_duckduckgo_url(url.strip())
107
+ parsed = urlparse(url)
108
+ scheme = (parsed.scheme or "https").lower()
109
+ host = (parsed.hostname or "").lower()
110
+ netloc = host
111
+ if parsed.port and not (
112
+ scheme == "http" and parsed.port == 80
113
+ or scheme == "https" and parsed.port == 443
114
+ ):
115
+ netloc = f"{host}:{parsed.port}"
116
+ query_items = [
117
+ (key, value)
118
+ for key, value in parse_qsl(parsed.query, keep_blank_values=True)
119
+ if not key.lower().startswith("utm_") and key.lower() not in _TRACKING_PARAMS
120
+ ]
121
+ query = urlencode(sorted(query_items), doseq=True)
122
+ path = parsed.path or "/"
123
+ if path != "/":
124
+ path = path.rstrip("/")
125
+ return urlunparse((scheme, netloc, path, "", query, ""))
126
+
127
+
128
+ def domain_for_url(url: str) -> str:
129
+ host = urlparse(canonicalize_url(url)).hostname or ""
130
+ return host.removeprefix("www.")
131
+
132
+
133
+ def matches_domain(url: str, domain: str) -> bool:
134
+ host = domain_for_url(url)
135
+ target = domain.lower().strip().removeprefix("www.")
136
+ return host == target or host.endswith(f".{target}")
137
+
138
+
139
+ def normalize_search_results(results: list[dict[str, str]]) -> list[dict[str, Any]]:
140
+ seen: set[str] = set()
141
+ normalized: list[dict[str, str]] = []
142
+ for rank, result in enumerate(results, start=1):
143
+ raw_url = result.get("url", "").strip()
144
+ if not raw_url:
145
+ continue
146
+ url = canonicalize_url(raw_url)
147
+ if url in seen:
148
+ continue
149
+ seen.add(url)
150
+ title = _clean_inline_text(result.get("title", "")) or domain_for_url(url) or url
151
+ snippet = _clean_inline_text(result.get("snippet", ""))
152
+ normalized.append({
153
+ "rank": rank,
154
+ "title": title,
155
+ "url": url,
156
+ "domain": domain_for_url(url),
157
+ "snippet": snippet,
158
+ })
159
+ return normalized
160
+
161
+
162
+ def extract_readable_content(
163
+ *,
164
+ url: str,
165
+ text: str,
166
+ content_type: str = "",
167
+ prompt: str = "",
168
+ max_chars: int = 12_000,
169
+ ) -> dict[str, Any]:
170
+ if "html" in content_type.lower() or _looks_like_html(text):
171
+ parsed = _ReadableHtmlParser()
172
+ parsed.feed(text)
173
+ parsed.close()
174
+ title = _clean_inline_text(parsed.title)
175
+ blocks = _dedupe_blocks(parsed.blocks)
176
+ else:
177
+ title = ""
178
+ blocks = _plain_text_blocks(text)
179
+
180
+ page_text = "\n\n".join(blocks).strip()
181
+ relevant = _relevant_blocks(blocks, prompt)
182
+ if relevant:
183
+ page_text = "## Relevant excerpts\n\n" + "\n\n".join(relevant) + "\n\n## Page content\n\n" + page_text
184
+
185
+ total_chars = len(page_text)
186
+ truncated = total_chars > max_chars
187
+ if truncated:
188
+ page_text = page_text[:max_chars].rstrip()
189
+ page_text += f"\n\n[truncated: {total_chars} extracted chars, showing first {max_chars}]"
190
+
191
+ return {
192
+ "title": title,
193
+ "url": canonicalize_url(url),
194
+ "content": page_text,
195
+ "total_chars": total_chars,
196
+ "truncated": truncated,
197
+ "excerpt_count": len(relevant),
198
+ }
199
+
200
+
201
+ def search_cache_key(
202
+ *,
203
+ query: str,
204
+ allowed_domains: list[str] | None,
205
+ blocked_domains: list[str] | None,
206
+ max_results: int,
207
+ backend: str,
208
+ ) -> str:
209
+ return "|".join([
210
+ "search:v2",
211
+ backend,
212
+ _clean_inline_text(query).lower(),
213
+ ",".join(sorted((allowed_domains or []))),
214
+ ",".join(sorted((blocked_domains or []))),
215
+ str(max_results),
216
+ ])
217
+
218
+
219
+ def fetch_cache_key(url: str, prompt: str, max_chars: int) -> str:
220
+ return "|".join(["fetch:v2", canonicalize_url(url), _clean_inline_text(prompt).lower(), str(max_chars)])
221
+
222
+
223
+ class _ReadableHtmlParser(HTMLParser):
224
+ def __init__(self) -> None:
225
+ super().__init__(convert_charrefs=True)
226
+ self.blocks: list[str] = []
227
+ self.title = ""
228
+ self._title_parts: list[str] = []
229
+ self._current_parts: list[str] = []
230
+ self._current_tag = ""
231
+ self._heading_level = 0
232
+ self._skip_depth = 0
233
+ self._in_title = False
234
+
235
+ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
236
+ tag = tag.lower()
237
+ if tag in _SKIP_TAGS:
238
+ self._skip_depth += 1
239
+ return
240
+ if tag == "title":
241
+ self._in_title = True
242
+ self._title_parts = []
243
+ return
244
+ if self._skip_depth:
245
+ return
246
+ if tag == "br":
247
+ self._append_text("\n")
248
+ return
249
+ if tag in _BLOCK_TAGS or re.fullmatch(r"h[1-6]", tag):
250
+ self._finish_block()
251
+ self._current_tag = tag
252
+ self._heading_level = int(tag[1]) if re.fullmatch(r"h[1-6]", tag) else 0
253
+
254
+ def handle_endtag(self, tag: str) -> None:
255
+ tag = tag.lower()
256
+ if tag in _SKIP_TAGS and self._skip_depth:
257
+ self._skip_depth -= 1
258
+ return
259
+ if tag == "title":
260
+ self.title = _clean_inline_text(" ".join(self._title_parts))
261
+ self._in_title = False
262
+ return
263
+ if self._skip_depth:
264
+ return
265
+ if tag == self._current_tag or tag in _BLOCK_TAGS or re.fullmatch(r"h[1-6]", tag):
266
+ self._finish_block()
267
+
268
+ def handle_data(self, data: str) -> None:
269
+ if self._skip_depth:
270
+ return
271
+ if self._in_title:
272
+ self._title_parts.append(data)
273
+ return
274
+ if data.strip():
275
+ self._append_text(data)
276
+
277
+ def close(self) -> None:
278
+ self._finish_block()
279
+ super().close()
280
+
281
+ def _append_text(self, text: str) -> None:
282
+ if not self._current_tag:
283
+ self._current_tag = "p"
284
+ self._current_parts.append(text)
285
+
286
+ def _finish_block(self) -> None:
287
+ raw = " ".join(self._current_parts)
288
+ text = _clean_block_text(raw)
289
+ if text:
290
+ if self._heading_level:
291
+ text = f"{'#' * self._heading_level} {text}"
292
+ elif self._current_tag == "li":
293
+ text = f"- {text}"
294
+ self.blocks.append(text)
295
+ self._current_parts = []
296
+ self._current_tag = ""
297
+ self._heading_level = 0
298
+
299
+
300
+ def _decode_duckduckgo_url(url: str) -> str:
301
+ parsed = urlparse(url)
302
+ if "duckduckgo.com" not in (parsed.hostname or ""):
303
+ return url
304
+ values = parse_qs(parsed.query).get("uddg")
305
+ if values:
306
+ return unquote(values[0])
307
+ return url
308
+
309
+
310
+ def _looks_like_html(text: str) -> bool:
311
+ head = text[:500].lower()
312
+ return "<html" in head or "<body" in head or "<!doctype html" in head
313
+
314
+
315
+ def _plain_text_blocks(text: str) -> list[str]:
316
+ return [
317
+ _clean_block_text(block)
318
+ for block in re.split(r"\n\s*\n", text)
319
+ if _clean_block_text(block)
320
+ ]
321
+
322
+
323
+ def _dedupe_blocks(blocks: list[str]) -> list[str]:
324
+ result: list[str] = []
325
+ seen: set[str] = set()
326
+ for block in blocks:
327
+ normalized = _clean_inline_text(block).lower()
328
+ if len(normalized) < 2 or normalized in seen:
329
+ continue
330
+ seen.add(normalized)
331
+ result.append(block)
332
+ return result
333
+
334
+
335
+ def _relevant_blocks(blocks: list[str], prompt: str) -> list[str]:
336
+ terms = [
337
+ term.lower()
338
+ for term in re.findall(r"[A-Za-z0-9_.-]{3,}|[\u4e00-\u9fff]{2,}", prompt)
339
+ if term.lower() not in _STOP_WORDS
340
+ ]
341
+ if not terms:
342
+ return []
343
+ matches = [
344
+ block
345
+ for block in blocks
346
+ if any(term in block.lower() for term in terms)
347
+ ]
348
+ return matches[:5]
349
+
350
+
351
+ def _clean_inline_text(text: str) -> str:
352
+ return re.sub(r"\s+", " ", html.unescape(str(text))).strip()
353
+
354
+
355
+ def _clean_block_text(text: str) -> str:
356
+ lines = [_clean_inline_text(line) for line in str(text).splitlines()]
357
+ return "\n".join(line for line in lines if line).strip()
voidx/tools/web_mcp.py ADDED
@@ -0,0 +1,107 @@
1
+ """MCP delegation helpers for web tools."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from voidx.tools.base import ToolContext, ToolResult
8
+
9
+
10
+ def adapt_mcp_web_arguments(kind: str, tool: str, arguments: dict[str, Any]) -> dict[str, Any]:
11
+ if kind == "search" and tool == "tavily_search":
12
+ return _adapt_tavily_search_arguments(arguments)
13
+ if kind == "fetch" and tool == "tavily_extract":
14
+ return _adapt_tavily_extract_arguments(arguments)
15
+ return dict(arguments)
16
+
17
+
18
+ def _adapt_tavily_search_arguments(arguments: dict[str, Any]) -> dict[str, Any]:
19
+ mapped: dict[str, Any] = {"query": arguments["query"]}
20
+
21
+ allowed = arguments.get("allowed_domains")
22
+ if allowed:
23
+ mapped["include_domains"] = allowed
24
+
25
+ blocked = arguments.get("blocked_domains")
26
+ if blocked:
27
+ mapped["exclude_domains"] = blocked
28
+
29
+ max_results = arguments.get("max_results")
30
+ if max_results:
31
+ mapped["max_results"] = max_results
32
+
33
+ return mapped
34
+
35
+
36
+ def _adapt_tavily_extract_arguments(arguments: dict[str, Any]) -> dict[str, Any]:
37
+ mapped: dict[str, Any] = {"urls": [arguments["url"]]}
38
+
39
+ prompt = arguments.get("prompt")
40
+ if prompt:
41
+ mapped["query"] = prompt
42
+
43
+ return mapped
44
+
45
+
46
+ async def call_mcp_web_tool(
47
+ *,
48
+ kind: str,
49
+ settings: Any,
50
+ ctx: ToolContext,
51
+ arguments: dict[str, Any],
52
+ title: str,
53
+ ) -> ToolResult | None:
54
+ if settings is None or not hasattr(settings, "get_web_tool_route"):
55
+ return None
56
+
57
+ route = settings.get_web_tool_route(kind)
58
+ if route.backend != "mcp":
59
+ return None
60
+
61
+ if not route.server or not route.tool:
62
+ return ToolResult(
63
+ output=f"Web {kind} is configured for MCP but no server/tool route is set.",
64
+ metadata={"backend": "mcp", "error": True, "kind": kind},
65
+ )
66
+
67
+ manager = getattr(ctx, "mcp_manager", None)
68
+ if manager is None:
69
+ return ToolResult(
70
+ output=f"Web {kind} is configured for MCP but no MCP manager is available.",
71
+ metadata={
72
+ "backend": "mcp",
73
+ "error": True,
74
+ "kind": kind,
75
+ "server": route.server,
76
+ "tool": route.tool,
77
+ },
78
+ )
79
+
80
+ try:
81
+ adapted_arguments = adapt_mcp_web_arguments(kind, route.tool, arguments)
82
+ result = await manager.call_tool(route.server, route.tool, adapted_arguments)
83
+ except Exception as exc:
84
+ return ToolResult(
85
+ output=f"MCP web {kind} failed via {route.server}/{route.tool}: {exc}",
86
+ metadata={
87
+ "backend": "mcp",
88
+ "error": True,
89
+ "kind": kind,
90
+ "server": route.server,
91
+ "tool": route.tool,
92
+ },
93
+ )
94
+
95
+ from voidx.mcp.tool import format_mcp_call_result
96
+
97
+ return ToolResult(
98
+ title=title,
99
+ output=format_mcp_call_result(result),
100
+ metadata={
101
+ "backend": "mcp",
102
+ "kind": kind,
103
+ "server": route.server,
104
+ "tool": route.tool,
105
+ "error": result.isError,
106
+ },
107
+ )
@@ -0,0 +1,155 @@
1
+ """WebFetch tool — fetch web page content. SSRF-protected."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ipaddress
6
+ import socket
7
+ from dataclasses import dataclass
8
+ from urllib.parse import urlparse
9
+
10
+ import httpx
11
+ from pydantic import BaseModel, Field
12
+
13
+ from voidx.tools.base import BaseTool, model_to_json_schema, ToolContext, ToolResult
14
+ from voidx.tools.web_content import (
15
+ WEB_TOOL_CACHE,
16
+ cached_tool_result,
17
+ canonicalize_url,
18
+ extract_readable_content,
19
+ fetch_cache_key,
20
+ )
21
+ from voidx.tools.web_mcp import call_mcp_web_tool
22
+
23
+ _PRIVATE_RANGES = (
24
+ ipaddress.IPv4Network("127.0.0.0/8"), # loopback
25
+ ipaddress.IPv4Network("10.0.0.0/8"), # private
26
+ ipaddress.IPv4Network("172.16.0.0/12"), # private
27
+ ipaddress.IPv4Network("192.168.0.0/16"), # private
28
+ ipaddress.IPv4Network("169.254.0.0/16"), # link-local
29
+ ipaddress.IPv4Network("0.0.0.0/8"), # current network
30
+ ipaddress.IPv6Network("::1/128"), # IPv6 loopback
31
+ ipaddress.IPv6Network("fc00::/7"), # IPv6 unique local
32
+ ipaddress.IPv6Network("fe80::/10"), # IPv6 link-local
33
+ )
34
+
35
+
36
+ def _is_private_host(host: str) -> bool:
37
+ """Check if a hostname or IP resolves to a private/internal address."""
38
+ try:
39
+ addr = ipaddress.ip_address(host)
40
+ except ValueError:
41
+ try:
42
+ addr = ipaddress.ip_address(socket.gethostbyname(host))
43
+ except (socket.gaierror, OSError):
44
+ return False
45
+ return any(addr in net for net in _PRIVATE_RANGES)
46
+
47
+
48
+ class WebFetchInput(BaseModel):
49
+ url: str = Field(description="URL to fetch content from")
50
+ prompt: str = Field(description="The prompt to run on the fetched content")
51
+ max_chars: int = Field(default=12000, ge=1000, le=50000, description="Maximum extracted text characters")
52
+
53
+
54
+ @dataclass
55
+ class _FetchResponse:
56
+ url: str
57
+ status_code: int
58
+ text: str
59
+ content_type: str
60
+
61
+
62
+ class WebFetchTool(BaseTool):
63
+ id = "webfetch"
64
+ description = "Fetch content from a URL and convert to readable text."
65
+
66
+ def __init__(self, settings=None):
67
+ self._settings = settings
68
+
69
+ def parameters_schema(self) -> dict:
70
+ return model_to_json_schema(WebFetchInput)
71
+
72
+ async def execute(self, args: dict, ctx: ToolContext) -> ToolResult:
73
+ inp = WebFetchInput.model_validate(args)
74
+ mcp_arguments = inp.model_dump(exclude_none=True)
75
+ if "max_chars" not in args:
76
+ mcp_arguments.pop("max_chars", None)
77
+ mcp_result = await call_mcp_web_tool(
78
+ kind="fetch",
79
+ settings=self._settings,
80
+ ctx=ctx,
81
+ arguments=mcp_arguments,
82
+ title=f"Fetched: {inp.url}",
83
+ )
84
+ if mcp_result is not None:
85
+ return mcp_result
86
+
87
+ parsed = urlparse(inp.url)
88
+ if parsed.scheme not in {"http", "https"}:
89
+ return ToolResult(
90
+ output=f"Blocked: unsupported URL scheme '{parsed.scheme or '(none)'}'",
91
+ metadata={"url": inp.url, "blocked": True, "reason": "unsupported_scheme"},
92
+ )
93
+ if parsed.hostname and _is_private_host(parsed.hostname):
94
+ return ToolResult(
95
+ output=f"Blocked: {parsed.hostname} resolves to a private/internal address",
96
+ metadata={"url": inp.url, "blocked": True},
97
+ )
98
+
99
+ key = fetch_cache_key(inp.url, inp.prompt, inp.max_chars)
100
+ cached = WEB_TOOL_CACHE.get(key)
101
+ if isinstance(cached, ToolResult):
102
+ return cached_tool_result(cached)
103
+
104
+ try:
105
+ resp = await _fetch_url(inp.url)
106
+ final_host = urlparse(resp.url).hostname
107
+ if final_host and _is_private_host(final_host):
108
+ return ToolResult(
109
+ output=f"Blocked: redirect target {final_host} resolves to a private/internal address",
110
+ metadata={"url": inp.url, "final_url": resp.url, "blocked": True},
111
+ )
112
+ extracted = extract_readable_content(
113
+ url=resp.url,
114
+ text=resp.text,
115
+ content_type=resp.content_type,
116
+ prompt=inp.prompt,
117
+ max_chars=inp.max_chars,
118
+ )
119
+ output = extracted["content"] or resp.text[:inp.max_chars]
120
+
121
+ result = ToolResult(
122
+ title=f"Fetched: {canonicalize_url(resp.url)}",
123
+ output=output,
124
+ metadata={
125
+ "url": inp.url,
126
+ "canonical_url": extracted["url"],
127
+ "status": resp.status_code,
128
+ "size": len(resp.text),
129
+ "content_type": resp.content_type,
130
+ "title": extracted["title"],
131
+ "extracted_chars": extracted["total_chars"],
132
+ "truncated": extracted["truncated"],
133
+ "excerpt_count": extracted["excerpt_count"],
134
+ "cached": False,
135
+ },
136
+ )
137
+ WEB_TOOL_CACHE.set(key, result, ttl_seconds=1800)
138
+ return result
139
+ except Exception as e:
140
+ return ToolResult(
141
+ output=f"Failed to fetch {inp.url}: {e}",
142
+ metadata={"url": inp.url, "error": str(e)},
143
+ )
144
+
145
+
146
+ async def _fetch_url(url: str) -> _FetchResponse:
147
+ async with httpx.AsyncClient(timeout=20, follow_redirects=True) as client:
148
+ resp = await client.get(url, headers={"User-Agent": "voidx/0.1"})
149
+ resp.raise_for_status()
150
+ return _FetchResponse(
151
+ url=str(resp.url),
152
+ status_code=resp.status_code,
153
+ text=resp.text,
154
+ content_type=resp.headers.get("content-type", ""),
155
+ )