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.
- deepagents_code/__init__.py +42 -0
- deepagents_code/__main__.py +6 -0
- deepagents_code/_ask_user_types.py +90 -0
- deepagents_code/_cli_context.py +96 -0
- deepagents_code/_constants.py +41 -0
- deepagents_code/_debug.py +148 -0
- deepagents_code/_debug_buffer.py +204 -0
- deepagents_code/_env_vars.py +411 -0
- deepagents_code/_fake_models.py +66 -0
- deepagents_code/_git.py +521 -0
- deepagents_code/_paths.py +69 -0
- deepagents_code/_server_config.py +576 -0
- deepagents_code/_session_stats.py +235 -0
- deepagents_code/_startup_error.py +45 -0
- deepagents_code/_testing_models.py +305 -0
- deepagents_code/_textual_patches.py +420 -0
- deepagents_code/_tool_stream.py +694 -0
- deepagents_code/_version.py +46 -0
- deepagents_code/agent.py +1976 -0
- deepagents_code/app.py +19239 -0
- deepagents_code/app.tcss +438 -0
- deepagents_code/approval_mode.py +131 -0
- deepagents_code/ask_user.py +306 -0
- deepagents_code/auth_display.py +185 -0
- deepagents_code/auth_store.py +545 -0
- deepagents_code/built_in_skills/__init__.py +5 -0
- deepagents_code/built_in_skills/remember/SKILL.md +118 -0
- deepagents_code/built_in_skills/skill-creator/SKILL.md +383 -0
- deepagents_code/built_in_skills/skill-creator/scripts/init_skill.py +366 -0
- deepagents_code/built_in_skills/skill-creator/scripts/quick_validate.py +158 -0
- deepagents_code/client/__init__.py +1 -0
- deepagents_code/client/commands/__init__.py +1 -0
- deepagents_code/client/commands/auth.py +541 -0
- deepagents_code/client/commands/config.py +714 -0
- deepagents_code/client/commands/mcp.py +250 -0
- deepagents_code/client/commands/tools.py +416 -0
- deepagents_code/client/launch/__init__.py +1 -0
- deepagents_code/client/launch/server.py +978 -0
- deepagents_code/client/launch/server_manager.py +540 -0
- deepagents_code/client/non_interactive.py +1758 -0
- deepagents_code/client/remote_client.py +794 -0
- deepagents_code/clipboard.py +217 -0
- deepagents_code/command_registry.py +450 -0
- deepagents_code/config.py +4829 -0
- deepagents_code/config_manifest.py +1451 -0
- deepagents_code/configurable_model.py +577 -0
- deepagents_code/default_agent_prompt.md +12 -0
- deepagents_code/doctor.py +559 -0
- deepagents_code/editor.py +142 -0
- deepagents_code/event_bus.py +411 -0
- deepagents_code/extras_info.py +661 -0
- deepagents_code/file_ops.py +576 -0
- deepagents_code/formatting.py +135 -0
- deepagents_code/goal_rubric.py +497 -0
- deepagents_code/goal_tools.py +459 -0
- deepagents_code/hooks.py +348 -0
- deepagents_code/input.py +1041 -0
- deepagents_code/integrations/__init__.py +1 -0
- deepagents_code/integrations/openai_codex.py +551 -0
- deepagents_code/integrations/sandbox_config.py +198 -0
- deepagents_code/integrations/sandbox_factory.py +1124 -0
- deepagents_code/integrations/sandbox_provider.py +137 -0
- deepagents_code/integrations/sandbox_registry.py +350 -0
- deepagents_code/iterm_cursor_guide.py +176 -0
- deepagents_code/local_context.py +926 -0
- deepagents_code/main.py +3985 -0
- deepagents_code/managed_tools.py +642 -0
- deepagents_code/mcp_auth.py +1950 -0
- deepagents_code/mcp_config.py +176 -0
- deepagents_code/mcp_disabled.py +212 -0
- deepagents_code/mcp_login_service.py +281 -0
- deepagents_code/mcp_oauth_ui.py +199 -0
- deepagents_code/mcp_providers/__init__.py +23 -0
- deepagents_code/mcp_providers/_registry.py +39 -0
- deepagents_code/mcp_providers/base.py +133 -0
- deepagents_code/mcp_providers/github.py +102 -0
- deepagents_code/mcp_providers/slack.py +175 -0
- deepagents_code/mcp_tools.py +2427 -0
- deepagents_code/mcp_trust.py +207 -0
- deepagents_code/media_utils.py +826 -0
- deepagents_code/memory_guard.py +474 -0
- deepagents_code/model_config.py +4156 -0
- deepagents_code/notifications.py +247 -0
- deepagents_code/offload.py +402 -0
- deepagents_code/onboarding.py +223 -0
- deepagents_code/output.py +69 -0
- deepagents_code/paste_collapse.py +103 -0
- deepagents_code/project_utils.py +231 -0
- deepagents_code/py.typed +0 -0
- deepagents_code/reasoning_effort.py +641 -0
- deepagents_code/reliable_rubric.py +97 -0
- deepagents_code/resume_state.py +237 -0
- deepagents_code/server_graph.py +310 -0
- deepagents_code/session_end_summary.py +329 -0
- deepagents_code/sessions.py +1716 -0
- deepagents_code/skills/__init__.py +18 -0
- deepagents_code/skills/commands.py +1252 -0
- deepagents_code/skills/invocation.py +112 -0
- deepagents_code/skills/load.py +196 -0
- deepagents_code/skills/trust.py +547 -0
- deepagents_code/state_migration.py +136 -0
- deepagents_code/subagents.py +278 -0
- deepagents_code/system_prompt.md +204 -0
- deepagents_code/terminal_capabilities.py +115 -0
- deepagents_code/terminal_escape.py +287 -0
- deepagents_code/theme.py +891 -0
- deepagents_code/todo_list_prompt.md +12 -0
- deepagents_code/tool_catalog.py +509 -0
- deepagents_code/tool_display.py +367 -0
- deepagents_code/tools.py +516 -0
- deepagents_code/tui/__init__.py +1 -0
- deepagents_code/tui/textual_adapter.py +2553 -0
- deepagents_code/tui/widgets/__init__.py +9 -0
- deepagents_code/tui/widgets/_js_eval_display.py +139 -0
- deepagents_code/tui/widgets/_links.py +261 -0
- deepagents_code/tui/widgets/agent_selector.py +420 -0
- deepagents_code/tui/widgets/approval.py +602 -0
- deepagents_code/tui/widgets/ask_user.py +515 -0
- deepagents_code/tui/widgets/auth.py +1997 -0
- deepagents_code/tui/widgets/autocomplete.py +890 -0
- deepagents_code/tui/widgets/chat_input.py +3181 -0
- deepagents_code/tui/widgets/codex_auth.py +452 -0
- deepagents_code/tui/widgets/cwd_switch.py +242 -0
- deepagents_code/tui/widgets/debug_console.py +868 -0
- deepagents_code/tui/widgets/diff.py +252 -0
- deepagents_code/tui/widgets/effort_selector.py +189 -0
- deepagents_code/tui/widgets/goal_review.py +390 -0
- deepagents_code/tui/widgets/goal_status.py +50 -0
- deepagents_code/tui/widgets/history.py +194 -0
- deepagents_code/tui/widgets/install_confirm.py +248 -0
- deepagents_code/tui/widgets/launch_init.py +482 -0
- deepagents_code/tui/widgets/loading.py +227 -0
- deepagents_code/tui/widgets/mcp_login.py +539 -0
- deepagents_code/tui/widgets/mcp_reconnect.py +212 -0
- deepagents_code/tui/widgets/mcp_viewer.py +1634 -0
- deepagents_code/tui/widgets/message_store.py +999 -0
- deepagents_code/tui/widgets/messages.py +3846 -0
- deepagents_code/tui/widgets/model_selector.py +2343 -0
- deepagents_code/tui/widgets/notification_center.py +456 -0
- deepagents_code/tui/widgets/notification_detail.py +257 -0
- deepagents_code/tui/widgets/notification_settings.py +170 -0
- deepagents_code/tui/widgets/restart_prompt.py +158 -0
- deepagents_code/tui/widgets/skill_trust.py +131 -0
- deepagents_code/tui/widgets/startup_tip.py +91 -0
- deepagents_code/tui/widgets/status.py +781 -0
- deepagents_code/tui/widgets/subagent_panel.py +969 -0
- deepagents_code/tui/widgets/theme_selector.py +401 -0
- deepagents_code/tui/widgets/thread_selector.py +2564 -0
- deepagents_code/tui/widgets/tool_renderers.py +190 -0
- deepagents_code/tui/widgets/tool_widgets.py +304 -0
- deepagents_code/tui/widgets/update_available.py +311 -0
- deepagents_code/tui/widgets/update_confirm.py +184 -0
- deepagents_code/tui/widgets/update_progress.py +327 -0
- deepagents_code/tui/widgets/welcome.py +508 -0
- deepagents_code/turn_end_summary.py +522 -0
- deepagents_code/ui.py +858 -0
- deepagents_code/unicode_security.py +563 -0
- deepagents_code/update_check.py +3124 -0
- zjcode-0.0.1.data/data/deepagents_code/default_agent_prompt.md +12 -0
- zjcode-0.0.1.dist-info/METADATA +220 -0
- zjcode-0.0.1.dist-info/RECORD +163 -0
- zjcode-0.0.1.dist-info/WHEEL +4 -0
- zjcode-0.0.1.dist-info/entry_points.txt +2 -0
deepagents_code/tools.py
ADDED
|
@@ -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`."""
|