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.
- langchain_agentsearch-0.1.0/LICENSE +21 -0
- langchain_agentsearch-0.1.0/PKG-INFO +80 -0
- langchain_agentsearch-0.1.0/README.md +62 -0
- langchain_agentsearch-0.1.0/langchain_agentsearch/__init__.py +13 -0
- langchain_agentsearch-0.1.0/langchain_agentsearch/_client.py +264 -0
- langchain_agentsearch-0.1.0/langchain_agentsearch/py.typed +0 -0
- langchain_agentsearch-0.1.0/langchain_agentsearch/tools.py +81 -0
- langchain_agentsearch-0.1.0/pyproject.toml +26 -0
- langchain_agentsearch-0.1.0/tests/test_tools.py +106 -0
|
@@ -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)
|
|
File without changes
|
|
@@ -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"})
|