zjcode 0.0.1__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 (163) hide show
  1. deepagents_code/__init__.py +42 -0
  2. deepagents_code/__main__.py +6 -0
  3. deepagents_code/_ask_user_types.py +90 -0
  4. deepagents_code/_cli_context.py +96 -0
  5. deepagents_code/_constants.py +41 -0
  6. deepagents_code/_debug.py +148 -0
  7. deepagents_code/_debug_buffer.py +204 -0
  8. deepagents_code/_env_vars.py +411 -0
  9. deepagents_code/_fake_models.py +66 -0
  10. deepagents_code/_git.py +521 -0
  11. deepagents_code/_paths.py +69 -0
  12. deepagents_code/_server_config.py +576 -0
  13. deepagents_code/_session_stats.py +235 -0
  14. deepagents_code/_startup_error.py +45 -0
  15. deepagents_code/_testing_models.py +305 -0
  16. deepagents_code/_textual_patches.py +420 -0
  17. deepagents_code/_tool_stream.py +694 -0
  18. deepagents_code/_version.py +46 -0
  19. deepagents_code/agent.py +1976 -0
  20. deepagents_code/app.py +19239 -0
  21. deepagents_code/app.tcss +438 -0
  22. deepagents_code/approval_mode.py +131 -0
  23. deepagents_code/ask_user.py +306 -0
  24. deepagents_code/auth_display.py +185 -0
  25. deepagents_code/auth_store.py +545 -0
  26. deepagents_code/built_in_skills/__init__.py +5 -0
  27. deepagents_code/built_in_skills/remember/SKILL.md +118 -0
  28. deepagents_code/built_in_skills/skill-creator/SKILL.md +383 -0
  29. deepagents_code/built_in_skills/skill-creator/scripts/init_skill.py +366 -0
  30. deepagents_code/built_in_skills/skill-creator/scripts/quick_validate.py +158 -0
  31. deepagents_code/client/__init__.py +1 -0
  32. deepagents_code/client/commands/__init__.py +1 -0
  33. deepagents_code/client/commands/auth.py +541 -0
  34. deepagents_code/client/commands/config.py +714 -0
  35. deepagents_code/client/commands/mcp.py +250 -0
  36. deepagents_code/client/commands/tools.py +416 -0
  37. deepagents_code/client/launch/__init__.py +1 -0
  38. deepagents_code/client/launch/server.py +978 -0
  39. deepagents_code/client/launch/server_manager.py +540 -0
  40. deepagents_code/client/non_interactive.py +1758 -0
  41. deepagents_code/client/remote_client.py +794 -0
  42. deepagents_code/clipboard.py +217 -0
  43. deepagents_code/command_registry.py +450 -0
  44. deepagents_code/config.py +4829 -0
  45. deepagents_code/config_manifest.py +1451 -0
  46. deepagents_code/configurable_model.py +577 -0
  47. deepagents_code/default_agent_prompt.md +12 -0
  48. deepagents_code/doctor.py +559 -0
  49. deepagents_code/editor.py +142 -0
  50. deepagents_code/event_bus.py +411 -0
  51. deepagents_code/extras_info.py +661 -0
  52. deepagents_code/file_ops.py +576 -0
  53. deepagents_code/formatting.py +135 -0
  54. deepagents_code/goal_rubric.py +497 -0
  55. deepagents_code/goal_tools.py +459 -0
  56. deepagents_code/hooks.py +348 -0
  57. deepagents_code/input.py +1041 -0
  58. deepagents_code/integrations/__init__.py +1 -0
  59. deepagents_code/integrations/openai_codex.py +551 -0
  60. deepagents_code/integrations/sandbox_config.py +198 -0
  61. deepagents_code/integrations/sandbox_factory.py +1124 -0
  62. deepagents_code/integrations/sandbox_provider.py +137 -0
  63. deepagents_code/integrations/sandbox_registry.py +350 -0
  64. deepagents_code/iterm_cursor_guide.py +176 -0
  65. deepagents_code/local_context.py +926 -0
  66. deepagents_code/main.py +3985 -0
  67. deepagents_code/managed_tools.py +642 -0
  68. deepagents_code/mcp_auth.py +1950 -0
  69. deepagents_code/mcp_config.py +176 -0
  70. deepagents_code/mcp_disabled.py +212 -0
  71. deepagents_code/mcp_login_service.py +281 -0
  72. deepagents_code/mcp_oauth_ui.py +199 -0
  73. deepagents_code/mcp_providers/__init__.py +23 -0
  74. deepagents_code/mcp_providers/_registry.py +39 -0
  75. deepagents_code/mcp_providers/base.py +133 -0
  76. deepagents_code/mcp_providers/github.py +102 -0
  77. deepagents_code/mcp_providers/slack.py +175 -0
  78. deepagents_code/mcp_tools.py +2427 -0
  79. deepagents_code/mcp_trust.py +207 -0
  80. deepagents_code/media_utils.py +826 -0
  81. deepagents_code/memory_guard.py +474 -0
  82. deepagents_code/model_config.py +4156 -0
  83. deepagents_code/notifications.py +247 -0
  84. deepagents_code/offload.py +402 -0
  85. deepagents_code/onboarding.py +223 -0
  86. deepagents_code/output.py +69 -0
  87. deepagents_code/paste_collapse.py +103 -0
  88. deepagents_code/project_utils.py +231 -0
  89. deepagents_code/py.typed +0 -0
  90. deepagents_code/reasoning_effort.py +641 -0
  91. deepagents_code/reliable_rubric.py +97 -0
  92. deepagents_code/resume_state.py +237 -0
  93. deepagents_code/server_graph.py +310 -0
  94. deepagents_code/session_end_summary.py +329 -0
  95. deepagents_code/sessions.py +1716 -0
  96. deepagents_code/skills/__init__.py +18 -0
  97. deepagents_code/skills/commands.py +1252 -0
  98. deepagents_code/skills/invocation.py +112 -0
  99. deepagents_code/skills/load.py +196 -0
  100. deepagents_code/skills/trust.py +547 -0
  101. deepagents_code/state_migration.py +136 -0
  102. deepagents_code/subagents.py +278 -0
  103. deepagents_code/system_prompt.md +204 -0
  104. deepagents_code/terminal_capabilities.py +115 -0
  105. deepagents_code/terminal_escape.py +287 -0
  106. deepagents_code/theme.py +891 -0
  107. deepagents_code/todo_list_prompt.md +12 -0
  108. deepagents_code/tool_catalog.py +509 -0
  109. deepagents_code/tool_display.py +367 -0
  110. deepagents_code/tools.py +516 -0
  111. deepagents_code/tui/__init__.py +1 -0
  112. deepagents_code/tui/textual_adapter.py +2553 -0
  113. deepagents_code/tui/widgets/__init__.py +9 -0
  114. deepagents_code/tui/widgets/_js_eval_display.py +139 -0
  115. deepagents_code/tui/widgets/_links.py +261 -0
  116. deepagents_code/tui/widgets/agent_selector.py +420 -0
  117. deepagents_code/tui/widgets/approval.py +602 -0
  118. deepagents_code/tui/widgets/ask_user.py +515 -0
  119. deepagents_code/tui/widgets/auth.py +1997 -0
  120. deepagents_code/tui/widgets/autocomplete.py +890 -0
  121. deepagents_code/tui/widgets/chat_input.py +3181 -0
  122. deepagents_code/tui/widgets/codex_auth.py +452 -0
  123. deepagents_code/tui/widgets/cwd_switch.py +242 -0
  124. deepagents_code/tui/widgets/debug_console.py +868 -0
  125. deepagents_code/tui/widgets/diff.py +252 -0
  126. deepagents_code/tui/widgets/effort_selector.py +189 -0
  127. deepagents_code/tui/widgets/goal_review.py +390 -0
  128. deepagents_code/tui/widgets/goal_status.py +50 -0
  129. deepagents_code/tui/widgets/history.py +194 -0
  130. deepagents_code/tui/widgets/install_confirm.py +248 -0
  131. deepagents_code/tui/widgets/launch_init.py +482 -0
  132. deepagents_code/tui/widgets/loading.py +227 -0
  133. deepagents_code/tui/widgets/mcp_login.py +539 -0
  134. deepagents_code/tui/widgets/mcp_reconnect.py +212 -0
  135. deepagents_code/tui/widgets/mcp_viewer.py +1634 -0
  136. deepagents_code/tui/widgets/message_store.py +999 -0
  137. deepagents_code/tui/widgets/messages.py +3846 -0
  138. deepagents_code/tui/widgets/model_selector.py +2343 -0
  139. deepagents_code/tui/widgets/notification_center.py +456 -0
  140. deepagents_code/tui/widgets/notification_detail.py +257 -0
  141. deepagents_code/tui/widgets/notification_settings.py +170 -0
  142. deepagents_code/tui/widgets/restart_prompt.py +158 -0
  143. deepagents_code/tui/widgets/skill_trust.py +131 -0
  144. deepagents_code/tui/widgets/startup_tip.py +91 -0
  145. deepagents_code/tui/widgets/status.py +781 -0
  146. deepagents_code/tui/widgets/subagent_panel.py +969 -0
  147. deepagents_code/tui/widgets/theme_selector.py +401 -0
  148. deepagents_code/tui/widgets/thread_selector.py +2564 -0
  149. deepagents_code/tui/widgets/tool_renderers.py +190 -0
  150. deepagents_code/tui/widgets/tool_widgets.py +304 -0
  151. deepagents_code/tui/widgets/update_available.py +311 -0
  152. deepagents_code/tui/widgets/update_confirm.py +184 -0
  153. deepagents_code/tui/widgets/update_progress.py +327 -0
  154. deepagents_code/tui/widgets/welcome.py +508 -0
  155. deepagents_code/turn_end_summary.py +522 -0
  156. deepagents_code/ui.py +858 -0
  157. deepagents_code/unicode_security.py +563 -0
  158. deepagents_code/update_check.py +3124 -0
  159. zjcode-0.0.1.data/data/deepagents_code/default_agent_prompt.md +12 -0
  160. zjcode-0.0.1.dist-info/METADATA +220 -0
  161. zjcode-0.0.1.dist-info/RECORD +163 -0
  162. zjcode-0.0.1.dist-info/WHEEL +4 -0
  163. zjcode-0.0.1.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,516 @@
