langchain-agentsearch 0.1.0__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 AgentSearch / Angel Mendez
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,80 @@
1
+ Metadata-Version: 2.5
2
+ Name: langchain-agentsearch
3
+ Version: 0.1.0
4
+ Summary: LangChain tools for AgentSearch: free live web search, plus pay-per-call extract and JS render ($0.005 USDC on Base via x402)
5
+ Project-URL: Homepage, https://agentsearchhq.com/?utm_source=langchain-agentsearch&utm_medium=package
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Keywords: agents,agentsearch,langchain,tools,web-search,x402
9
+ Requires-Python: >=3.9
10
+ Requires-Dist: httpx>=0.25
11
+ Requires-Dist: langchain-core<2.0.0,>=0.3.0
12
+ Requires-Dist: pydantic>=2
13
+ Provides-Extra: paid
14
+ Requires-Dist: x402[evm]>=2.0; extra == 'paid'
15
+ Provides-Extra: test
16
+ Requires-Dist: pytest>=7; extra == 'test'
17
+ Description-Content-Type: text/markdown
18
+
19
+ # langchain-agentsearch
20
+
21
+ LangChain (Python) tools for [AgentSearch](https://agentsearchhq.com/?utm_source=langchain-agentsearch&utm_medium=package): live web search for agents.
22
+
23
+ | Tool | Name | Cost |
24
+ |---|---|---|
25
+ | `AgentSearchTool` | `agentsearch_search` | **Free** (25 searches/day per user, up to 5 results, no key). Paid & unlimited if a wallet is set. |
26
+ | `AgentSearchExtractTool` | `agentsearch_extract` | $0.005/call (USDC on Base via x402) |
27
+ | `AgentSearchRenderTool` | `agentsearch_render` | $0.005/call (headless browser for JS pages) |
28
+
29
+ ## Install
30
+
31
+ ```bash
32
+ pip install -U langchain-agentsearch # free search
33
+ pip install -U "langchain-agentsearch[paid]" # + x402 payments for extract/render
34
+ ```
35
+
36
+ ## Quick start (free, no key)
37
+
38
+ ```python
39
+ from langchain_agentsearch import AgentSearchTool
40
+
41
+ search = AgentSearchTool()
42
+ print(search.invoke({"query": "latest LangChain release", "max_results": 3}))
43
+ # JSON string: {"query": ..., "results": [{"title", "url", "content"}, ...]}
44
+ ```
45
+
46
+ With an agent:
47
+
48
+ ```python
49
+ from langgraph.prebuilt import create_react_agent
50
+ from langchain_agentsearch import AgentSearchTool, AgentSearchExtractTool, AgentSearchRenderTool
51
+
52
+ agent = create_react_agent(llm, [AgentSearchTool(), AgentSearchExtractTool(), AgentSearchRenderTool()])
53
+ ```
54
+
55
+ ## Paid mode
56
+
57
+ Install the `paid` extra and set `AGENTSEARCH_WALLET_PRIVATE_KEY` to the private key of a **new wallet made only for this**, funded with a little USDC on Base (e.g. $1; no ETH needed). Each call is paid over x402 through the Pocket Network Agentic Portal using the official x402 Python SDK. With a key set, `AgentSearchTool` also goes paid; force free with `AgentSearchTool(mode="free")`.
58
+
59
+ | Env var | Default |
60
+ |---|---|
61
+ | `AGENTSEARCH_MAX_TOTAL_ATOMIC` | `1000000` ($1.00 per process) |
62
+ | `AGENTSEARCH_MAX_PER_CALL_ATOMIC` | `5000` ($0.005; can only be lowered) |
63
+ | `AGENTSEARCH_TIMEOUT_MS` | `30000` |
64
+
65
+ Limits are checked before anything is signed. The key is never logged or returned. Paid results include a `payment` object.
66
+
67
+ ## Errors
68
+
69
+ Tools return `"Error: ..."` strings rather than raising. A 429 from the free relay means the daily limit was hit; the message explains how to upgrade.
70
+
71
+ ## Development
72
+
73
+ ```bash
74
+ pip install -e ".[test,paid]" && pytest # unit tests (mocked)
75
+ AGENTSEARCH_LIVE=1 pytest -k live # one real free search
76
+ ```
77
+
78
+ Docs: https://agentsearchhq.com/agents.md?utm_source=langchain-agentsearch&utm_medium=package
79
+
80
+ MIT
@@ -0,0 +1,62 @@
1
+ # langchain-agentsearch
2
+
3
+ LangChain (Python) tools for [AgentSearch](https://agentsearchhq.com/?utm_source=langchain-agentsearch&utm_medium=package): live web search for agents.
4
+
5
+ | Tool | Name | Cost |
6
+ |---|---|---|
7
+ | `AgentSearchTool` | `agentsearch_search` | **Free** (25 searches/day per user, up to 5 results, no key). Paid & unlimited if a wallet is set. |
8
+ | `AgentSearchExtractTool` | `agentsearch_extract` | $0.005/call (USDC on Base via x402) |
9
+ | `AgentSearchRenderTool` | `agentsearch_render` | $0.005/call (headless browser for JS pages) |
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ pip install -U langchain-agentsearch # free search
15
+ pip install -U "langchain-agentsearch[paid]" # + x402 payments for extract/render
16
+ ```
17
+
18
+ ## Quick start (free, no key)
19
+
20
+ ```python
21
+ from langchain_agentsearch import AgentSearchTool
22
+
23
+ search = AgentSearchTool()
24
+ print(search.invoke({"query": "latest LangChain release", "max_results": 3}))
25
+ # JSON string: {"query": ..., "results": [{"title", "url", "content"}, ...]}
26
+ ```
27
+
28
+ With an agent:
29
+
30
+ ```python
31
+ from langgraph.prebuilt import create_react_agent
32
+ from langchain_agentsearch import AgentSearchTool, AgentSearchExtractTool, AgentSearchRenderTool
33
+
34
+ agent = create_react_agent(llm, [AgentSearchTool(), AgentSearchExtractTool(), AgentSearchRenderTool()])
35
+ ```
36
+
37
+ ## Paid mode
38
+
39
+ Install the `paid` extra and set `AGENTSEARCH_WALLET_PRIVATE_KEY` to the private key of a **new wallet made only for this**, funded with a little USDC on Base (e.g. $1; no ETH needed). Each call is paid over x402 through the Pocket Network Agentic Portal using the official x402 Python SDK. With a key set, `AgentSearchTool` also goes paid; force free with `AgentSearchTool(mode="free")`.
40
+
41
+ | Env var | Default |
42
+ |---|---|
43
+ | `AGENTSEARCH_MAX_TOTAL_ATOMIC` | `1000000` ($1.00 per process) |
44
+ | `AGENTSEARCH_MAX_PER_CALL_ATOMIC` | `5000` ($0.005; can only be lowered) |
45
+ | `AGENTSEARCH_TIMEOUT_MS` | `30000` |
46
+
47
+ Limits are checked before anything is signed. The key is never logged or returned. Paid results include a `payment` object.
48
+
49
+ ## Errors
50
+
51
+ Tools return `"Error: ..."` strings rather than raising. A 429 from the free relay means the daily limit was hit; the message explains how to upgrade.
52
+
53
+ ## Development
54
+
55
+ ```bash
56
+ pip install -e ".[test,paid]" && pytest # unit tests (mocked)
57
+ AGENTSEARCH_LIVE=1 pytest -k live # one real free search
58
+ ```
59
+
60
+ Docs: https://agentsearchhq.com/agents.md?utm_source=langchain-agentsearch&utm_medium=package
61
+
62
+ MIT
@@ -0,0 +1,13 @@
1
+ """LangChain tools for AgentSearch."""
2
+ from importlib import metadata
3
+
4
+ from ._client import AgentSearchError, FREE_LIMIT_MESSAGE, reset_session_budget, session_spent_atomic
5
+ from .tools import AgentSearchExtractTool, AgentSearchRenderTool, AgentSearchTool
6
+
7
+ try:
8
+ __version__ = metadata.version(__package__ or "langchain_agentsearch")
9
+ except metadata.PackageNotFoundError: # pragma: no cover
10
+ __version__ = ""
11
+
12
+ __all__ = ["AgentSearchTool", "AgentSearchExtractTool", "AgentSearchRenderTool", "AgentSearchError",
13
+ "FREE_LIMIT_MESSAGE", "reset_session_budget", "session_spent_atomic", "__version__"]
@@ -0,0 +1,264 @@
1
+ """HTTP layer: free relay search and x402 pay-per-call via the Pocket Network Agentic Portal.
2
+
3
+ The wallet key is read from env, used only to sign, and never logged or returned.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import os
9
+ import re
10
+ import threading
11
+ from typing import Any, Optional
12
+
13
+ import httpx
14
+
15
+ FREE_RELAY_URL = "https://relay.agentsearchhq.com"
16
+ FREE_RELAY_DAILY_LIMIT = 25
17
+ DEFAULT_PORTAL_URL = "https://agent.pocket.network"
18
+ BASE_NETWORK = "eip155:8453"
19
+ USDC_BASE = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"
20
+ PORTAL_PAYEE = "0xF732ea490c5766071785a2310523f7fA2CEbB829"
21
+ MAX_PER_CALL_ATOMIC = 5000 # $0.005 hard ceiling
22
+ DEFAULT_SESSION_CAP_ATOMIC = 1_000_000 # $1.00
23
+ KEY_ENV_VARS = ("AGENTSEARCH_WALLET_PRIVATE_KEY", "POCKET_PRIVATE_KEY")
24
+ SERVICES = {
25
+ "search": ("agentsearch-web-search-v1", "/v1/search"),
26
+ "extract": ("agentsearch-web-extract-v1", "/v1/extract"),
27
+ "render": ("agentsearch-web-render-v1", "/v1/render"),
28
+ }
29
+ DOCS = "https://agentsearchhq.com/?utm_source=langchain-agentsearch&utm_medium=package"
30
+
31
+ FREE_LIMIT_MESSAGE = (
32
+ f"Free search limit reached ({FREE_RELAY_DAILY_LIMIT} searches per day per user; resets daily). "
33
+ "For unlimited search plus extract/render, set AGENTSEARCH_WALLET_PRIVATE_KEY to the key of a NEW wallet "
34
+ "funded with a little USDC on Base ($0.005 per call via x402, capped by AGENTSEARCH_MAX_TOTAL_ATOMIC, "
35
+ f"default $1.00) and install langchain-agentsearch[paid]. Docs: {DOCS}"
36
+ )
37
+
38
+
39
+ def paid_only_message(name: str) -> str:
40
+ return (
41
+ f"{name} is paid ($0.005 per call in USDC on Base via x402). No wallet is configured, so no call was made. "
42
+ "Set AGENTSEARCH_WALLET_PRIVATE_KEY to the key of a NEW wallet funded with a little USDC on Base and "
43
+ f"install langchain-agentsearch[paid]. Docs: {DOCS}"
44
+ )
45
+
46
+
47
+ class AgentSearchError(Exception):
48
+ def __init__(self, message: str, status: Optional[int] = None):
49
+ super().__init__(message)
50
+ self.status = status
51
+
52
+
53
+ def timeout_s() -> float:
54
+ try:
55
+ n = int(os.environ.get("AGENTSEARCH_TIMEOUT_MS", ""))
56
+ if 1000 <= n <= 300_000:
57
+ return n / 1000
58
+ except ValueError:
59
+ pass
60
+ return 30.0
61
+
62
+
63
+ def _strip_provider(d: Any) -> Any:
64
+ if isinstance(d, dict):
65
+ d.pop("provider", None)
66
+ return d
67
+
68
+
69
+ def free_search(query: str, max_results: Optional[int] = None, client: Optional[httpx.Client] = None) -> Any:
70
+ body: dict[str, Any] = {"query": query}
71
+ if max_results is not None:
72
+ body["max_results"] = max_results
73
+ c = client or httpx.Client(timeout=timeout_s())
74
+ try:
75
+ r = c.post(f"{FREE_RELAY_URL}/v1/search", json=body, headers={"accept": "application/json"})
76
+ finally:
77
+ if client is None:
78
+ c.close()
79
+ if r.status_code == 429:
80
+ raise AgentSearchError(FREE_LIMIT_MESSAGE, 429)
81
+ if r.status_code >= 400:
82
+ raise AgentSearchError(f"AgentSearch relay HTTP {r.status_code}: {r.text[:500]}", r.status_code)
83
+ try:
84
+ return _strip_provider(r.json())
85
+ except ValueError:
86
+ return {"raw": r.text}
87
+
88
+
89
+ # ---------- paid mode ----------
90
+
91
+ def has_wallet_key() -> bool:
92
+ return any((os.environ.get(n) or "").strip() for n in KEY_ENV_VARS)
93
+
94
+
95
+ def _read_wallet_key() -> Optional[str]:
96
+ for name in KEY_ENV_VARS:
97
+ raw = (os.environ.get(name) or "").strip()
98
+ if not raw:
99
+ continue
100
+ if not re.fullmatch(r"(0x)?[0-9a-fA-F]{64}", raw):
101
+ raise AgentSearchError(
102
+ f"{name} is set but is not a private key (expected 64 hex characters, optional 0x prefix). "
103
+ "The value is not shown. Nothing was signed."
104
+ )
105
+ return raw if raw.startswith("0x") else "0x" + raw
106
+ return None
107
+
108
+
109
+ def _atomic_env(names: tuple[str, ...], fallback: int) -> int:
110
+ for name in names:
111
+ raw = (os.environ.get(name) or "").strip()
112
+ if not raw:
113
+ continue
114
+ if not raw.isdigit():
115
+ raise AgentSearchError(f"{name} must be a whole number of USDC atomic units (1000000 = $1.00). Nothing was signed.")
116
+ return int(raw)
117
+ return fallback
118
+
119
+
120
+ def usd(atomic: int) -> str:
121
+ return f"${atomic // 1_000_000}.{atomic % 1_000_000:06d}"
122
+
123
+
124
+ class _Budget:
125
+ def __init__(self) -> None:
126
+ self.spent = 0
127
+ self.lock = threading.Lock()
128
+
129
+
130
+ _budget = _Budget()
131
+
132
+
133
+ def reset_session_budget() -> None:
134
+ global _budget
135
+ _budget = _Budget()
136
+
137
+
138
+ def session_spent_atomic() -> int:
139
+ return _budget.spent
140
+
141
+
142
+ def _eq(a: Any, b: str) -> bool:
143
+ return isinstance(a, str) and a.lower() == b.lower()
144
+
145
+
146
+ def select_accept(accepts: list[Any], ceiling: int) -> Any:
147
+ why = []
148
+ for a in accepts or []:
149
+ if a.scheme != "exact":
150
+ why.append(f"{a.scheme} scheme"); continue
151
+ if a.network != BASE_NETWORK:
152
+ why.append(f"network {a.network}"); continue
153
+ if not _eq(a.asset, USDC_BASE):
154
+ why.append(f"asset {a.asset} is not USDC on Base"); continue
155
+ if not _eq(a.pay_to, PORTAL_PAYEE):
156
+ why.append(f"payee {a.pay_to} is not the portal's"); continue
157
+ if not str(a.amount).isdigit() or int(a.amount) <= 0:
158
+ why.append(f"amount {a.amount}"); continue
159
+ if int(a.amount) > ceiling:
160
+ why.append(f"amount {a.amount} exceeds the cap of {ceiling} atomic units"); continue
161
+ return a
162
+ raise AgentSearchError(f"No acceptable payment option ({'; '.join(why) or 'none offered'}). Nothing was signed.")
163
+
164
+
165
+ def call_portal(op: str, body: dict[str, Any], client: Optional[httpx.Client] = None) -> dict[str, Any]:
166
+ """Unpaid POST -> 402 -> limits checked -> sign (x402 SDK) -> retry once with PAYMENT-SIGNATURE."""
167
+ try:
168
+ from eth_account import Account
169
+ from x402 import x402ClientSync
170
+ from x402.http import x402HTTPClientSync
171
+ from x402.mechanisms.evm.exact import register_exact_evm_client
172
+ except ImportError as e: # pragma: no cover
173
+ raise AgentSearchError(
174
+ "Paid calls need the x402 SDK: pip install 'langchain-agentsearch[paid]'. Nothing was signed."
175
+ ) from e
176
+
177
+ key = _read_wallet_key()
178
+ if not key:
179
+ raise AgentSearchError(paid_only_message(f"agentsearch_{op}"))
180
+ portal = (os.environ.get("AGENTSEARCH_PORTAL_URL") or DEFAULT_PORTAL_URL).strip().rstrip("/")
181
+ if not portal.startswith("https://") and not re.match(r"http://(localhost|127\.)", portal):
182
+ raise AgentSearchError("AGENTSEARCH_PORTAL_URL must be https://. Nothing was signed.")
183
+ session_cap = _atomic_env(("AGENTSEARCH_MAX_TOTAL_ATOMIC", "POCKET_MAX_TOTAL_ATOMIC"), DEFAULT_SESSION_CAP_ATOMIC)
184
+ per_call = min(_atomic_env(("AGENTSEARCH_MAX_PER_CALL_ATOMIC", "POCKET_MAX_PER_CALL_ATOMIC"), MAX_PER_CALL_ATOMIC), MAX_PER_CALL_ATOMIC)
185
+ service_id, path = SERVICES[op]
186
+ url = f"{portal}/v1/{service_id}{path}"
187
+
188
+ c = client or httpx.Client(timeout=timeout_s())
189
+ try:
190
+ unpaid = c.post(url, json=body)
191
+ if unpaid.status_code != 402:
192
+ raise AgentSearchError(f"Expected 402 from the portal, got {unpaid.status_code}: {unpaid.text[:300]}. Nothing was signed.", unpaid.status_code)
193
+ http = x402HTTPClientSync(x402ClientSync())
194
+ try:
195
+ required = http.get_payment_required_response(lambda n: unpaid.headers.get(n), unpaid.content)
196
+ except Exception as e:
197
+ raise AgentSearchError(f"The portal's 402 is not valid x402 v2 ({e}). Nothing was signed.")
198
+ if getattr(required, "x402_version", None) != 2:
199
+ raise AgentSearchError("Unsupported x402 version. Nothing was signed.")
200
+ res_url = (getattr(getattr(required, "resource", None), "url", "") or "").rstrip("/")
201
+ if not res_url.endswith(f"/v1/{service_id}"):
202
+ raise AgentSearchError(f"The 402 is for resource {res_url}, not {service_id}. Nothing was signed.")
203
+
204
+ with _budget.lock:
205
+ remaining = max(session_cap - _budget.spent, 0)
206
+ ceiling = min(per_call, remaining)
207
+ if ceiling <= 0:
208
+ raise AgentSearchError(
209
+ f"Session spend cap reached: {usd(_budget.spent)} of {usd(session_cap)} signed. "
210
+ "Raise AGENTSEARCH_MAX_TOTAL_ATOMIC to spend more. Nothing was signed."
211
+ )
212
+ chosen = select_accept(list(required.accepts), ceiling)
213
+ amount = int(chosen.amount)
214
+
215
+ def selector(_v: int, options: list[Any]) -> Any:
216
+ for o in options:
217
+ if o.scheme == chosen.scheme and o.network == chosen.network and _eq(o.asset, chosen.asset) and _eq(o.pay_to, chosen.pay_to) and str(o.amount) == str(chosen.amount):
218
+ return o
219
+ raise AgentSearchError("Selected option disappeared. Nothing was signed.")
220
+
221
+ account = Account.from_key(key)
222
+ signer_client = register_exact_evm_client(x402ClientSync(selector), account, networks=BASE_NETWORK)
223
+ payer = x402HTTPClientSync(signer_client)
224
+ signed = payer.create_payment_payload(required)
225
+ auth = (signed.payload or {}).get("authorization") or {}
226
+ if not _eq(auth.get("to"), PORTAL_PAYEE) or int(auth.get("value", 0)) != amount:
227
+ raise AgentSearchError("The signed authorization does not match the approved terms; it was discarded and not sent.")
228
+ _budget.spent += amount # counted at signing
229
+
230
+ try:
231
+ paid = c.post(url, json=body, headers=payer.encode_payment_signature_header(signed))
232
+ except Exception as e:
233
+ raise AgentSearchError(f"A payment of {usd(amount)} was signed but the paid request failed ({e}). Check the wallet before retrying.")
234
+ if not 200 <= paid.status_code < 300:
235
+ raise AgentSearchError(
236
+ f"The portal answered {paid.status_code}: {paid.text[:300]}. Non-2xx answers are not settled; "
237
+ f"the signed {usd(amount)} still counts against this session's cap.", paid.status_code)
238
+ receipt = None
239
+ try:
240
+ receipt = payer.get_payment_settle_response(lambda n: paid.headers.get(n))
241
+ except Exception:
242
+ pass
243
+ tx = getattr(receipt, "transaction", None) if receipt else None
244
+ payment = {
245
+ "amountAtomic": str(amount), "amountUsd": usd(amount), "asset": "USDC", "network": BASE_NETWORK,
246
+ "payTo": PORTAL_PAYEE, "payer": account.address, "serviceId": service_id,
247
+ "settled": bool(getattr(receipt, "success", False)), "transaction": tx,
248
+ }
249
+ if tx:
250
+ payment["explorer"] = f"https://basescan.org/tx/{tx}"
251
+ try:
252
+ env = paid.json()
253
+ except ValueError:
254
+ env = {"raw": paid.text}
255
+ data = env.get("data", env) if isinstance(env, dict) else env
256
+ out = _strip_provider(data) if isinstance(data, dict) else {"data": data}
257
+ return {**out, "payment": payment}
258
+ finally:
259
+ if client is None:
260
+ c.close()
261
+
262
+
263
+ def dumps(v: Any) -> str:
264
+ return json.dumps(v, ensure_ascii=False)
@@ -0,0 +1,81 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Literal, Optional, Type
4
+
5
+ from langchain_core.tools import BaseTool
6
+ from pydantic import BaseModel, Field
7
+
8
+ from . import _client as c
9
+
10
+
11
+ class SearchInput(BaseModel):
12
+ query: str = Field(description="Search query", min_length=1)
13
+ max_results: Optional[int] = Field(default=None, ge=1, le=5, description="1-5, default 5")
14
+
15
+
16
+ class ExtractInput(BaseModel):
17
+ url: str = Field(description="http(s) URL to extract")
18
+ formats: Optional[list[str]] = Field(default=None, description='e.g. ["markdown"]')
19
+ max_chars: Optional[int] = Field(default=None, gt=0)
20
+
21
+
22
+ class RenderInput(BaseModel):
23
+ url: str = Field(description="http(s) URL to render")
24
+ formats: Optional[list[Literal["markdown", "text", "html", "links"]]] = None
25
+ max_chars: Optional[int] = Field(default=None, gt=0)
26
+ screenshot: Optional[bool] = None
27
+ wait_until: Optional[Literal["domcontentloaded", "load", "networkidle"]] = None
28
+ wait_for_selector: Optional[str] = None
29
+ wait_ms: Optional[int] = Field(default=None, ge=0)
30
+
31
+
32
+ def _err(e: Exception) -> str:
33
+ return f"Error: {e}"
34
+
35
+
36
+ class AgentSearchTool(BaseTool):
37
+ """Live web search. Free by default (25/day per user); paid & unlimited when a wallet key is set."""
38
+
39
+ name: str = "agentsearch_search"
40
+ description: str = "Live web search. Returns up to 5 ranked results (title, url, content). Use for current facts or finding URLs."
41
+ args_schema: Type[BaseModel] = SearchInput
42
+ mode: Literal["auto", "free", "paid"] = "auto"
43
+
44
+ def _run(self, query: str, max_results: Optional[int] = None, run_manager: Any = None) -> str:
45
+ try:
46
+ paid = self.mode == "paid" or (self.mode == "auto" and c.has_wallet_key())
47
+ if paid:
48
+ body: dict[str, Any] = {"query": query}
49
+ if max_results:
50
+ body["max_results"] = max_results
51
+ return c.dumps(c.call_portal("search", body))
52
+ return c.dumps(c.free_search(query, max_results))
53
+ except Exception as e:
54
+ return _err(e)
55
+
56
+
57
+ class _PaidTool(BaseTool):
58
+ op: str = ""
59
+
60
+ def _run(self, run_manager: Any = None, **kwargs: Any) -> str:
61
+ try:
62
+ if not c.has_wallet_key():
63
+ raise c.AgentSearchError(c.paid_only_message(self.name))
64
+ body = {k: v for k, v in kwargs.items() if v is not None}
65
+ return c.dumps(c.call_portal(self.op, body))
66
+ except Exception as e:
67
+ return _err(e)
68
+
69
+
70
+ class AgentSearchExtractTool(_PaidTool):
71
+ name: str = "agentsearch_extract"
72
+ description: str = "Fetch a URL and return its main content as clean text/markdown. Paid: $0.005 per call (needs a wallet)."
73
+ args_schema: Type[BaseModel] = ExtractInput
74
+ op: str = "extract"
75
+
76
+
77
+ class AgentSearchRenderTool(_PaidTool):
78
+ name: str = "agentsearch_render"
79
+ description: str = "Render a JavaScript-heavy page in a headless browser and return markdown/text/html/links. Paid: $0.005 per call (needs a wallet)."
80
+ args_schema: Type[BaseModel] = RenderInput
81
+ op: str = "render"
@@ -0,0 +1,26 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "langchain-agentsearch"
7
+ version = "0.1.0"
8
+ description = "LangChain tools for AgentSearch: free live web search, plus pay-per-call extract and JS render ($0.005 USDC on Base via x402)"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.9"
12
+ dependencies = ["langchain-core>=0.3.0,<2.0.0", "httpx>=0.25", "pydantic>=2"]
13
+ keywords = ["langchain", "agentsearch", "web-search", "tools", "agents", "x402"]
14
+
15
+ [project.optional-dependencies]
16
+ paid = ["x402[evm]>=2.0"]
17
+ test = ["pytest>=7"]
18
+
19
+ [project.urls]
20
+ Homepage = "https://agentsearchhq.com/?utm_source=langchain-agentsearch&utm_medium=package"
21
+
22
+ [tool.hatch.build.targets.wheel]
23
+ packages = ["langchain_agentsearch"]
24
+
25
+ [tool.hatch.build.targets.sdist]
26
+ include = ["langchain_agentsearch", "tests", "README.md", "LICENSE", "pyproject.toml"]
@@ -0,0 +1,106 @@
1
+ import json
2
+ import os
3
+
4
+ import httpx
5
+ import pytest
6
+
7
+ from langchain_agentsearch import AgentSearchExtractTool, AgentSearchRenderTool, AgentSearchTool, FREE_LIMIT_MESSAGE
8
+ from langchain_agentsearch import _client
9
+
10
+
11
+ @pytest.fixture(autouse=True)
12
+ def no_wallet(monkeypatch):
13
+ for k in _client.KEY_ENV_VARS:
14
+ monkeypatch.delenv(k, raising=False)
15
+
16
+
17
+ def mock(monkeypatch, status, body):
18
+ seen = {}
19
+
20
+ def handler(req):
21
+ seen["url"] = str(req.url)
22
+ seen["body"] = json.loads(req.content)
23
+ return httpx.Response(status, json=body)
24
+
25
+ real = httpx.Client
26
+ monkeypatch.setattr(_client.httpx, "Client", lambda **kw: real(transport=httpx.MockTransport(handler)))
27
+ return seen
28
+
29
+
30
+ def test_names():
31
+ assert [t().name for t in (AgentSearchTool, AgentSearchExtractTool, AgentSearchRenderTool)] == [
32
+ "agentsearch_search", "agentsearch_extract", "agentsearch_render"]
33
+
34
+
35
+ def test_free_search_mocked(monkeypatch):
36
+ seen = mock(monkeypatch, 200, {"provider": "p", "results": [{"title": "T", "url": "https://a"}]})
37
+ out = json.loads(AgentSearchTool().invoke({"query": "x", "max_results": 1}))
38
+ assert seen["url"] == "https://relay.agentsearchhq.com/v1/search"
39
+ assert seen["body"] == {"query": "x", "max_results": 1}
40
+ assert "provider" not in out and out["results"][0]["title"] == "T"
41
+
42
+
43
+ def test_429(monkeypatch):
44
+ mock(monkeypatch, 429, {})
45
+ assert AgentSearchTool().invoke({"query": "x"}) == f"Error: {FREE_LIMIT_MESSAGE}"
46
+
47
+
48
+ def test_paid_without_wallet():
49
+ assert "No wallet" in AgentSearchExtractTool().invoke({"url": "https://example.com"})
50
+ assert "No wallet" in AgentSearchRenderTool().invoke({"url": "https://example.com"})
51
+
52
+
53
+ def test_bad_key_not_leaked(monkeypatch):
54
+ monkeypatch.setenv("AGENTSEARCH_WALLET_PRIVATE_KEY", "nothex-secret")
55
+ out = AgentSearchExtractTool().invoke({"url": "https://example.com"})
56
+ assert "not a private key" in out and "nothex-secret" not in out
57
+
58
+
59
+ def test_select_accept_caps():
60
+ class A:
61
+ scheme = "exact"; network = _client.BASE_NETWORK; asset = _client.USDC_BASE; pay_to = _client.PORTAL_PAYEE; amount = "6000"
62
+ with pytest.raises(_client.AgentSearchError):
63
+ _client.select_accept([A()], 5000)
64
+ A.amount = "5000"
65
+ assert _client.select_accept([A()], 5000).amount == "5000"
66
+
67
+
68
+ def test_tool_call_message(monkeypatch):
69
+ mock(monkeypatch, 200, {"results": []})
70
+ msg = AgentSearchTool().invoke({"type": "tool_call", "id": "1", "name": "agentsearch_search", "args": {"query": "x"}})
71
+ assert msg.tool_call_id == "1"
72
+
73
+
74
+ @pytest.mark.skipif(not os.environ.get("AGENTSEARCH_LIVE"), reason="set AGENTSEARCH_LIVE=1")
75
+ def test_live_free_search():
76
+ out = json.loads(AgentSearchTool(mode="free").invoke({"query": "LangChain python tools", "max_results": 2}))
77
+ assert out["results"] and out["results"][0]["url"].startswith("http")
78
+
79
+
80
+ def test_paid_flow_signs_with_x402_sdk(monkeypatch):
81
+ """Mocked portal: 402 -> SDK signs -> 200. Uses a throwaway random key; no network."""
82
+ pytest.importorskip("x402")
83
+ from eth_account import Account
84
+ from x402.http.utils import encode_payment_required_header
85
+ from x402.schemas import PaymentRequired, PaymentRequirements, ResourceInfo
86
+
87
+ _client.reset_session_budget()
88
+ monkeypatch.setenv("AGENTSEARCH_WALLET_PRIVATE_KEY", Account.create().key.hex())
89
+ req = PaymentRequired(x402_version=2, resource=ResourceInfo(url="https://agent.pocket.network/v1/agentsearch-web-extract-v1"),
90
+ accepts=[PaymentRequirements(scheme="exact", network=_client.BASE_NETWORK, asset=_client.USDC_BASE, amount="5000",
91
+ pay_to=_client.PORTAL_PAYEE, max_timeout_seconds=60, extra={"name": "USD Coin", "version": "2"})])
92
+ calls = []
93
+
94
+ def handler(r):
95
+ calls.append(r)
96
+ if "payment-signature" not in r.headers:
97
+ return httpx.Response(402, headers={"PAYMENT-REQUIRED": encode_payment_required_header(req)}, json={})
98
+ return httpx.Response(200, json={"portal": {}, "data": {"url": "https://example.com", "content": "hi"}})
99
+
100
+ real = httpx.Client
101
+ monkeypatch.setattr(_client.httpx, "Client", lambda **kw: real(transport=httpx.MockTransport(handler)))
102
+ out = json.loads(AgentSearchExtractTool().invoke({"url": "https://example.com"}))
103
+ assert out["content"] == "hi" and out["payment"]["amountAtomic"] == "5000"
104
+ assert len(calls) == 2 and _client.session_spent_atomic() == 5000
105
+ monkeypatch.setenv("AGENTSEARCH_MAX_TOTAL_ATOMIC", "5000")
106
+ assert "cap reached" in AgentSearchExtractTool().invoke({"url": "https://example.com"})