1
+ """Custom tools for the agent."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import ipaddress
7
+ import logging
8
+ import socket
9
+ import threading
10
+ from html.parser import HTMLParser
11
+ from typing import TYPE_CHECKING, Any, Literal
12
+ from urllib.parse import urljoin, urlparse
13
+
14
+ from langchain_core.runnables import RunnableConfig # noqa: TC002 # runtime hint
15
+ from langchain_core.tools import tool
16
+
17
+ if TYPE_CHECKING:
18
+ from collections.abc import Callable, Iterator
19
+
20
+ from tavily import TavilyClient
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ _UNSET = object()
25
+ _tavily_client: TavilyClient | object | None = _UNSET
26
+
27
+ _ALLOWED_URL_SCHEMES = frozenset({"http", "https"})
28
+ _MAX_FETCH_REDIRECTS = 5
29
+
30
+ # Maintainer note: `deepagents-talon` imports `web_search` and `fetch_url`
31
+ # directly from this module. Keep their names, signatures, and return/error dict
32
+ # shapes stable unless `deepagents-talon` is migrated in the same change.
33
+
34
+ # Module-level lock guarding the urllib3 connection-factory monkeypatch used by
35
+ # `_pinned_dns`. The patch is process-global, so serializing fetches keeps
36
+ # concurrent calls from clobbering each other's pinned IP set.
37
+ _dns_pin_lock = threading.Lock()
38
+
39
+
40
+ class _UrlValidationError(ValueError):
41
+ """Raised by `_validate_url` for scheme/DNS/SSRF-blocked URLs.
42
+
43
+ Distinguishes intentional SSRF-guard rejections from incidental
44
+ `ValueError`s raised elsewhere in the fetch path (e.g., markdown
45
+ conversion).
46
+ """
47
+
48
+
49
+ def _is_blocked_ip(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
50
+ """Return True if `ip` belongs to a non-publicly-routable range.
51
+
52
+ Rejects: private (RFC1918/ULA), loopback, link-local (including cloud
53
+ IMDS at `169.254.169.254`), reserved, multicast, unspecified
54
+ (`0.0.0.0`/`::`), and anything `ipaddress` does not consider globally
55
+ routable (catches benchmarking, documentation, and similar ranges the
56
+ explicit predicates miss).
57
+
58
+ IPv4-mapped IPv6 (`::ffff:a.b.c.d`) and 6to4 (`2002::/16`) are unwrapped
59
+ to their underlying IPv4 address before the checks so that private
60
+ space tunneled inside an IPv6 wrapper is still caught — e.g.,
61
+ `::ffff:127.0.0.1` and `2002:a9fe:a9fe::1` (6to4 over IMDS) both
62
+ evaluate as blocked.
63
+ """
64
+ if isinstance(ip, ipaddress.IPv6Address):
65
+ if ip.ipv4_mapped is not None:
66
+ ip = ip.ipv4_mapped
67
+ elif ip.sixtofour is not None:
68
+ ip = ip.sixtofour
69
+ return (
70
+ not ip.is_global
71
+ or ip.is_private
72
+ or ip.is_loopback
73
+ or ip.is_link_local
74
+ or ip.is_reserved
75
+ or ip.is_multicast
76
+ or ip.is_unspecified
77
+ )
78
+
79
+
80
+ def _validate_url(url: str) -> list[str]:
81
+ """Reject URLs that target private/internal/metadata addresses.
82
+
83
+ Resolves the URL's hostname and rejects any URL whose hostname resolves
84
+ to a private, loopback, link-local (includes cloud IMDS at
85
+ `169.254.169.254`), reserved, multicast, or unspecified IP — including
86
+ such addresses wrapped in IPv4-mapped IPv6 (`::ffff:...`) or 6to4
87
+ (`2002::/16`). This is the SSRF guard required because the URL is
88
+ supplied by an LLM agent and may originate from prompt-injected content.
89
+
90
+ Note:
91
+ This function resolves DNS once. The HTTP client must be pinned to
92
+ the returned IP list (see `_pinned_dns`) to close the TOCTOU window
93
+ against attacker-controlled DNS (rebinding).
94
+
95
+ Args:
96
+ url: Candidate URL to validate.
97
+
98
+ Returns:
99
+ The list of validated IP strings the hostname resolves to.
100
+
101
+ Callers should pin the outgoing connection to one of these IPs.
102
+
103
+ Raises:
104
+ _UrlValidationError: If the URL is malformed, uses a disallowed
105
+ scheme, fails DNS resolution, or resolves to a blocked address.
106
+ """
107
+ parsed = urlparse(url)
108
+ if parsed.scheme not in _ALLOWED_URL_SCHEMES:
109
+ msg = f"URL scheme not allowed: {parsed.scheme!r} (must be http or https)"
110
+ raise _UrlValidationError(msg)
111
+
112
+ hostname = parsed.hostname
113
+ if not hostname:
114
+ msg = "URL is missing a hostname"
115
+ raise _UrlValidationError(msg)
116
+
117
+ try:
118
+ encoded_hostname = hostname.encode("idna").decode("ascii")
119
+ except UnicodeError as exc:
120
+ msg = f"Could not encode hostname {hostname!r} as IDNA: {exc}"
121
+ raise _UrlValidationError(msg) from exc
122
+
123
+ try:
124
+ infos = socket.getaddrinfo(
125
+ encoded_hostname,
126
+ None,
127
+ type=socket.SOCK_STREAM,
128
+ proto=socket.IPPROTO_TCP,
129
+ )
130
+ except socket.gaierror as exc:
131
+ msg = f"Could not resolve hostname {hostname!r}: {exc}"
132
+ raise _UrlValidationError(msg) from exc
133
+
134
+ validated_ips: list[str] = []
135
+ for info in infos:
136
+ # `sockaddr[0]` may include an IPv6 scope id (`fe80::1%eth0`); strip
137
+ # it before parsing so `ipaddress.ip_address` never raises.
138
+ raw_ip = str(info[4][0]).split("%", 1)[0]
139
+ ip = ipaddress.ip_address(raw_ip)
140
+ if _is_blocked_ip(ip):
141
+ logger.warning(
142
+ "SSRF guard blocked URL %r: hostname %r resolves to %s",
143
+ url,
144
+ hostname,
145
+ ip,
146
+ )
147
+ msg = (
148
+ f"URL hostname {hostname!r} resolves to blocked address {ip} "
149
+ "(private, loopback, link-local, reserved, or non-global range)"
150
+ )
151
+ raise _UrlValidationError(msg)
152
+ validated_ips.append(raw_ip)
153
+
154
+ if not validated_ips:
155
+ msg = f"Hostname {hostname!r} resolved to no addresses"
156
+ raise _UrlValidationError(msg)
157
+
158
+ return validated_ips
159
+
160
+
161
+ @contextlib.contextmanager
162
+ def _pinned_dns(hostname: str, allowed_ips: list[str]) -> Iterator[None]:
163
+ """Force outgoing urllib3 connections for `hostname` to use `allowed_ips`.
164
+
165
+ Patches `urllib3.util.connection.create_connection` for the duration of
166
+ the context so that `requests` cannot re-resolve `hostname` to a
167
+ different IP than the one `_validate_url` vetted (defends against DNS
168
+ rebinding TOCTOU). The patch is process-global, so the module lock
169
+ serializes concurrent fetches.
170
+
171
+ Args:
172
+ hostname: The exact hostname (already IDNA-encoded by the caller)
173
+ whose resolution must be pinned.
174
+ allowed_ips: The IPs `_validate_url` confirmed are safe to connect
175
+ to. Tried in order; the first that accepts the connection wins.
176
+ """
177
+ from urllib3.util import connection as urllib3_connection
178
+
179
+ with _dns_pin_lock:
180
+ original = urllib3_connection.create_connection
181
+
182
+ def patched(
183
+ address: tuple[str, int], *args: Any, **kwargs: Any
184
+ ) -> socket.socket:
185
+ host, port = address[0], address[1]
186
+ if host != hostname:
187
+ return original(address, *args, **kwargs)
188
+ last_exc: OSError | None = None
189
+ for ip in allowed_ips:
190
+ try:
191
+ return original((ip, port), *args, **kwargs)
192
+ except OSError as exc:
193
+ last_exc = exc
194
+ assert last_exc is not None # noqa: S101 # loop body guarantees this
195
+ raise last_exc
196
+
197
+ urllib3_connection.create_connection = patched # ty: ignore[invalid-assignment] # signature matches at runtime
198
+ try:
199
+ yield
200
+ finally:
201
+ urllib3_connection.create_connection = original
202
+
203
+
204
+ class _TextExtractor(HTMLParser):
205
+ """Extract text content from HTML as a markdownify fallback.
206
+
207
+ The character data inside raw-text elements (`script`, `style`,
208
+ `noscript`, `template`) is skipped so the fallback never emits
209
+ JavaScript or CSS source from the fetched (untrusted) page as page
210
+ content.
211
+ """
212
+
213
+ # Tags whose character data is never page content. Suppressed via an
214
+ # explicit allowlist of skipped tags rather than trying to detect script
215
+ # payloads after the fact.
216
+ _SKIP_TAGS = frozenset({"script", "style", "noscript", "template"})
217
+
218
+ def __init__(self) -> None:
219
+ super().__init__(convert_charrefs=True)
220
+ self.parts: list[str] = []
221
+ self._skip_depth = 0
222
+
223
+ def handle_starttag(
224
+ self,
225
+ tag: str,
226
+ attrs: list[tuple[str, str | None]], # noqa: ARG002 # required by HTMLParser override
227
+ ) -> None:
228
+ """Enter a raw-text element so its data is skipped."""
229
+ if tag in self._SKIP_TAGS:
230
+ self._skip_depth += 1
231
+
232
+ def handle_endtag(self, tag: str) -> None:
233
+ """Leave a raw-text element."""
234
+ if tag in self._SKIP_TAGS and self._skip_depth:
235
+ self._skip_depth -= 1
236
+
237
+ def handle_data(self, data: str) -> None:
238
+ """Collect non-empty, whitespace-collapsed text outside skipped tags."""
239
+ if self._skip_depth:
240
+ return
241
+ text = " ".join(data.split())
242
+ if text:
243
+ self.parts.append(text)
244
+
245
+ def get_text(self) -> str:
246
+ """Return extracted text fragments separated by blank lines."""
247
+ return "\n\n".join(self.parts)
248
+
249
+
250
+ def _html_to_markdown_content(html: str, markdownify: Callable[[str], str]) -> str:
251
+ """Convert HTML to markdown, falling back to plain text on recursion.
252
+
253
+ Args:
254
+ html: Raw HTML to convert.
255
+ markdownify: The `markdownify.markdownify` callable, injected so this
256
+ module avoids an eager top-level import of the optional dependency.
257
+
258
+ Returns:
259
+ Markdown content, or text extracted from the HTML if markdown
260
+ conversion exceeds the recursion limit. Returns an empty string if
261
+ the text-extraction fallback itself fails.
262
+ """
263
+ try:
264
+ return markdownify(html)
265
+ except RecursionError:
266
+ logger.warning(
267
+ "markdownify hit recursion depth; falling back to text extraction",
268
+ exc_info=True,
269
+ )
270
+
271
+ # Best-effort plain-text extraction. Guard it so a failure here (e.g. the
272
+ # same pathological input that exhausted markdownify's recursion) cannot
273
+ # re-introduce the uncaught crash this fallback exists to prevent.
274
+ try:
275
+ parser = _TextExtractor()
276
+ parser.feed(html)
277
+ parser.close()
278
+ except Exception: # fallback is best-effort; must never propagate
279
+ logger.warning("text-extraction fallback failed", exc_info=True)
280
+ return ""
281
+ return parser.get_text()
282
+
283
+
284
+ def _get_tavily_client() -> TavilyClient | None:
285
+ """Get or initialize the lazy Tavily client singleton.
286
+
287
+ Returns:
288
+ TavilyClient instance, or None if API key is not configured.
289
+ """
290
+ global _tavily_client # noqa: PLW0603 # Module-level cache requires global statement
291
+ if _tavily_client is not _UNSET:
292
+ return _tavily_client # ty: ignore[invalid-return-type] # narrowed by sentinel check
293
+
294
+ from deepagents_code.config import settings
295
+
296
+ if settings.has_tavily:
297
+ from tavily import TavilyClient as _TavilyClient
298
+
299
+ _tavily_client = _TavilyClient(api_key=settings.tavily_api_key)
300
+ else:
301
+ _tavily_client = None
302
+ return _tavily_client
303
+
304
+
305
+ @tool
306
+ def get_current_thread_id(config: RunnableConfig) -> str:
307
+ """Get the current Deep Agents thread ID for LangSmith or MCP tooling.
308
+
309
+ Args:
310
+ config: Runtime config injected by LangChain.
311
+
312
+ Returns:
313
+ The current `configurable.thread_id`, or an explanatory message if missing.
314
+ """
315
+ thread_id = config.get("configurable", {}).get("thread_id")
316
+ if isinstance(thread_id, str) and thread_id:
317
+ return thread_id
318
+ return "No current thread ID is available."
319
+
320
+
321
+ def web_search( # noqa: ANN201 # Return type depends on dynamic tool configuration
322
+ query: str,
323
+ max_results: int = 5,
324
+ topic: Literal["general", "news", "finance"] = "general",
325
+ include_raw_content: bool = False,
326
+ ):
327
+ """Search the web using Tavily for current information and documentation.
328
+
329
+ This tool searches the web and returns relevant results. After receiving results,
330
+ you MUST synthesize the information into a natural, helpful response for the user.
331
+
332
+ Args:
333
+ query: The search query (be specific and detailed)
334
+ max_results: Number of results to return (default: 5)
335
+ topic: Search topic type - "general" for most queries, "news" for current events
336
+ include_raw_content: Include full page content (warning: uses more tokens)
337
+
338
+ Returns:
339
+ Dictionary containing:
340
+ - results: List of search results, each with:
341
+ - title: Page title
342
+ - url: Page URL
343
+ - content: Relevant excerpt from the page
344
+ - score: Relevance score (0-1)
345
+ - query: The original search query
346
+
347
+ IMPORTANT: After using this tool:
348
+ 1. Read through the 'content' field of each result
349
+ 2. Extract relevant information that answers the user's question
350
+ 3. Synthesize this into a clear, natural language response
351
+ 4. Cite sources by mentioning the page titles or URLs
352
+ 5. NEVER show the raw JSON to the user - always provide a formatted response
353
+ """
354
+ try:
355
+ import requests
356
+ from tavily import (
357
+ BadRequestError,
358
+ InvalidAPIKeyError,
359
+ MissingAPIKeyError,
360
+ UsageLimitExceededError,
361
+ )
362
+ from tavily.errors import ForbiddenError, TimeoutError as TavilyTimeoutError
363
+ except ImportError as exc:
364
+ return {"error": f"Required package not installed: {exc.name}."}
365
+
366
+ client = _get_tavily_client()
367
+ if client is None:
368
+ return {
369
+ "error": "Tavily API key not configured. "
370
+ "Please set TAVILY_API_KEY environment variable.",
371
+ "query": query,
372
+ }
373
+
374
+ try:
375
+ return client.search(
376
+ query,
377
+ max_results=max_results,
378
+ include_raw_content=include_raw_content,
379
+ topic=topic,
380
+ )
381
+ except (
382
+ requests.exceptions.RequestException,
383
+ ValueError,
384
+ TypeError,
385
+ # Tavily-specific exceptions
386
+ BadRequestError,
387
+ ForbiddenError,
388
+ InvalidAPIKeyError,
389
+ MissingAPIKeyError,
390
+ TavilyTimeoutError,
391
+ UsageLimitExceededError,
392
+ ) as e:
393
+ return {"error": f"Web search error: {e!s}", "query": query}
394
+
395
+
396
+ def fetch_url(url: str, timeout: int = 30) -> dict[str, Any]:
397
+ """Fetch content from a URL and convert HTML to markdown format.
398
+
399
+ This tool fetches web page content and converts it to clean markdown text,
400
+ making it easy to read and process HTML content. After receiving the markdown,
401
+ you MUST synthesize the information into a natural, helpful response for the user.
402
+
403
+ Args:
404
+ url: The URL to fetch (must be a valid HTTP/HTTPS URL)
405
+ timeout: Request timeout in seconds (default: 30)
406
+
407
+ Returns:
408
+ Dictionary containing:
409
+ - success: Whether the request succeeded
410
+ - url: The final URL after redirects
411
+ - markdown_content: The page content converted to markdown
412
+ - status_code: HTTP status code
413
+ - content_length: Length of the markdown content in characters
414
+
415
+ IMPORTANT: After using this tool:
416
+ 1. Read through the markdown content
417
+ 2. Extract relevant information that answers the user's question
418
+ 3. Synthesize this into a clear, natural language response
419
+ 4. NEVER show the raw markdown to the user unless specifically requested
420
+ """
421
+ try:
422
+ import requests
423
+ from markdownify import markdownify
424
+ except ImportError as exc:
425
+ return {"error": f"Required package not installed: {exc.name}."}
426
+
427
+ try:
428
+ response = _fetch_with_redirects(url, timeout=timeout)
429
+ except _UrlValidationError as e:
430
+ return {
431
+ "error": f"Fetch URL error: {e!s}",
432
+ "url": url,
433
+ "category": "validation",
434
+ }
435
+ except requests.exceptions.TooManyRedirects as e:
436
+ return {"error": f"Fetch URL error: {e!s}", "url": url, "category": "redirects"}
437
+ except requests.exceptions.RequestException as e:
438
+ return {"error": f"Fetch URL error: {e!s}", "url": url, "category": "network"}
439
+
440
+ markdown_content = _html_to_markdown_content(response.text, markdownify)
441
+ if not markdown_content:
442
+ logger.warning(
443
+ "fetch_url produced empty content for %s (status %s)",
444
+ response.url,
445
+ response.status_code,
446
+ )
447
+ return {
448
+ "url": str(response.url),
449
+ "markdown_content": markdown_content,
450
+ "status_code": response.status_code,
451
+ "content_length": len(markdown_content),
452
+ }
453
+
454
+
455
+ def _fetch_with_redirects(url: str, *, timeout: int) -> Any: # noqa: ANN401 # requests.Response, but kept dynamic to avoid eager import
456
+ """Fetch `url`, re-validating each redirect hop against the SSRF guard.
457
+
458
+ Each hop is validated by `_validate_url` and its connection pinned to
459
+ the validated IP via `_pinned_dns`. Caps at `_MAX_FETCH_REDIRECTS`
460
+ redirects (so up to `_MAX_FETCH_REDIRECTS + 1` total hops counting the
461
+ initial request). Network/HTTP errors propagate as
462
+ `requests.exceptions.RequestException` (or its subclasses).
463
+
464
+ Args:
465
+ url: Initial URL to fetch.
466
+ timeout: Per-request timeout in seconds.
467
+
468
+ Returns:
469
+ The final `requests.Response` for the non-redirect terminal hop.
470
+
471
+ Raises:
472
+ _UrlValidationError: If any hop fails SSRF validation or returns a
473
+ 3xx without a `Location` header.
474
+ requests.exceptions.TooManyRedirects: If the redirect cap is exceeded.
475
+ """
476
+ import requests
477
+
478
+ current_url = url
479
+ session = requests.Session()
480
+ # DNS pinning only protects the direct target connection. Environment
481
+ # proxies resolve the target separately, so they must be disabled here.
482
+ session.trust_env = False
483
+ for _hop in range(_MAX_FETCH_REDIRECTS + 1):
484
+ validated_ips = _validate_url(current_url)
485
+ hostname = urlparse(current_url).hostname
486
+ # `_validate_url` raises if hostname is missing, so this is non-None.
487
+ assert hostname is not None # noqa: S101 # invariant from _validate_url
488
+ encoded_hostname = hostname.encode("idna").decode("ascii")
489
+
490
+ with _pinned_dns(encoded_hostname, validated_ips):
491
+ response = session.get(
492
+ current_url,
493
+ timeout=timeout,
494
+ headers={"User-Agent": "Mozilla/5.0 (compatible; DeepAgents/1.0)"},
495
+ allow_redirects=False,
496
+ )
497
+
498
+ # 300-399 covers every redirect class. `requests.Response.is_redirect`
499
+ # also checks for a `Location` header, which would hide malformed 3xx
500
+ # responses — so we check the raw status code instead.
501
+ if 300 <= response.status_code < 400: # noqa: PLR2004 # HTTP redirect class
502
+ location = response.headers.get("Location")
503
+ if not location:
504
+ msg = (
505
+ f"Redirect response (status {response.status_code}) at "
506
+ f"{current_url!r} is missing a Location header"
507
+ )
508
+ raise _UrlValidationError(msg)
509
+ current_url = urljoin(current_url, location)
510
+ continue
511
+
512
+ response.raise_for_status()
513
+ return response
514
+
515
+ msg = f"Exceeded {_MAX_FETCH_REDIRECTS} redirects starting from {url!r}"
516
+ raise requests.exceptions.TooManyRedirects(msg)
@@ -0,0 +1 @@
1
+ """Textual user interface package for `deepagents-code`."""